Skip to content

docs: make every documented af-stack invocation match the CLI (29 audit fixes) #173

docs: make every documented af-stack invocation match the CLI (29 audit fixes)

docs: make every documented af-stack invocation match the CLI (29 audit fixes) #173

Workflow file for this run

name: CI
# CI runs automatically on pushes to main and on PRs targeting main.
# The fast gates (build, lint, unit tests, compose/deploy config validation,
# docs) run on every PR. The heavy/real-infra deploy smokes (helm-kind,
# fly-staging, prod-compose) are OPT-IN: they only run on manual dispatch or on
# a PR carrying the `deploy-smoke` label, so ordinary PRs never spin up clusters,
# deploy to Fly, or require the full prod secret set.
on:
workflow_dispatch:
inputs:
run_helm_kind_smoke:
description: "Run the full Helm kind deploy smoke test"
required: false
default: "true"
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
go: ${{ steps.filter.outputs.go }}
python: ${{ steps.filter.outputs.python }}
typescript: ${{ steps.filter.outputs.typescript }}
docs: ${{ steps.filter.outputs.docs }}
docs_site: ${{ steps.filter.outputs.docs_site }}
compose: ${{ steps.filter.outputs.compose }}
deploy: ${{ steps.filter.outputs.deploy }}
images: ${{ steps.filter.outputs.images }}
install_script: ${{ steps.filter.outputs.install_script }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
go:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
# secrets.TestEnvExampleBootsKMS pins the quickstart contract
# that the committed example env boots the runtime.
- '.env.example'
python:
- '**/*.py'
- 'pyproject.toml'
- 'packages/sdk-py/**'
- 'apps/backend/**'
typescript:
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- 'pnpm-workspace.yaml'
- 'packages/sdk-ts/**'
- 'apps/dashboard/**'
- 'scripts/generate-brand.mjs'
- 'brand.yaml'
docs:
- '**/*.md'
- 'docs/**'
docs_site:
- 'docs-site/**'
- 'docs/**'
compose:
- 'docker-compose*.yml'
- 'scripts/postgres-init.sh'
deploy:
- '.github/workflows/ci.yml'
- 'deploy/**'
- 'docker-compose*.yml'
- 'scripts/validate-deploy-targets.py'
- 'scripts/test-helm-kind.sh'
- 'scripts/test-fly-staging.sh'
- 'scripts/test-prod-compose-smoke.sh'
images:
- 'apps/dashboard/Dockerfile'
- 'apps/customer-app/Dockerfile'
- 'scripts/generate-brand.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
install_script:
- 'scripts/install.sh'
- '.github/workflows/ci.yml'
lint-go:
name: Lint (Go)
needs: changes
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# only-new-issues needs the base ref to diff against.
fetch-depth: 0
- uses: actions/setup-go@v5
with:
# Match the toolchain go.mod requires (go 1.26.x). Pinning an older
# version forced a GOTOOLCHAIN auto-download whose tool set was
# missing `covdata`, breaking `go test -cover` on no-test packages.
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: go build
run: go build ./...
# golangci-lint v2 (S7): restored as a blocking gate in only-new-issues
# mode — the pre-existing backlog is not gated, but any NEW issue a PR
# introduces fails the build. Run ENFORCE via `golangci-lint run`
# locally to see the full backlog.
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
# v2.13.1 is built with go1.27 and is the first release that can
# typecheck a go 1.26 module. v2.5.0 (go1.25) panics with
# "package requires newer Go version go1.26".
version: v2.13.1
# Compute NEW issues from git, not the GitHub diff API. The action's
# only-new-issues path fetches the PR patch from the API, which has a
# hard 20k-line ceiling ("diff too large") — a large PR then trips it
# and the action falls back to reporting the entire ~106-issue
# backlog. `--new-from-merge-base` does the identical filtering via
# git (fetch-depth: 0 above provides origin/main), with no ceiling:
# only issues on lines this PR changed relative to the merge base
# fail the build. Same gate, robust to PR size.
only-new-issues: false
args: --new-from-merge-base=origin/main
test-go:
name: Test (Go)
needs: changes
if: needs.changes.outputs.go == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
with:
# Match the toolchain go.mod requires (go 1.26.x). Pinning an older
# version forced a GOTOOLCHAIN auto-download whose tool set was
# missing `covdata`, breaking `go test -cover` on no-test packages.
go-version-file: go.mod
cache: true
- run: go test ./... -race -cover
lint-python:
name: Lint (Python)
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- run: uv python install 3.11
# Pin ruff: its formatter output drifts across releases, so an unpinned
# `ruff` would fail `format --check` whenever a new version ships. The
# baseline is formatted with this exact version.
- run: uv tool install ruff==0.15.20
- run: ruff check .
- run: ruff format --check .
test-python:
name: Test (Python)
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- run: uv python install 3.11
- run: uv sync --all-packages
continue-on-error: true # OK while packages are empty
- run: |
if find packages/sdk-py/tests apps/backend/tests -name 'test_*.py' 2>/dev/null | grep -q .; then
uv run pytest -q
else
echo "No Python tests yet"
fi
lint-typescript:
name: Lint (TypeScript)
needs: changes
if: needs.changes.outputs.typescript == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Brand codegen (js-yaml ESM import)
run: node scripts/generate-brand.mjs
- run: pnpm -r lint || echo "no TS lint scripts yet"
- run: pnpm exec prettier --check "**/*.{ts,tsx,js,jsx,json,md,yaml,yml}" || true
test-typescript:
name: Test (TypeScript)
needs: changes
if: needs.changes.outputs.typescript == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm -r test || echo "no TS tests yet"
dco:
name: DCO
# PR-only: GitHub merge commits on main are not DCO-signed. The
# required check is the PR range against origin/main.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Verify Signed-off-by on PR commits
env:
BASE_REF: origin/${{ github.base_ref }}
run: scripts/check-dco.sh "$BASE_REF"
validate-compose:
name: Validate docker-compose
needs: changes
if: needs.changes.outputs.compose == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Validate compose syntax
run: |
docker compose config --quiet
- name: Validate prod compose
run: |
if [ -f docker-compose.prod.yml ]; then
docker compose -f docker-compose.yml -f docker-compose.prod.yml config --quiet
fi
validate-deploy-targets:
name: Validate deploy targets
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: azure/setup-helm@v4
- name: Validate Helm, Fly, Railway, Render, and prod compose
run: scripts/validate-deploy-targets.py
install-script:
# The first line of the README quickstart is `curl … install.sh | bash`.
# Lint it and actually run it — against the real latest release in the
# shapes users hit (piped from stdin, from a file, pinned with and
# without the v prefix), and against a local fake release to prove the
# checksum gate fails closed. Version assertions are exact: a resolver
# that silently picks the wrong tag must go red here.
name: Install script
needs: changes
if: needs.changes.outputs.install_script == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ github.token }}
MIRROR: http://127.0.0.1:8765
steps:
- uses: actions/checkout@v7
- name: Lint
run: |
bash -n scripts/install.sh
shellcheck scripts/install.sh
- name: Resolve the two newest releases
# Via gh (authenticated), deliberately NOT via the same redirect the
# script uses, so the assertions below are independent of it.
run: |
latest="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
prev="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 10 --json tagName -q '.[1].tagName')"
[ -n "$latest" ] || { echo "could not resolve the latest release" >&2; exit 1; }
echo "latest=$latest prev=${prev:-<none>}"
{ echo "LATEST=$latest"; echo "PREV=$prev"; } >> "$GITHUB_ENV"
- name: Install latest (piped, like the README one-liner)
run: |
AF_STACK_INSTALL_DIR="$RUNNER_TEMP/piped" bash -c 'cat scripts/install.sh | bash'
"$RUNNER_TEMP/piped/af-stack" version | grep -Fx "af-stack ${LATEST#v}"
- name: Install latest (from file)
run: |
AF_STACK_INSTALL_DIR="$RUNNER_TEMP/file" bash scripts/install.sh
"$RUNNER_TEMP/file/af-stack" version | grep -Fx "af-stack ${LATEST#v}"
- name: Install the previous release, pinned (v-prefixed and bare)
# Pinning the *previous* release means ignoring the pin can never
# pass, and the step never needs a hand-bumped version.
run: |
if [ -z "$PREV" ]; then echo "only one release exists; nothing to pin"; exit 0; fi
AF_STACK_VERSION="$PREV" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned" bash scripts/install.sh
"$RUNNER_TEMP/pinned/af-stack" version | grep -Fx "af-stack ${PREV#v}"
AF_STACK_VERSION="${PREV#v}" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned-bare" bash scripts/install.sh
"$RUNNER_TEMP/pinned-bare/af-stack" version | grep -Fx "af-stack ${PREV#v}"
- name: Serve a local fake release
run: |
mkdir -p "$RUNNER_TEMP/release" && cd "$RUNNER_TEMP/release"
gh release download "$LATEST" --repo "$GITHUB_REPOSITORY" --pattern '*_linux_amd64.tar.gz' --pattern 'checksums.txt'
cp checksums.txt checksums.good
nohup python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &
for _ in $(seq 1 40); do curl -fs "$MIRROR/checksums.txt" >/dev/null && break; sleep 0.25; done
curl -fs "$MIRROR/checksums.txt" >/dev/null
- name: Reject a tampered checksums.txt (real script, nothing installed)
run: |
cd "$RUNNER_TEMP/release"
sed -E 's/^[0-9a-f]{64}/0000000000000000000000000000000000000000000000000000000000000000/' checksums.good > checksums.txt
if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/tampered" \
bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then
echo "installer accepted a tampered checksum" >&2; cat err.txt; exit 1
fi
grep -q 'checksum verification failed' err.txt
[ ! -e "$RUNNER_TEMP/tampered/af-stack" ]
- name: Refuse to install when checksums.txt is unreachable, unless overridden
run: |
cd "$RUNNER_TEMP/release" && rm -f checksums.txt
if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/nochecksums" \
bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then
echo "installer proceeded without checksums.txt" >&2; cat err.txt; exit 1
fi
grep -q 'HTTP 404' err.txt
[ ! -e "$RUNNER_TEMP/nochecksums/af-stack" ]
AF_STACK_SKIP_CHECKSUM=1 AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/skipped" \
bash "$GITHUB_WORKSPACE/scripts/install.sh"
"$RUNNER_TEMP/skipped/af-stack" version | grep -Fx "af-stack ${LATEST#v}"
- name: Accept the correct checksums.txt from the mirror
run: |
cd "$RUNNER_TEMP/release" && cp checksums.good checksums.txt
AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/mirror" \
bash "$GITHUB_WORKSPACE/scripts/install.sh"
"$RUNNER_TEMP/mirror/af-stack" version | grep -Fx "af-stack ${LATEST#v}"
build-app-images:
name: Build app images
needs: changes
# Catch release-image breakage (brand codegen, lockfile, Dockerfile)
# before workflow_run/Release tries to push to GHCR.
if: needs.changes.outputs.images == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: dashboard
dockerfile: apps/dashboard/Dockerfile
- name: customer-app
dockerfile: apps/customer-app/Dockerfile
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.name }} (no push)
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
push: false
tags: af-stack-${{ matrix.name }}:ci
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
provenance: false
helm-kind-smoke:
name: Helm kind smoke
needs: changes
# Opt-in only: manual dispatch (with the input set) or a `deploy-smoke`-labeled PR.
if: needs.changes.outputs.deploy == 'true' && ((github.event_name == 'workflow_dispatch' && github.event.inputs.run_helm_kind_smoke == 'true') || contains(github.event.pull_request.labels.*.name, 'deploy-smoke'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: azure/setup-helm@v4
- uses: helm/kind-action@v1
with:
install_only: true
- name: Install kubectl
uses: azure/setup-kubectl@v5
- name: Deploy chart to kind and probe health endpoints
run: scripts/test-helm-kind.sh
fly-staging-smoke:
name: Fly staging smoke
needs: changes
# Opt-in only: deploys to REAL Fly infra (needs FLY_API_TOKEN), so never on
# an ordinary PR. Manual dispatch or a `deploy-smoke`-labeled PR.
if: needs.changes.outputs.deploy == 'true' && (github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'deploy-smoke'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy Fly staging apps and probe health endpoints
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
AF_STACK_FLY_STAGING_RUNTIME_APP: ${{ vars.AF_STACK_FLY_STAGING_RUNTIME_APP }}
AF_STACK_FLY_STAGING_DASHBOARD_APP: ${{ vars.AF_STACK_FLY_STAGING_DASHBOARD_APP }}
AF_STACK_FLY_STAGING_REGION: ${{ vars.AF_STACK_FLY_STAGING_REGION }}
AF_STACK_FLY_STAGING_RUNTIME_URL: ${{ vars.AF_STACK_FLY_STAGING_RUNTIME_URL }}
AF_STACK_FLY_STAGING_DASHBOARD_URL: ${{ vars.AF_STACK_FLY_STAGING_DASHBOARD_URL }}
run: scripts/test-fly-staging.sh
prod-compose-smoke:
name: Production compose smoke
needs: changes
# Opt-in only: needs the full prod secret set; absent secrets => guaranteed
# red on ordinary PRs. Manual dispatch or a `deploy-smoke`-labeled PR.
if: needs.changes.outputs.deploy == 'true' && (github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'deploy-smoke'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Bring up production compose and probe services
env:
AF_STACK_PROD_COMPOSE_SMOKE: ${{ vars.AF_STACK_PROD_COMPOSE_SMOKE }}
AF_STACK_DOMAIN: ${{ vars.AF_STACK_PROD_COMPOSE_DOMAIN }}
ACME_EMAIL: ${{ vars.AF_STACK_PROD_COMPOSE_ACME_EMAIL }}
AF_STACK_DATABASE_URL: ${{ secrets.AF_STACK_PROD_COMPOSE_DATABASE_URL }}
AGENTFIELD_STORAGE_POSTGRES_URL: ${{ secrets.AGENTFIELD_STORAGE_POSTGRES_URL }}
AF_STACK_KMS_KEY: ${{ secrets.AF_STACK_PROD_COMPOSE_KMS_KEY }}
AF_STACK_AUTH_SECRET: ${{ secrets.AF_STACK_PROD_COMPOSE_AUTH_SECRET }}
AF_STACK_S3_ENDPOINT: ${{ vars.AF_STACK_PROD_COMPOSE_S3_ENDPOINT }}
AF_STACK_S3_BUCKET: ${{ vars.AF_STACK_PROD_COMPOSE_S3_BUCKET }}
AF_STACK_S3_ACCESS_KEY: ${{ secrets.AF_STACK_PROD_COMPOSE_S3_ACCESS_KEY }}
AF_STACK_S3_SECRET_KEY: ${{ secrets.AF_STACK_PROD_COMPOSE_S3_SECRET_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
run: scripts/test-prod-compose-smoke.sh
docs-lint:
name: Lint docs
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Check for broken internal links
run: |
# Simple check for obviously broken markdown links
! grep -rn '](\.\./\.\./\.\.' --include='*.md' . || (echo "Suspicious path traversal in docs" && exit 1)
build-docs-site:
name: Build docs-site (Astro Starlight)
needs: changes
if: needs.changes.outputs.docs_site == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs-site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
# Astro 5 requires Node >=22.12.0; the docs-site build fails on 20.
node-version: "22"
cache: "npm"
cache-dependency-path: docs-site/package-lock.json
- name: Install
run: npm install
- name: Build
run: npm run build
- name: Verify build output
run: |
# Build must produce a non-trivial static site. Threshold is loose
# so adding/removing one or two pages doesn't fail CI, but a
# broken collection that drops most pages does.
pages=$(find dist -name '*.html' -type f | wc -l)
echo "Built $pages HTML pages"
if [ "$pages" -lt 15 ]; then
echo "Expected at least 15 pages, got $pages"
exit 1
fi
ci-success:
name: CI Success
needs:
[
lint-go,
test-go,
lint-python,
test-python,
lint-typescript,
test-typescript,
validate-compose,
validate-deploy-targets,
install-script,
helm-kind-smoke,
fly-staging-smoke,
prod-compose-smoke,
docs-lint,
build-docs-site,
dco,
build-app-images,
]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check all jobs
run: |
# This job aggregates results so branch protection can require one check.
# If any required job fails, this job fails.
results='${{ toJSON(needs) }}'
echo "Job results: $results"
# Allow skipped (due to path filters) but not failed
echo "$results" | jq -e 'all(.[]; .result == "success" or .result == "skipped")'