docs: make every documented af-stack invocation match the CLI (29 audit fixes) #173
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")' |