Repository navigation
chore(auth): enumerate permission actions and resources, and check the TS unions against them #1905
Workflow file for this run
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
| # CI Workflow - Build, Test, Lint, Security | |
| # | |
| # This workflow runs on every pull request and push to main/develop branches. | |
| # It performs comprehensive quality checks before allowing code to be merged. | |
| # | |
| # Required GitHub Secrets: None (all checks run without cloud credentials) | |
| # | |
| # Required GitHub Variables: | |
| # - GO_VERSION: Go version to use (default: 1.26.10) | |
| # | |
| # Triggered by: | |
| # - Pull requests to main/develop | |
| # - Pushes to main/develop | |
| # - Manual workflow dispatch | |
| # | |
| # Runners: the Go and Docker heavy jobs (lint, unit, integration, the | |
| # selection self-test, security scanning, e2e) run on ubuntu-24.04-arm. | |
| # On main's runs the same cold Dockerfile build took 124-142s there against | |
| # 201-207s on ubuntu-latest, and `go install govulncheck` under 3s against | |
| # 12-16s. arm64 is also what the AWS deploys run (Graviton). The amd64 image | |
| # leg stays on ubuntu-latest, as GCP and Azure run amd64. Snyk (a Docker | |
| # action), Terraform (provider lock files) and the jobs that finish in | |
| # seconds stay there too: nothing to gain. Every cache key in this file | |
| # includes the runner architecture, so neither architecture restores the | |
| # other's Go build cache or binaries. | |
| name: CI - Build & Test | |
| permissions: | |
| contents: read | |
| on: | |
| pull_request: | |
| branches: [main, develop] | |
| push: | |
| branches: [main, develop] | |
| workflow_dispatch: | |
| env: | |
| GO_VERSION: '1.26.10' | |
| DOCKER_BUILDKIT: 1 | |
| COMPOSE_DOCKER_CLI_BUILD: 1 | |
| jobs: | |
| # Go code linting | |
| lint: | |
| name: Lint Code | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| cache: true | |
| - name: Install golangci-lint | |
| uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 | |
| with: | |
| version: v2.10.1 | |
| install-only: true | |
| - name: Verify golangci-lint config against vendored schema | |
| # `golangci-lint config verify` downloads its JSON schema from | |
| # golangci-lint.run by default, with a hard-coded 2s HTTP timeout, and | |
| # that fetch timing out failed this job at random (#977). The schema is | |
| # vendored instead and passed through the (hidden) --schema flag, so | |
| # the config is still validated on every run, with no network access. | |
| # | |
| # Vendored file: .github/golangci-lint/golangci.v<major>.<minor>.jsonschema.json | |
| # source: https://golangci-lint.run/jsonschema/golangci.v2.10.jsonschema.json | |
| # (byte-identical to jsonschema/golangci.next.jsonschema.json | |
| # at golangci-lint tag v2.10.1, the copy this file came from) | |
| # sha256: GOLANGCI_LINT_SCHEMA_SHA256 below | |
| # | |
| # When bumping `version:` above to a new minor, this step fails until | |
| # the matching schema is vendored. To update: download the URL above | |
| # with the new minor, save it under the new file name, delete the old | |
| # one, and set GOLANGCI_LINT_SCHEMA_SHA256 to its sha256sum. | |
| env: | |
| GOLANGCI_LINT_SCHEMA_SHA256: '692e8da79dc1f110a4a4f3bb841b26ed44e461d3503678eaaae90ae8876e3403' | |
| run: | | |
| set -euo pipefail | |
| version=$(golangci-lint version --short) | |
| minor="${version#v}" | |
| minor="${minor%.*}" | |
| schema=".github/golangci-lint/golangci.v${minor}.jsonschema.json" | |
| if [ ! -f "$schema" ]; then | |
| echo "::error::no vendored schema for golangci-lint ${version}: expected ${schema} (see the update steps in ci.yml)" | |
| exit 1 | |
| fi | |
| echo "${GOLANGCI_LINT_SCHEMA_SHA256} ${schema}" | sha256sum -c - | |
| # One check covers both modules: tests/e2e has no config of its own, | |
| # and golangci-lint finds the root .golangci.yml from there too. | |
| golangci-lint config verify --schema "file://${PWD}/${schema}" | |
| - name: Run golangci-lint | |
| run: | | |
| set -uo pipefail | |
| export GOLANGCI_LINT_CACHE="$HOME/.cache/golangci-lint" | |
| status=0 | |
| for mod in . tests/e2e; do | |
| echo "==> golangci-lint in $mod" | |
| args=(run --timeout=10m) | |
| # The standalone e2e module has only e2e-tagged test files. | |
| if [ "$mod" = "tests/e2e" ]; then | |
| args+=(--build-tags=e2e) | |
| fi | |
| if ! (cd "$mod" && golangci-lint "${args[@]}" ./...); then | |
| status=1 | |
| fi | |
| done | |
| exit "$status" | |
| - name: Run go vet | |
| run: | | |
| set -uo pipefail | |
| status=0 | |
| for mod in . tests/e2e; do | |
| echo "==> go vet in $mod" | |
| args=(vet) | |
| if [ "$mod" = "tests/e2e" ]; then | |
| args+=(-tags=e2e) | |
| fi | |
| if ! (cd "$mod" && go "${args[@]}" ./...); then | |
| status=1 | |
| fi | |
| done | |
| exit "$status" | |
| - name: Install gocyclo | |
| run: go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 | |
| - name: Check cyclomatic complexity | |
| run: | | |
| echo "Checking for functions with cyclomatic complexity over 10..." | |
| # Exclude _test.go files to stay consistent with .golangci.yml, which | |
| # excludes gocyclo on test files (test helpers/table-driven tests are | |
| # allowed higher complexity). Production code is still gated at >10. | |
| COMPLEXITY_ISSUES=$(gocyclo -over 10 -ignore "_test\.go" . 2>&1 || true) | |
| if [ -n "$COMPLEXITY_ISSUES" ]; then | |
| echo "❌ Found functions with cyclomatic complexity over 10:" | |
| echo "$COMPLEXITY_ISSUES" | |
| echo "" | |
| echo "⚠️ Please refactor these functions to reduce complexity." | |
| echo "📖 Tip: Extract helper functions, use early returns, or simplify logic." | |
| exit 1 | |
| fi | |
| echo "✅ All functions have acceptable cyclomatic complexity (≤10)" | |
| # GitHub Actions workflow linting | |
| # | |
| # Nothing else in this repo reads .github/workflows/ for defects. govulncheck | |
| # and gosec are Go source scanners, trivy-config targets Terraform/Dockerfile/ | |
| # Kubernetes, and check-yaml only proves the YAML parses. That gap is why the | |
| # rollback.yml and deploy-aws-lambda.yml expression injections (#1542, #1649), | |
| # both reaching production cloud credentials, passed every CI run. | |
| # | |
| # Both linters are needed and neither substitutes for the other: | |
| # - actionlint catches workflow-level defects and, via shellcheck, shell | |
| # bugs inside run: blocks. | |
| # - zizmor has a template-injection audit that names the injection itself. | |
| # Measured against the pre-fix rollback.yml, actionlint exited 1 only on | |
| # unrelated SC2086 noise and never flagged the injected heredoc at all; | |
| # zizmor flagged that exact line high severity, high confidence. actionlint | |
| # alone would not have caught the bug this job exists to prevent. | |
| workflow-lint: | |
| name: Lint Workflows | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| env: | |
| # Pinned by digest, not only by tag. A tag is mutable, and a linter whose | |
| # ruleset changes without a change in this repo turns main red on its own | |
| # schedule -- the hadolint :latest failure in #1695. | |
| ACTIONLINT_IMAGE: 'rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667' | |
| ZIZMOR_VERSION: '1.29.0' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert there are workflows to lint | |
| run: | | |
| set -euo pipefail | |
| count=$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d ' ') | |
| # Defence in depth, not the only guard: both linters do exit 3 on an | |
| # empty input set. This catches the case they cannot, where the path | |
| # still resolves but the set silently shrinks, and it prints the count | |
| # so a drop is visible in the log rather than inferred from silence. | |
| if [ "$count" -eq 0 ]; then | |
| echo "::error::no workflow files found under .github/workflows" | |
| exit 1 | |
| fi | |
| echo "Linting $count workflow files" | |
| - name: Assert shellcheck is available to actionlint | |
| # actionlint does not fail when shellcheck is missing from PATH: it | |
| # silently skips every run: block and still exits 0. That silent skip is | |
| # the failure mode this job exists to close, so assert the binary is | |
| # present rather than trusting the image to keep bundling it. | |
| run: | | |
| set -euo pipefail | |
| docker run --rm --entrypoint sh "$ACTIONLINT_IMAGE" -c ' | |
| command -v shellcheck >/dev/null || { | |
| echo "::error::shellcheck is not present in the actionlint image; shell linting would be silently skipped" | |
| exit 1 | |
| } | |
| shellcheck --version | sed -n "1,3p"' | |
| - name: Run actionlint | |
| # No file arguments: actionlint discovers .github/workflows itself, so | |
| # it also covers .yaml files and any workflow added later. Passing an | |
| # explicit *.yml glob would silently skip a .yaml workflow that the | |
| # count step above still counts. | |
| run: | | |
| set -euo pipefail | |
| docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT_IMAGE" -color | |
| - name: Run zizmor | |
| # --offline on purpose: the online audits query the GitHub API for action | |
| # metadata, so findings could change without a change in this repo and | |
| # redden main, the same class of failure the hadolint digest pin fixed. | |
| # | |
| # Coverage note: zizmor reads the directory non-recursively, while | |
| # actionlint walks it. A workflow under .github/workflows/sub/ would | |
| # therefore reach actionlint but not zizmor. GitHub itself ignores | |
| # workflows in subdirectories, so this is not a live hole, and the | |
| # -maxdepth 1 count above fails loud if the set ever moves down a level. | |
| # | |
| # Two independent filters, and it matters which does what. | |
| # | |
| # --persona=pedantic rather than the default regular: regular hides | |
| # three high-severity findings this repo actually had (workflow-level | |
| # id-token: write in both sanity workflows, and an unpinned postgres | |
| # service image). Those are fixed rather than filtered, so the stricter | |
| # persona costs nothing today and gates more. auditor is the one level | |
| # up and is documented as accepting false positives, so it is not used. | |
| # | |
| # --min-severity=medium is a threshold, not a suppression: no baseline | |
| # file, no per-finding ignore, no only-new-issues. Every medium and | |
| # high finding the pedantic persona surfaces fails this job, and the | |
| # injection class this job exists to catch scores high. Below the line | |
| # sit 106 findings, none above low: 66 template-injection on values | |
| # #1649 already assessed as non-injectable (github.actor, github.sha | |
| # and similar), plus undocumented-permissions, concurrency-limits and | |
| # anonymous-definition. Clearing those means rewriting the deploy | |
| # workflows, so they are left to a follow-up rather than silenced here. | |
| run: | | |
| set -euo pipefail | |
| # `pipx run --spec` rather than `pipx install`: it pins the version in | |
| # the same statement that invokes it and does not assume pipx's bin | |
| # directory is on PATH. | |
| pipx run --spec "zizmor==${ZIZMOR_VERSION}" zizmor \ | |
| --offline --persona=pedantic --min-severity=medium \ | |
| --color=always .github/workflows/ | |
| # Unit tests with race detection | |
| unit-tests: | |
| name: Unit Tests | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| cache: true | |
| - name: Download dependencies | |
| run: | | |
| # Verify both platform-owned modules; libraries have their own CI. | |
| set -e | |
| for mod in . tests/e2e; do | |
| echo "==> go mod download/verify in $mod" | |
| (cd "$mod" && go mod download && go mod verify) | |
| done | |
| - name: Run unit tests (all modules) | |
| run: | | |
| # Each ./... walks one module. Check both platform-owned modules. | |
| # | |
| # A failing module must not abort the loop. Aborting would hide every | |
| # later module's result behind the first failure, which is the same | |
| # shape as the gosec bug in issue #1717. Guard each run, record the | |
| # status, and fail at the end so every module is reported. | |
| set -uo pipefail | |
| status=0 | |
| for mod in . tests/e2e; do | |
| tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/') | |
| log="$RUNNER_TEMP/unit-${tag}.log" | |
| # tests/e2e holds only //go:build e2e files, so `go test ./...` | |
| # there matches no packages and exits 1 -- it cannot be run like the | |
| # others. Its tests need the running stack and are executed by the | |
| # e2e-tests job over docker compose; what this job can add is a | |
| # type-check under that tag, which nothing else here does. Handled | |
| # by name, not by a pattern, so the difference is visible in review. | |
| if [ "$mod" = "tests/e2e" ]; then | |
| echo "==> type-check $mod under -tags=e2e (tests run in the e2e-tests job)" | |
| if ! (cd "$mod" && go vet -tags=e2e ./...) 2>&1 | tee "$log"; then | |
| echo "::error::$mod failed to type-check under -tags=e2e" | |
| status=1 | |
| fi | |
| continue | |
| fi | |
| echo "==> unit tests in $mod" | |
| # Profiles go to $RUNNER_TEMP, not the checkout: never leave working | |
| # files in the repository root (repo coding guideline). | |
| if ! (cd "$mod" && go test -v -race -short \ | |
| -coverprofile="$RUNNER_TEMP/coverage-${tag}.out" \ | |
| -covermode=atomic ./...) 2>&1 | tee "$log"; then | |
| echo "::error::unit tests failed in $mod" | |
| status=1 | |
| fi | |
| # A module that runs zero tests is issue #1751 wearing a new | |
| # costume: the loop would "cover" it while asserting nothing. Count | |
| # top-level results (subtest lines are indented) and fail loudly. | |
| ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true) | |
| echo "==> $mod ran $ran top-level test(s)" | |
| if [ "$ran" -eq 0 ]; then | |
| echo "::error::$mod ran zero tests -- it is in the loop but asserting nothing" | |
| status=1 | |
| fi | |
| done | |
| # Merge the per-module profiles into the single file the steps below | |
| # expect. A Go profile is one "mode:" header followed by block lines, | |
| # so keep one header and concatenate the bodies. | |
| shopt -s nullglob | |
| profiles=("$RUNNER_TEMP"/coverage-*.out) | |
| if [ "${#profiles[@]}" -eq 0 ]; then | |
| echo "::error::no module produced a coverage profile" >&2 | |
| exit 1 | |
| fi | |
| # Take the mode header from the first profile rather than hardcoding | |
| # it: go test picks the default covermode itself (atomic whenever | |
| # -race is on), so a literal here would silently mislabel the merge if | |
| # the flags change. | |
| merged="$RUNNER_TEMP/coverage.out" | |
| head -n 1 "${profiles[0]}" > "$merged" | |
| for p in "${profiles[@]}"; do | |
| tail -n +2 "$p" >> "$merged" | |
| done | |
| echo "Merged ${#profiles[@]} coverage profile(s), $(( $(wc -l < "$merged") - 1 )) block(s)" | |
| exit "$status" | |
| - name: Upload coverage to Codecov | |
| uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 | |
| with: | |
| files: ${{ runner.temp }}/coverage.out | |
| flags: unittests | |
| name: codecov-umbrella | |
| fail_ci_if_error: false | |
| - name: Generate coverage report | |
| run: | | |
| go tool cover -html="$RUNNER_TEMP/coverage.out" -o "$RUNNER_TEMP/coverage.html" | |
| - name: Upload coverage artifacts | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: coverage-report | |
| path: | | |
| ${{ runner.temp }}/coverage.out | |
| ${{ runner.temp }}/coverage.html | |
| retention-days: 30 | |
| - name: Check coverage threshold | |
| run: | | |
| coverage=$(go tool cover -func="$RUNNER_TEMP/coverage.out" | grep total | awk '{print $3}' | sed 's/%//') | |
| echo "Total coverage: ${coverage}%" | |
| if (( $(echo "$coverage < 80" | bc -l) )); then | |
| echo "::warning::Coverage is below 80% (current: ${coverage}%)" | |
| fi | |
| # Integration tests with real PostgreSQL, sharded across parallel runners. | |
| # | |
| # Only the tests the Unit Tests job does not run are run here: those in | |
| # `//go:build integration` files, plus untagged tests that skip themselves | |
| # under -short. scripts/integration-test-selection.sh finds them and prints a | |
| # `-run` regex per package; packages with none are not even compiled. Before | |
| # that, this job re-ran every unit test under -tags=integration as well. | |
| # | |
| # Each leg runs the packages INTEGRATION_SHARDS gives its shard; `*` marks | |
| # the catch-all shard, which takes every package with integration tests that | |
| # no other shard names, so a new package is never silently skipped. Balance | |
| # the shards by the per-package "ok ... <time>" lines in the test step's log. | |
| # `needs: integration-tests` waits for every leg, so ci-success fails if any | |
| # leg fails or is cancelled. | |
| integration-tests: | |
| name: Integration Tests (${{ matrix.title }}) | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| contents: read | |
| strategy: | |
| # One shard failing must not cancel the others: their results are what | |
| # tell a real failure from a flaky one. | |
| fail-fast: false | |
| matrix: | |
| # Same shards, same order as INTEGRATION_SHARDS below; the guard step | |
| # fails on any difference. | |
| # | |
| # Balanced on PR #1402's first run (package test times under -race | |
| # in the three-leg layout): api 87s, config 85s, auth 62s, server | |
| # 55s, purchase 53s, migrations 64s, and the rest 95s together | |
| # (testhelpers 32s, cleanup-lambda 15s, database 15s, scheduler 12s, | |
| # analytics 12s, commitmentopts 9s). api is the floor, as one package | |
| # is never split; migrations and the rest run side by side within it. | |
| include: | |
| - shard: api | |
| title: api | |
| - shard: config | |
| title: config | |
| - shard: purchase | |
| title: purchase | |
| - shard: auth | |
| title: auth | |
| - shard: server | |
| title: server | |
| - shard: migrations-rest | |
| title: migrations, rest | |
| e2e-typecheck: true | |
| env: | |
| # <shard> <package path relative to the module> ... ; `*` = the rest. | |
| # Shard names also go into cache keys and artifact names. | |
| INTEGRATION_SHARDS: | | |
| api internal/api | |
| config internal/config | |
| purchase internal/purchase | |
| auth internal/auth | |
| server internal/server | |
| migrations-rest internal/database/postgres/migrations * | |
| SHARD: ${{ matrix.shard }} | |
| services: | |
| postgres: | |
| # Pinned by digest for the same reason as the linter images below: a | |
| # floating tag lets the service container change under an unchanged | |
| # repo, which is how #1695 turned main red. | |
| image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 | |
| env: | |
| POSTGRES_DB: cudly_test | |
| POSTGRES_USER: cudly_test | |
| POSTGRES_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| options: >- | |
| --health-cmd pg_isready | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| ports: | |
| - 5432:5432 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| # The caches below replace setup-go's. Its one entry is shared by | |
| # every job, keyed on go.sum and saved by whichever job finishes | |
| # first, so it never holds this job's -race -tags=integration | |
| # coverage build: every leg compiled cold, minutes per leg. | |
| cache: false | |
| # Each leg restores its own build cache (GOCACHE), as each compiles a | |
| # different set of packages, and the legs share one module cache | |
| # (GOMODCACHE). Restore and save are separate steps, as in the | |
| # security-scan job, so that a red run still warms the build cache. | |
| - name: Compute Go cache keys | |
| id: go_cache | |
| run: | | |
| set -euo pipefail | |
| go_version=$(go env GOVERSION) | |
| # sha256sum lines carry each path as well as its content, so a | |
| # renamed file changes the hash too. The sources are every Go file | |
| # plus everything under cmd/, internal/ and iac/, which covers the | |
| # go:embed inputs (openapi.yaml, IaC templates). Including too much | |
| # only costs a partial hit and a save. | |
| hash_files() { | |
| git ls-files -z -- "$@" | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 | |
| } | |
| mod_hash=$(hash_files go.work go.work.sum '*go.mod' '*go.sum') | |
| src_hash=$(hash_files go.work go.work.sum '*go.mod' '*go.sum' '*.go' cmd internal iac) | |
| build_any="integration-gobuild-${RUNNER_OS}-${RUNNER_ARCH}-${go_version}-" | |
| { | |
| echo "gocache=$(go env GOCACHE)" | |
| echo "gomodcache=$(go env GOMODCACHE)" | |
| echo "mod_prefix=integration-gomod-${RUNNER_OS}-${RUNNER_ARCH}-" | |
| echo "mod_key=integration-gomod-${RUNNER_OS}-${RUNNER_ARCH}-${mod_hash}" | |
| echo "build_any_prefix=${build_any}" | |
| echo "build_prefix=${build_any}shard-${SHARD}-src-" | |
| echo "build_key=${build_any}shard-${SHARD}-src-${src_hash}" | |
| } | tee -a "$GITHUB_OUTPUT" | |
| - name: Restore Go module cache | |
| id: go_mod_cache | |
| uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.go_cache.outputs.gomodcache }} | |
| key: ${{ steps.go_cache.outputs.mod_key }} | |
| # An older module set still saves most downloads; go fetches the | |
| # rest. | |
| restore-keys: ${{ steps.go_cache.outputs.mod_prefix }} | |
| - name: Restore Go build cache | |
| id: go_build_cache | |
| uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.go_cache.outputs.gocache }} | |
| key: ${{ steps.go_cache.outputs.build_key }} | |
| # On a miss, this shard's latest entry (a PR's own, else main's), | |
| # then any shard's (a renamed or new shard still gets the shared | |
| # dependencies). Go's build cache is content-addressed: an entry | |
| # from other sources is used only where it still matches, so a | |
| # stale cache recompiles just what changed. | |
| restore-keys: | | |
| ${{ steps.go_cache.outputs.build_prefix }} | |
| ${{ steps.go_cache.outputs.build_any_prefix }} | |
| - name: Mark restored build cache entries unused | |
| # Go moves an entry's mtime to now when it uses an entry that is over | |
| # an hour old. Backdating every restored entry by two hours (well | |
| # inside Go's own 5-day trim) lets the prune step below drop exactly | |
| # the entries this run did not use, so a cache restored from an older | |
| # key or from another shard does not keep growing. | |
| env: | |
| GOCACHE_DIR: ${{ steps.go_cache.outputs.gocache }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$GOCACHE_DIR" | |
| find "$GOCACHE_DIR" -mindepth 2 -maxdepth 2 -type f -print0 | | |
| xargs -0 -r touch -m -d '2 hours ago' | |
| touch "$RUNNER_TEMP/go-build-restored" | |
| echo "Restored $(find "$GOCACHE_DIR" -mindepth 2 -maxdepth 2 -type f | wc -l) build cache entries, $(du -sh "$GOCACHE_DIR" | cut -f1)" | |
| - name: Download the modules of every shard | |
| id: go_mod_download | |
| # Only on the leg that saves the module cache, and only on a miss, so | |
| # the saved set covers every leg, not just this one's packages. | |
| if: strategy.job-index == 0 && steps.go_mod_cache.outputs.cache-hit != 'true' | |
| run: go list -deps -test -tags=integration ./... > /dev/null | |
| - name: Install golang-migrate | |
| run: | | |
| # -tags 'pgx5', matching the Dockerfile and the library import in | |
| # internal/database/postgres/migrations: the postgres tag links | |
| # lib/pq, which carries unfixable advisories (issue #1849). No | |
| # @version suffix: installed as a package of this module, so the | |
| # version and dependency set come from go.mod. | |
| go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate | |
| - name: Run database migrations | |
| env: | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_NAME: cudly_test | |
| DB_USER: cudly_test | |
| DB_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| run: | | |
| if [ -d "internal/database/postgres/migrations" ]; then | |
| # The password goes through PGPASSWORD, not the URL, so it needs no | |
| # encoding whatever it contains (@ : / ? # % or spaces). | |
| PGPASSWORD="$DB_PASSWORD" migrate -path internal/database/postgres/migrations \ | |
| -database "pgx5://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" \ | |
| up | |
| fi | |
| - name: Select integration tests | |
| run: | | |
| set -euo pipefail | |
| bash scripts/integration-test-selection.sh ./... > "$RUNNER_TEMP/selection.tsv" | |
| echo "Packages with integration tests (top-level tests selected):" | |
| awk -F'\t' '{ printf " %s (%d)\n", $1, $3 }' "$RUNNER_TEMP/selection.tsv" | |
| - name: Check every package with integration tests is in exactly one shard | |
| env: | |
| SHARD_INDEX: ${{ strategy.job-index }} | |
| SHARD_TOTAL: ${{ strategy.job-total }} | |
| run: | | |
| set -euo pipefail | |
| # The matrix and INTEGRATION_SHARDS must list the same shards in the | |
| # same order. job-index is unique per leg, so each spec shard then | |
| # has exactly one leg, and no leg runs a shard the spec lacks. | |
| mapfile -t names < <(awk 'NF { print $1 }' <<<"$INTEGRATION_SHARDS") | |
| if [ "${#names[@]}" -ne "$SHARD_TOTAL" ] || [ "${names[$SHARD_INDEX]:-}" != "$SHARD" ]; then | |
| echo "::error::matrix leg $((SHARD_INDEX + 1)) of $SHARD_TOTAL is shard '$SHARD', but INTEGRATION_SHARDS line $((SHARD_INDEX + 1)) of ${#names[@]} is '${names[$SHARD_INDEX]:-}'" | |
| exit 1 | |
| fi | |
| # Fails if a package lands in no shard or in two, if a named package | |
| # has no integration tests left, or if a shard is left empty. | |
| # From go.mod, not `go list -m`: the checked-in go.work makes that | |
| # print tests/e2e's module path as well. | |
| INTEGRATION_MODULE="$(awk '$1 == "module" { print $2; exit }' go.mod)" \ | |
| bash scripts/integration-test-selection.sh \ | |
| --shard "$SHARD" "$RUNNER_TEMP/selection.tsv" > "$RUNNER_TEMP/shard.tsv" | |
| echo "Shard $SHARD runs:" | |
| cut -f1 "$RUNNER_TEMP/shard.tsv" | sed 's/^/ /' | |
| - name: Run integration tests | |
| id: integration_tests | |
| env: | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_NAME: cudly_test | |
| DB_USER: cudly_test | |
| DB_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| DB_SSL_MODE: disable | |
| run: | | |
| set -uo pipefail | |
| log="$RUNNER_TEMP/integration-${SHARD}.log" | |
| profile="$RUNNER_TEMP/coverage-integration-${SHARD}.out" | |
| mapfile -t pkgs < <(cut -f1 "$RUNNER_TEMP/shard.tsv") | |
| # One go test for the whole shard, so its packages still build and | |
| # run in parallel. -run is the per-package regexes joined by |; a | |
| # name only matches that name, so another package in the shard runs | |
| # extra tests only if it has a test of the same name (counted below). | |
| run_regex="$(cut -f2 "$RUNNER_TEMP/shard.tsv" | paste -sd'|' -)" | |
| selected=$(awk -F'\t' '{ n += $3 } END { print n + 0 }' "$RUNNER_TEMP/shard.tsv") | |
| status=0 | |
| echo "==> integration tests: ${pkgs[*]}" | |
| # -count=1: with the build cache restored, go test would otherwise | |
| # replay an earlier run's results. -run, -v, -timeout and | |
| # -coverprofile are all cacheable test flags, and go test cannot | |
| # see that the tests depend on the database. | |
| if ! go test -count=1 -v -race -tags=integration -timeout=20m \ | |
| -run "$run_regex" \ | |
| -coverprofile="$profile" \ | |
| "${pkgs[@]}" 2>&1 | tee "$log"; then | |
| echo "::error::integration tests failed in shard $SHARD" | |
| status=1 | |
| fi | |
| # Every selected test reports exactly one top-level result line | |
| # (subtests are indented). Fewer means the selection named tests | |
| # that did not run; zero is the #1751 shape, asserting nothing. | |
| ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true) | |
| echo "==> shard $SHARD ran $ran of $selected selected top-level test(s)" | |
| if [ "$ran" -eq 0 ] || [ "$ran" -lt "$selected" ]; then | |
| echo "::error::shard $SHARD ran $ran top-level test(s), $selected were selected" | |
| status=1 | |
| elif [ "$ran" -gt "$selected" ]; then | |
| echo "::warning::shard $SHARD ran $ran top-level tests for $selected selected: a selected name also names a unit test in another package of this shard, which therefore ran twice" | |
| fi | |
| if [ ! -s "$profile" ]; then | |
| echo "::error::shard $SHARD produced no integration coverage profile" >&2 | |
| status=1 | |
| fi | |
| exit "$status" | |
| - name: Type-check tests/e2e under -tags=e2e | |
| # tests/e2e holds only //go:build e2e files in a module of its own, so | |
| # it has no package to test here; its tests run in the e2e-tests job. | |
| # One leg is enough for a type-check. It runs even when the tests | |
| # failed, as both results were reported side by side before. | |
| if: ${{ !cancelled() && matrix.e2e-typecheck }} | |
| working-directory: tests/e2e | |
| run: go vet -tags=e2e ./... | |
| - name: Drop build cache entries this run did not use | |
| id: go_build_prune | |
| # Whenever the tests ran, pass or fail, so a red run still warms the | |
| # cache. Not on an exact hit: that key is already saved. | |
| if: ${{ !cancelled() && steps.go_build_cache.outputs.cache-hit != 'true' && (steps.integration_tests.outcome == 'success' || steps.integration_tests.outcome == 'failure') }} | |
| env: | |
| GOCACHE_DIR: ${{ steps.go_cache.outputs.gocache }} | |
| run: | | |
| set -euo pipefail | |
| # Entries still older than the marker were restored and never used | |
| # (see "Mark restored build cache entries unused"). | |
| dropped=$(find "$GOCACHE_DIR" -mindepth 2 -maxdepth 2 -type f \ | |
| ! -newer "$RUNNER_TEMP/go-build-restored" -print -delete | wc -l) | |
| echo "Dropped $dropped unused entries; saving $(find "$GOCACHE_DIR" -mindepth 2 -maxdepth 2 -type f | wc -l), $(du -sh "$GOCACHE_DIR" | cut -f1)" | |
| - name: Save Go build cache | |
| if: ${{ !cancelled() && steps.go_build_prune.outcome == 'success' }} | |
| uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.go_cache.outputs.gocache }} | |
| key: ${{ steps.go_cache.outputs.build_key }} | |
| - name: Save Go module cache | |
| if: ${{ !cancelled() && steps.go_mod_download.outcome == 'success' }} | |
| uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.go_cache.outputs.gomodcache }} | |
| key: ${{ steps.go_cache.outputs.mod_key }} | |
| - name: Upload integration coverage | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| # One artifact per shard; the integration-coverage job below merges | |
| # them into the single artifact this job used to upload. Short | |
| # retention: only the merged one is kept for 30 days. | |
| name: integration-coverage-${{ matrix.shard }} | |
| path: ${{ runner.temp }}/coverage-integration-${{ matrix.shard }}.out | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # Rebuild the integration-coverage artifact (coverage-integration.out) the | |
| # unsharded job used to upload. That job ran the unit tests too, so its | |
| # profile covered both suites; the shards run only integration tests, so | |
| # the unit job's profile is merged back in and the artifact keeps its | |
| # meaning (81.6% total on main's run 40153816430, 81.6% for the same merge | |
| # on this layout). Nothing in CI reads it: Codecov and the 80% coverage | |
| # warning use the unit job's own profile. Produced only when every shard | |
| # and the unit job passed. | |
| integration-coverage: | |
| name: Merge integration coverage | |
| needs: [unit-tests, integration-tests] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Download shard coverage | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | |
| with: | |
| pattern: integration-coverage-* | |
| path: ${{ runner.temp }}/shards | |
| merge-multiple: true | |
| - name: Download unit coverage | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | |
| with: | |
| name: coverage-report | |
| path: ${{ runner.temp }}/unit | |
| - name: Merge profiles | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| profiles=("$RUNNER_TEMP"/shards/coverage-integration-*.out) | |
| if [ "${#profiles[@]}" -eq 0 ]; then | |
| echo "::error::no integration shard produced a coverage profile" >&2 | |
| exit 1 | |
| fi | |
| profiles+=("$RUNNER_TEMP/unit/coverage.out") | |
| # Same merge as the unit job: one "mode:" header, then every | |
| # profile's blocks. All are atomic (-race); a block present in | |
| # more than one profile is summed by go tool cover when read. | |
| merged="$RUNNER_TEMP/coverage-integration.out" | |
| head -n 1 "${profiles[0]}" > "$merged" | |
| for p in "${profiles[@]}"; do | |
| if [ "$(head -n 1 "$p")" != "$(head -n 1 "$merged")" ]; then | |
| echo "::error::$p has '$(head -n 1 "$p")', expected '$(head -n 1 "$merged")'" >&2 | |
| exit 1 | |
| fi | |
| tail -n +2 "$p" >> "$merged" | |
| done | |
| echo "Merged ${#profiles[@]} coverage profile(s), $(( $(wc -l < "$merged") - 1 )) block(s)" | |
| - name: Upload integration coverage | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: integration-coverage | |
| path: ${{ runner.temp }}/coverage-integration.out | |
| retention-days: 30 | |
| # Self-test for scripts/integration-test-selection.sh, which decides what the | |
| # Integration Tests job runs. A selector that drops a test drops it from CI | |
| # entirely, as the Unit Tests job skips integration files, so the suite | |
| # asserts both directions over fixture packages. Needs Go (go list); the | |
| # fixtures are stdlib-only, so no module cache. | |
| integration-test-selection: | |
| name: Integration test selection self-test | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| cache: false | |
| - name: Run selection self-tests | |
| run: bash scripts/test-integration-test-selection.sh | |
| # Docker image build test, once per architecture the image ships for. | |
| # | |
| # The AWS deploys (deploy-aws-lambda.yml, deploy-aws-fargate.yml) build | |
| # linux/arm64 natively on ubuntu-24.04-arm for Graviton; GCP and Azure build | |
| # linux/amd64 on ubuntu-latest. This job used to build amd64 only, so the | |
| # arm64 image AWS actually runs was first built by a deploy (#56). Each leg | |
| # builds natively on the same runner its deploy uses, so the Dockerfile's | |
| # $BUILDPLATFORM stages run on the same images and architecture as in | |
| # production, with no QEMU. `needs: docker-build` waits for every leg, so | |
| # ci-success fails if either architecture does. | |
| docker-build: | |
| name: Build Docker Image (${{ matrix.arch }}) | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: read | |
| strategy: | |
| # One architecture failing must not cancel the other: its result is the | |
| # thing this matrix exists to report. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: amd64 | |
| runner: ubuntu-latest | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # No GHA cache on this leg. The repository's Actions cache is | |
| # already at its 10 GB limit, mostly buildkit blobs from the amd64 | |
| # leg, and a second mode=max scope would evict other jobs' caches. | |
| # A cold native arm64 build takes about 2 minutes (measured in | |
| # deploy-aws-lambda.yml), and this job is not on the critical path. | |
| - arch: arm64 | |
| runner: ubuntu-24.04-arm | |
| cache-from: '' | |
| cache-to: '' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Build Docker image | |
| uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 | |
| with: | |
| context: . | |
| push: false | |
| # Stated rather than left to the builder's default, so the image's | |
| # architecture is the matrix's and not whatever the runner is. | |
| platforms: linux/${{ matrix.arch }} | |
| # Export the built image into the local docker image store so the | |
| # steps below inspect the artifact this job just produced, | |
| # rather than rebuilding one and hoping it is the same. | |
| load: true | |
| tags: cudly:${{ github.sha }} | |
| cache-from: ${{ matrix.cache-from }} | |
| cache-to: ${{ matrix.cache-to }} | |
| build-args: | | |
| VERSION=${{ github.sha }} | |
| # Go is needed by both the architecture check and the image scan below. | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| # Replaces a step that rebuilt the image with plain `docker build` (2m18s | |
| # on amd64) and ran `/app/cudly --version || true`, which asserted | |
| # nothing: cudly has no --version flag, and `|| true` hid every failure. | |
| # The plain `docker build` path is still covered by the e2e-tests job, | |
| # whose compose file builds this Dockerfile. | |
| - name: Verify image architecture and binaries | |
| env: | |
| IMAGE: cudly:${{ github.sha }} | |
| ARCH: ${{ matrix.arch }} | |
| run: | | |
| set -euo pipefail | |
| fail() { echo "::error::$*"; exit 1; } | |
| # Native, not emulated: the runner's own architecture is the target. | |
| case "$RUNNER_ARCH" in | |
| X64) runner_arch=amd64 ;; | |
| ARM64) runner_arch=arm64 ;; | |
| *) runner_arch="$RUNNER_ARCH" ;; | |
| esac | |
| [ "$runner_arch" = "$ARCH" ] || | |
| fail "runner is $RUNNER_ARCH, expected a native $ARCH runner" | |
| platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMAGE")" | |
| [ "$platform" = "linux/$ARCH" ] || | |
| fail "image $IMAGE is $platform, expected linux/$ARCH" | |
| # The image metadata alone does not prove what was compiled into it: | |
| # read GOOS/GOARCH from each Go binary's embedded build info. | |
| bins="$(mktemp -d "$RUNNER_TEMP/image-bins.XXXXXX")" | |
| cid="$(docker create "$IMAGE")" | |
| trap 'docker rm -f "$cid" >/dev/null' EXIT | |
| for bin in /app/cudly /usr/local/bin/migrate; do | |
| out="$bins/$(basename "$bin")" | |
| docker cp "$cid:$bin" "$out" | |
| info="$(go version -m "$out")" | |
| if ! grep -qxF "$(printf '\tbuild\tGOOS=linux')" <<<"$info" || | |
| ! grep -qxF "$(printf '\tbuild\tGOARCH=%s' "$ARCH")" <<<"$info"; then | |
| fail "$bin is not a linux/$ARCH Go binary: $(awk '$1 == "build" && $2 ~ /^GO(OS|ARCH)=/ { printf "%s ", $2 }' <<<"$info")" | |
| fi | |
| echo "$bin: linux/$ARCH" | |
| done | |
| # And both execute. cudly runs through the real entrypoint, which | |
| # appends --mode/--port; -h stops flag parsing there and exits 0. | |
| timeout 60 docker run --rm "$IMAGE" /app/cudly -h | |
| timeout 60 docker run --rm --entrypoint /usr/local/bin/migrate "$IMAGE" -version | |
| # Nothing else in the pipeline looks at the artifact: govulncheck runs in | |
| # source mode, and Trivy runs with scan-type fs and config, all against | |
| # the repository. A vulnerable binary baked into the image was invisible | |
| # by construction, which is how #1833 shipped twice (issue #1836). These | |
| # steps live in this job rather than a new one so the image is scanned | |
| # without being built again; docker-build is already in ci-success's | |
| # needs, so the gate is wired. They run on both legs: the arm64 image is | |
| # the one the AWS deploys ship. | |
| - name: Install govulncheck | |
| run: | | |
| # Same pin as the security-scan job: a govulncheck release with new | |
| # detection logic must not silently change this gate's verdict | |
| # between PRs. | |
| go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 | |
| - name: Run image scan self-tests | |
| # Asserts both directions of the verdict against recorded govulncheck | |
| # output, so a scanner that can no longer fail cannot ship as coverage. | |
| run: bash scripts/test-scan-shipped-image.sh | |
| - name: Run govulncheck retry self-tests | |
| run: bash scripts/test-govulncheck-retry.sh | |
| - name: Scan the shipped image for Go advisories | |
| run: bash scripts/scan-shipped-image.sh cudly:${{ github.sha }} | |
| # Terraform validation for all environments | |
| terraform-validate: | |
| name: Validate Terraform (${{ matrix.cloud }}) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| strategy: | |
| matrix: | |
| cloud: [aws, gcp, azure] | |
| fail-fast: false | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Terraform | |
| uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 | |
| with: | |
| # Must satisfy `required_version = ">= 1.10.0"` declared by every | |
| # terraform/environments/*/main.tf -- pinning below 1.10 makes | |
| # `terraform init`/`validate` abort with "Unsupported Terraform | |
| # Core version". Matches the pin in pre-commit.yml so both | |
| # workflows resolve to the same binary. | |
| terraform_version: 1.10.5 | |
| - name: Terraform Format Check | |
| run: terraform fmt -check -recursive terraform/ | |
| - name: Test Azure federation identity guards | |
| if: matrix.cloud == 'azure' | |
| run: | | |
| terraform -chdir=iac/federation/azure-target/terraform init -backend=false -lockfile=readonly -input=false | |
| terraform -chdir=iac/federation/azure-target/terraform fmt -check -recursive | |
| terraform -chdir=iac/federation/azure-target/terraform test | |
| - name: Test AWS compute modules | |
| if: matrix.cloud == 'aws' | |
| run: | | |
| for t in terraform/modules/compute/aws/*/tests; do | |
| [ -d "$t" ] || continue | |
| d=$(dirname "$t") | |
| terraform -chdir="$d" init -backend=false -lockfile=readonly -input=false | |
| terraform -chdir="$d" fmt -check -recursive | |
| terraform -chdir="$d" test | |
| done | |
| # Failure alerts must fire on the markers the app logs (#714). GCP's | |
| # module tests run in the /health step below. | |
| - name: Test monitoring module | |
| if: matrix.cloud == 'aws' || matrix.cloud == 'azure' | |
| env: | |
| CLOUD: ${{ matrix.cloud }} | |
| run: | | |
| d="terraform/modules/monitoring/$CLOUD" | |
| terraform -chdir="$d" init -backend=false -lockfile=readonly -input=false | |
| terraform -chdir="$d" fmt -check -recursive | |
| terraform -chdir="$d" test | |
| # The post-deploy /health gate and the GCP uptime check must read the | |
| # body's top-level "status", not just the 200 (#649). | |
| - name: Test /health gate modules | |
| if: matrix.cloud == 'gcp' | |
| run: | | |
| for d in terraform/modules/deployment-checks terraform/modules/monitoring/gcp; do | |
| terraform -chdir="$d" init -backend=false -lockfile=readonly -input=false | |
| terraform -chdir="$d" fmt -check -recursive | |
| terraform -chdir="$d" test | |
| done | |
| # Each database module must default to deletion protection and honor | |
| # an explicit opt-out (#293). Skips a cloud until it has a tests dir. | |
| - name: Test database module | |
| env: | |
| CLOUD: ${{ matrix.cloud }} | |
| run: | | |
| d="terraform/modules/database/$CLOUD" | |
| if [ -d "$d/tests" ]; then | |
| terraform -chdir="$d" init -backend=false -lockfile=readonly -input=false | |
| terraform -chdir="$d" fmt -check -recursive | |
| terraform -chdir="$d" test | |
| fi | |
| - name: Test CI/CD trust (immutable ids) | |
| # Skips a cloud until it has a tests dir. The lock file is gitignored, | |
| # so no -lockfile=readonly. | |
| run: | | |
| d=terraform/environments/${{ matrix.cloud }}/ci-cd-permissions | |
| if [ -d "$d/tests" ]; then | |
| terraform -chdir="$d" init -backend=false -input=false | |
| terraform -chdir="$d" test | |
| fi | |
| - name: Terraform Init | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| terraform init -backend=false | |
| - name: Terraform Validate | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| terraform validate | |
| - name: Validate environment tfvars files | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| for env in dev staging prod; do | |
| # Check local tfvars if present | |
| if [ -f "${env}.tfvars" ]; then | |
| echo "Checking ${env}.tfvars syntax..." | |
| terraform fmt -check "${env}.tfvars" || echo "Note: ${env}.tfvars may need formatting" | |
| fi | |
| # Check GitHub CI/CD tfvars | |
| if [ -f "github-${env}.tfvars" ]; then | |
| echo "Checking github-${env}.tfvars syntax..." | |
| terraform fmt -check "github-${env}.tfvars" || echo "Note: github-${env}.tfvars may need formatting" | |
| fi | |
| done | |
| # Security scanning | |
| security-scan: | |
| name: Security Scanning | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| security-events: write | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| id: checkout | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| id: setup_go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Run govulncheck CVE scanner (all modules) | |
| # always(): each scanner step below is independent evidence for the | |
| # Security tab. Without this, one scanner failing (e.g. a live npm | |
| # advisory) skips every scanner after it in the same job, silently | |
| # disabling Go SAST and Terraform IaC coverage repo-wide. The job | |
| # still fails overall if any scanner step here fails. Still requires | |
| # checkout and Go setup to have actually succeeded -- an infra | |
| # failure there must not be papered over as "scanner found nothing". | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| run: | | |
| # Pinned (not @latest): a govulncheck release with new | |
| # detection logic could silently change the gate's verdict | |
| # between PRs without an intentional bump in this repo. | |
| # Bumping is a deliberate review item, not a drift. | |
| go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 | |
| # Scan both platform-owned modules independently. The wrapper retries | |
| # only transient vulnerability-DB fetch failures, never findings (#74). | |
| set -e | |
| for mod in . tests/e2e; do | |
| echo "==> govulncheck in $mod" | |
| (cd "$mod" && bash "$GITHUB_WORKSPACE/scripts/govulncheck-retry.sh" ./...) | |
| done | |
| - name: Run npm audit (frontend) | |
| # always(): must not skip the scanners below it just because | |
| # govulncheck failed, and its own failure must not skip gosec/Trivy. | |
| # Still requires checkout to have succeeded (see govulncheck above). | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| run: | | |
| if [ -f frontend/package.json ]; then | |
| cd frontend && npm audit --audit-level=high | |
| fi | |
| - name: Run gosec wrapper self-tests | |
| # Pins that the pre-commit hook, `make security-scan-go` and the step | |
| # below all run gosec through scripts/run-gosec.sh with one rule set | |
| # (issue #340). Uses a fake gosec, so it is fast and offline. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| run: bash scripts/test-run-gosec.sh | |
| # gosec result cache. A full scan re-parses and type-checks every package | |
| # and dependency from source (about 300s), even when no Go input changed, | |
| # as on a frontend-only PR. gosec's verdict depends only on the inputs | |
| # hashed below, so a run whose inputs exactly match an earlier clean scan | |
| # would reach the same verdict. That run reuses the clean scan's merged | |
| # SARIF instead. | |
| - name: Compute gosec cache keys | |
| id: gosec_key | |
| # An optimisation only: if this step breaks, the restores and saves | |
| # below are skipped and gosec scans exactly as it would uncached. | |
| continue-on-error: true | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| run: | | |
| set -euo pipefail | |
| # Pinned gosec, effective Go version and binary cache dir, read from | |
| # scripts/run-gosec.sh so the pin lives in one place. | |
| versions=$(scripts/run-gosec.sh --print-versions) | |
| printf '%s\n' "$versions" | tee -a "$GITHUB_OUTPUT" | |
| gosec_version=$(printf '%s\n' "$versions" | sed -n 's/^gosec_version=//p') | |
| go_version=$(printf '%s\n' "$versions" | sed -n 's/^go_version=//p') | |
| # Every input to gosec's verdict: all Go sources (both scanned modules | |
| # and anything else on disk, testdata included), every go.mod/go.sum | |
| # (go.sum pins dependency content), go.work/go.work.sum, the wrapper | |
| # (pin, rule set, exclude dirs) and this workflow (the SARIF merge | |
| # below). sha256sum lines carry the path as well as the content, so a | |
| # moved or renamed file changes the key; hashFiles() would not. | |
| # Including too much only costs a miss. | |
| inputs_hash=$( | |
| { | |
| find . -path ./.git -prune -o -type f \( -name '*.go' -o -name go.mod \ | |
| -o -name go.sum -o -name go.work -o -name go.work.sum \) -print0 | |
| printf '%s\0' scripts/run-gosec.sh .github/workflows/ci.yml | |
| } | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 | |
| ) | |
| echo "results_key=gosec-sarif-${RUNNER_OS}-${RUNNER_ARCH}-${gosec_version}-${go_version}-${inputs_hash}" | tee -a "$GITHUB_OUTPUT" | |
| echo "bin_key=gosec-bin-${RUNNER_OS}-${RUNNER_ARCH}-${gosec_version}-${go_version}" | tee -a "$GITHUB_OUTPUT" | |
| - name: Restore gosec results | |
| id: gosec_results | |
| # Exact key only (no restore-keys): a result from different inputs is | |
| # never reused. A miss or a cache service error leaves no file, and the | |
| # scan below runs as usual. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec_key.outcome == 'success' | |
| uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ runner.temp }}/gosec-results.sarif | |
| key: ${{ steps.gosec_key.outputs.results_key }} | |
| # Split restore/save rather than the combined actions/cache, whose save | |
| # is a post step gated on `post-if: success()`: any failing scanner in | |
| # this job would stop the binary being saved. Not needed on a results | |
| # hit, which runs no gosec. | |
| - name: Restore gosec binary | |
| id: gosec_bin | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec_key.outcome == 'success' && steps.gosec_results.outputs.cache-hit != 'true' | |
| uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.gosec_key.outputs.bin_dir }} | |
| key: ${{ steps.gosec_key.outputs.bin_key }} | |
| - name: Run gosec Security Scanner | |
| id: gosec | |
| # always(): don't let an earlier scanner's failure (e.g. npm audit) | |
| # skip Go SAST coverage. The job still fails if gosec itself fails. | |
| # Still requires checkout and Go setup to have succeeded. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| env: | |
| RESULTS_CACHE_HIT: ${{ steps.gosec_results.outputs.cache-hit }} | |
| GOSEC_BIN_DIR: ${{ steps.gosec_key.outputs.bin_dir }} | |
| run: | | |
| # On an exact results-cache hit the restored SARIF is the merged | |
| # output of a clean scan of identical inputs (only clean scans are | |
| # saved, see "Save gosec results"), so hand it to the upload step | |
| # as-is. If it is unreadable, fall through to a real scan. | |
| merged="$RUNNER_TEMP/gosec-results.sarif" | |
| if [ "$RESULTS_CACHE_HIT" = "true" ]; then | |
| if jq -e '.runs | length > 0' "$merged" >/dev/null 2>&1; then | |
| echo "Go inputs match a clean gosec scan: reusing its SARIF ($(jq '.runs | length' "$merged") runs)" | |
| exit 0 | |
| fi | |
| echo "::warning::restored gosec SARIF is missing or unreadable; scanning instead" | |
| rm -f "$merged" | |
| fi | |
| # scripts/run-gosec.sh owns the pinned gosec version, toolchain, | |
| # module list and rule set, shared with the pre-commit hook and | |
| # `make security-scan-go` (issue #340). It installs gosec with the | |
| # job's existing setup-go: the securego/gosec Docker action bundles | |
| # its own Go toolchain, which cannot satisfy the module's go | |
| # directive. | |
| # | |
| # gosec exits non-zero both for real findings and for a processing | |
| # error. The script still scans every module after a failure, and | |
| # `|| rc=$?` exempts it from errexit so the merge below always | |
| # runs (issue #1717); `status` records whether the job should still | |
| # fail at the end. | |
| status=0 | |
| rc=0 | |
| scripts/run-gosec.sh --sarif-dir "$RUNNER_TEMP" || rc=$? | |
| [ "$rc" -eq 0 ] || status=1 | |
| # run-gosec.sh exits 2 on a setup failure (a failed install | |
| # included), but an early mkdir/mktemp failure can stop it with 1 | |
| # before gosec exists. Mark the binary ready for "Save gosec binary" | |
| # only when the executable is really in its cache dir; findings and | |
| # scan errors (exit 1 with the binary present) still save it. | |
| if [ "$rc" -ne 2 ] && [ -n "${GOSEC_BIN_DIR:-}" ] && [ -x "$GOSEC_BIN_DIR/gosec" ]; then | |
| echo "binary_ready=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| shopt -s nullglob | |
| for out in "$RUNNER_TEMP"/gosec-*.sarif; do | |
| tag=$(basename "$out" .sarif) | |
| # Code scanning rejects a SARIF file whose runs share a category | |
| # (github.blog changelog 2025-07-21), so give each module's run a | |
| # unique automationDetails.id before merging. | |
| jq --arg id "${tag}/" '.runs |= map(.automationDetails = {id: $id})' \ | |
| "$out" > "$out.tmp" && mv "$out.tmp" "$out" | |
| done | |
| # Merge per-module SARIF runs into one file for the upload step. | |
| # Only modules that actually produced a file are included; if | |
| # gosec crashed before writing any of them, fail loud here rather | |
| # than uploading an empty result silently. | |
| sarif_files=("$RUNNER_TEMP"/gosec-*.sarif) | |
| if [ "${#sarif_files[@]}" -eq 0 ]; then | |
| echo "::error::no gosec SARIF output was produced by any module" >&2 | |
| exit 1 | |
| fi | |
| # jq is preinstalled on the GitHub Ubuntu runner image (no new deps). | |
| # Written to $RUNNER_TEMP, not the checkout root: never save working | |
| # files in the repository root (repo coding guideline), and this | |
| # file is purely a hand-off to the upload step below. | |
| merged="$RUNNER_TEMP/gosec-results.sarif" | |
| jq -s '{version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", runs: [.[].runs[]]}' \ | |
| "${sarif_files[@]}" > "$merged" | |
| echo "Merged $(jq '.runs | length' "$merged") SARIF runs" | |
| # Only a clean scan (exit 0, SARIF merged) may be cached, so a | |
| # finding or a scan error is never replayed from the cache. | |
| [ "$status" -ne 0 ] || echo "clean_scan=true" >> "$GITHUB_OUTPUT" | |
| exit "$status" | |
| - name: Upload gosec results to GitHub Security | |
| # Tolerate a missing SARIF only when the gosec step itself never ran | |
| # (e.g. checkout/setup-go failed, so gosec's own `if:` above skipped | |
| # it). If gosec ran and the file is still missing, fail loud instead | |
| # of masking it as a skip. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec.outcome != 'skipped' | |
| uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 | |
| with: | |
| sarif_file: ${{ runner.temp }}/gosec-results.sarif | |
| - name: Save gosec results | |
| # Only after a clean scan of these exact inputs (never a hit, a | |
| # finding or a scan error). A pull_request run saves to its own | |
| # merge-ref scope, which main and other PRs cannot restore from. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec_key.outcome == 'success' && steps.gosec.outputs.clean_scan == 'true' | |
| uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ runner.temp }}/gosec-results.sarif | |
| key: ${{ steps.gosec_key.outputs.results_key }} | |
| - name: Save gosec binary | |
| # Saved even when gosec reports findings: the binary depends only on | |
| # the gosec and Go versions in the key, not on the scan. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec_key.outcome == 'success' && steps.gosec_bin.outputs.cache-hit != 'true' && steps.gosec.outputs.binary_ready == 'true' | |
| uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ${{ steps.gosec_key.outputs.bin_dir }} | |
| key: ${{ steps.gosec_key.outputs.bin_key }} | |
| - name: Run Trivy vulnerability scanner (filesystem) | |
| id: trivy_fs | |
| # always(): don't let an earlier scanner's failure skip Trivy fs | |
| # coverage. This scan uses the default exit-code 0 (see the IaC | |
| # scan comment below), so it does not gate the job on its own. | |
| # Still requires checkout and Go setup to have succeeded. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: 'fs' | |
| scan-ref: '.' | |
| format: 'sarif' | |
| # $RUNNER_TEMP, not the checkout root (repo coding guideline: never | |
| # save working files in the repository root). | |
| output: '${{ runner.temp }}/trivy-results.sarif' | |
| severity: 'CRITICAL,HIGH' | |
| # Pin the Trivy binary independently of the action SHA. v0.36.0 is | |
| # the latest trivy-action release but bundles Trivy v0.70.0, which | |
| # panics in adaptDefaultTags on terraform/environments/aws/main.tf | |
| # (null default_tags vars). Fixed in Trivy >= v0.72.0. The action | |
| # forwards this input to aquasecurity/setup-trivy, so both scan | |
| # steps run the same pinned binary. Keep both steps on this version. | |
| version: 'v0.72.0' | |
| - name: Upload Trivy results to GitHub Security | |
| # Tolerate a missing SARIF only when the Trivy fs step never ran. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.trivy_fs.outcome != 'skipped' | |
| uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 | |
| with: | |
| sarif_file: '${{ runner.temp }}/trivy-results.sarif' | |
| # Terraform IaC misconfiguration scanning. Replaces the deprecated | |
| # aquasecurity/tfsec-action, whose bundled HCL parser rejects Terraform | |
| # 1.5+ `check {}` blocks (e.g. terraform/modules/deployment-checks/main.tf) | |
| # with a hard "scan failed" parse error that soft_fail does not suppress | |
| # (soft_fail only downgrades findings, not scan errors). Trivy is tfsec's | |
| # official successor, parses `check {}` blocks, and (like the fs scan | |
| # above) uses the default exit-code 0 so misconfig findings are reported | |
| # to the Security tab without gating the job -- matching tfsec's prior | |
| # soft_fail: true behaviour while preserving Terraform IaC coverage. | |
| - name: Run Trivy IaC misconfiguration scanner (Terraform) | |
| id: trivy_iac | |
| # always(): don't let an earlier scanner's failure skip Terraform | |
| # IaC coverage. Still requires checkout and Go setup to have | |
| # succeeded. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: 'config' | |
| scan-ref: 'terraform/' | |
| format: 'sarif' | |
| # $RUNNER_TEMP, not the checkout root (repo coding guideline: never | |
| # save working files in the repository root). | |
| output: '${{ runner.temp }}/trivy-config-results.sarif' | |
| severity: 'CRITICAL,HIGH' | |
| # Same pinned Trivy binary as the filesystem scan above (>= v0.72.0 | |
| # avoids the adaptDefaultTags panic on null default_tags vars). | |
| version: 'v0.72.0' | |
| - name: Upload Trivy IaC results to GitHub Security | |
| # Tolerate a missing SARIF only when the Trivy IaC step never ran. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.trivy_iac.outcome != 'skipped' | |
| uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 | |
| with: | |
| sarif_file: '${{ runner.temp }}/trivy-config-results.sarif' | |
| # Distinct category so this IaC analysis does not overwrite the | |
| # filesystem Trivy analysis uploaded above (both report as "Trivy"). | |
| category: 'trivy-iac' | |
| # Snyk security scanning | |
| snyk-scan: | |
| name: Snyk Security Scan | |
| runs-on: ubuntu-latest | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Run Snyk to check for vulnerabilities | |
| uses: snyk/actions/golang@b98d498629f1c368650224d6d212bf7dfa89e4bf # 0.4.0 | |
| continue-on-error: true | |
| env: | |
| SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} | |
| with: | |
| args: --severity-threshold=high | |
| # Docker Compose E2E tests: build the app + test-runner images, bring up | |
| # postgres + cudly-app, then run the black-box suite in tests/e2e against | |
| # the app over HTTP. The test-runner service is profile-gated, so every | |
| # compose invocation needs --profile test (issue #1180). | |
| e2e-tests: | |
| name: E2E Tests | |
| runs-on: ubuntu-24.04-arm | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Build images | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test build | |
| - name: Run E2E tests with docker compose | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test up --abort-on-container-exit --exit-code-from test-runner | |
| env: | |
| COMPOSE_INTERACTIVE_NO_CLI: 1 | |
| - name: Cleanup | |
| if: always() | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test down -v | |
| # Assert that the Azure custom-role actions list is identical in the TF module | |
| # and the ARM onboarding template. Fast (shell + jq only), so it always runs. | |
| # Path changes that trigger drift will be caught regardless of PR context. | |
| azure-role-parity: | |
| name: Azure role actions parity (ARM vs TF) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert ARM/TF actions parity | |
| run: bash scripts/check-azure-role-parity.sh | |
| - name: Run parity script self-tests | |
| run: bash scripts/test-azure-role-parity.sh | |
| # Assert that the AWS IAM action lists are identical across the CFN stack, | |
| # the TF lambda/fargate modules, and the federation CFN/TF/CLI templates. | |
| # Fast (shell only), so it always runs. | |
| aws-iam-parity: | |
| name: AWS IAM actions parity (CFN vs TF) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert CFN/TF actions parity | |
| run: bash scripts/check-aws-iam-parity.sh | |
| - name: Run parity script self-tests | |
| run: bash scripts/test-aws-iam-parity.sh | |
| # Assert that no Terraform file grants a Secret Manager role at project, | |
| # folder or organization scope; those scopes hand the member every secret in | |
| # the scope. Fast (shell only), so it always runs. | |
| gcp-secret-scope: | |
| name: GCP Secret Manager grant scope | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert no scope-wide Secret Manager grants | |
| run: bash scripts/check-gcp-secret-scope.sh | |
| - name: Run guard script self-tests | |
| run: bash scripts/test-gcp-secret-scope.sh | |
| # Self-test for the git-secrets allowlist (#1972): asserts, through the | |
| # real pre-commit hook scan path, that secret-shaped fixtures are still | |
| # caught and that the documented false positives still scan clean. | |
| git-secrets-allowlist: | |
| name: git-secrets allowlist self-test | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install git-secrets | |
| # The 1.3.0 tag is mutable in principle, so clone it and then assert | |
| # HEAD resolves to the exact commit verified at the time this was | |
| # written (`git ls-remote --tags` against the upstream repo) before | |
| # trusting it enough to `sudo make install`. | |
| env: | |
| GIT_SECRETS_SHA: ad82d68ee924906a0401dfd48de5057731a9bc84 | |
| run: | | |
| set -euo pipefail | |
| git clone --depth 1 --branch 1.3.0 https://github.com/awslabs/git-secrets.git /tmp/git-secrets | |
| resolved="$(git -C /tmp/git-secrets rev-parse HEAD)" | |
| if [ "${resolved}" != "${GIT_SECRETS_SHA}" ]; then | |
| echo "git-secrets 1.3.0 tag resolved to ${resolved}, expected ${GIT_SECRETS_SHA}" >&2 | |
| exit 1 | |
| fi | |
| sudo make -C /tmp/git-secrets install | |
| - name: Run allowlist self-tests | |
| run: bash scripts/test-git-secrets-allowlist.sh | |
| # Assert that the ECR repository selector used by destroy-fargate-dev.yml and | |
| # cleanup-staging.yml picks the repository each state owns and nothing else. | |
| # The consumers force-delete what the selector prints, so both directions are | |
| # asserted: over-matching deletes images the workflow does not own, and | |
| # matching nothing leaves that state's repository behind. The suite also | |
| # asserts the wiring, which is what #1592 and #1820 each escaped: all three | |
| # destroy steps still call scripts/force-delete-owned-ecr-repo.sh, that script | |
| # still deletes only what the selector yields, and nothing else under | |
| # .github/workflows or scripts/ runs `aws ecr delete-repository` unguarded. | |
| # That last claim is checked over a GLOB of both directories, not a list of | |
| # known files, so a script added later is covered without anyone remembering | |
| # to name it; the sweep is asserted to have opened a non-zero number of files | |
| # first, since an empty swept set has no violations either. | |
| # Fast (shell only), so it always runs. | |
| ecr-delete-selection: | |
| name: ECR delete selection scope | |
| runs-on: ubuntu-latest | |
| # This job checks out the tree and runs a shell script against it, so | |
| # `contents: read` is all it needs. Same shape as security-scan above, | |
| # which adds `security-events: write` only because it uploads SARIF. | |
| # ci.yml now also declares `contents: read` at workflow level, so this | |
| # block narrows nothing on its own; it is kept explicit so the job states | |
| # its own requirement rather than inheriting silently. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Run selector self-tests | |
| run: bash scripts/test-ecr-delete-selection.sh | |
| # Assert that the RDS instance selector used by destroy-fargate-dev.yml and | |
| # cleanup-staging.yml unprotects the instance each state owns and nothing | |
| # else. Deletion protection is the last line of defence on a database, so | |
| # stripping it from an instance a state does not own leaves that database | |
| # exposed to the next destroy that does match it. Both directions are | |
| # asserted: a prefix near-miss is refused, and the owned instance is still | |
| # selected, since a selector matching nothing passes every refusal assertion | |
| # while leaving the destroy broken. The suite also asserts the wiring, which | |
| # is what #1592, #1820 and #1821 each escaped: all three destroy steps call | |
| # scripts/disable-owned-rds-deletion-protection.sh, that script unprotects | |
| # only what the selector yields and swallows nothing, and nothing else under | |
| # .github/workflows or scripts/ runs `aws rds modify-db-instance` unguarded. | |
| # That last claim is checked over a GLOB of both directories, not a list of | |
| # known files, so a script added later is covered without anyone remembering | |
| # to name it; the sweep is asserted to have opened a non-zero number of files | |
| # first, since an empty swept set has no violations either. | |
| # It additionally runs the script end to end against stubbed terraform and aws, | |
| # so "a failed strip is not swallowed" is asserted as behaviour, not as text. | |
| # Fast (shell only), so it always runs. | |
| rds-deletion-protection-scope: | |
| name: RDS deletion protection scope | |
| runs-on: ubuntu-latest | |
| # Same shape as ecr-delete-selection above: this job checks out the tree and | |
| # runs a shell script against it, so `contents: read` is all it needs. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Run RDS scope self-tests | |
| run: bash scripts/test-rds-deletion-protection-scope.sh | |
| # Assert that the Cloud SQL instance selector used by cleanup-staging.yml's | |
| # destroy-gcp job deletes the instance the staging state owns and nothing | |
| # else. deletion_protection = true on that instance means a selector that | |
| # matches nothing leaves it behind and the destroy fails on it; a selector | |
| # that over-matches deletes a database this state never owned. Both | |
| # directions are asserted: every near-miss name is refused, and the owned | |
| # instance is still selected out of a hostile listing first, since a | |
| # selector matching nothing passes every refusal assertion while leaving | |
| # the destroy broken. The suite also asserts the wiring, which is what | |
| # #1592, #1820 and #1821 each escaped on other resources: the destroy step | |
| # calls scripts/delete-owned-cloud-sql-instance.sh, that script lists | |
| # instances with no `--filter` and deletes only what the selector yields, | |
| # the three `terraform state rm` calls are module-qualified and run only | |
| # after a successful delete, and nothing else under .github/workflows or | |
| # scripts/ runs `gcloud sql instances delete` unguarded. That last claim is | |
| # checked over a GLOB of both directories, not a list of known files, so a | |
| # script added later is covered without anyone remembering to name it; the | |
| # sweep is asserted to have opened a non-zero number of files first, since | |
| # an empty swept set has no violations either. | |
| # It additionally runs the script end to end against stubbed terraform and | |
| # gcloud, so "a failed delete is not swallowed" and "state is removed only | |
| # after a successful delete" are asserted as behaviour, not as text. | |
| # Fast (shell only), so it always runs. | |
| cloud-sql-delete-scope: | |
| name: Cloud SQL delete selection scope | |
| runs-on: ubuntu-latest | |
| # Same shape as ecr-delete-selection above: this job checks out the tree and | |
| # runs a shell script against it, so `contents: read` is all it needs. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Run Cloud SQL scope self-tests | |
| run: bash scripts/test-cloud-sql-delete-scope.sh | |
| # Assert that every job applying a `compute_platform` writes the Terraform | |
| # state namespace that platform owns: `compute_platform=lambda` into | |
| # github-<env>/, `compute_platform=fargate` into github-fargate-<env>/. The | |
| # AWS environment is one Terraform root applied twice into two state objects, | |
| # nothing in Terraform ties the backend key to the platform, and a job that | |
| # pairs them wrongly initialises, plans and applies cleanly while rewriting | |
| # the other platform's stack and recording it in the wrong state file. That | |
| # was #1811, where rollback.yml's Fargate rollback keyed on the Lambda | |
| # namespace, so both rollback jobs wrote the same object. A workflow run | |
| # proves nothing about this, so the pairing is asserted as text. | |
| # Both directions are asserted, and the positive one first: the seven real | |
| # pairings are named and checked before any absence, since a scan that | |
| # recognizes no state-writing job has no violations either. The negative half | |
| # is checked over a GLOB of .github/workflows and scripts/, not a list of | |
| # known files, so a job added later is covered without anyone naming it. | |
| # Fast (shell only), so it always runs. | |
| aws-tfstate-platform-key: | |
| name: AWS Terraform state namespace per platform | |
| runs-on: ubuntu-latest | |
| # Same shape as ecr-delete-selection above: this job checks out the tree and | |
| # runs a shell script against it, so `contents: read` is all it needs. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Run state namespace self-tests | |
| run: bash scripts/test-aws-tfstate-platform-key.sh | |
| # Assert that no Terraform file declares an azurerm_key_vault_access_policy | |
| # resource. This project's only Key Vault sets enable_rbac_authorization = | |
| # true, and an RBAC-enabled vault ignores access policies entirely, so such a | |
| # grant applies cleanly and then does nothing at runtime (#1621). The nested | |
| # `access_policy` block form is deliberately not covered; #1839 tracks it. | |
| # Fast (shell only), so it always runs. | |
| azure-kv-access-policy: | |
| name: Azure Key Vault grant model | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert no Key Vault access policies | |
| run: bash scripts/check-azure-kv-access-policy.sh | |
| - name: Run guard script self-tests | |
| run: bash scripts/test-azure-kv-access-policy.sh | |
| - name: Run smoke-gate self-test | |
| run: bash scripts/test-wait-for-healthy.sh | |
| # Execute database-migration.yml's down-migration gate (#68): the gate script | |
| # over adversarial inputs, then the workflow's own validate and migrate-* run | |
| # blocks extracted from the YAML with a stub `migrate`, so a request that | |
| # amounts to `down -all` (steps=0, or steps at or past the migration count, | |
| # which golang-migrate treats as "roll back everything") is proven never to | |
| # reach migrate. Fast (shell only), so it always runs. | |
| migration-down-gate: | |
| name: Database migration down gate | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Run down-migration gate self-tests | |
| run: bash scripts/test-migration-down-gate.sh | |
| # Summary job - all checks must pass | |
| ci-success: | |
| name: CI Success | |
| runs-on: ubuntu-latest | |
| needs: | |
| - lint | |
| - workflow-lint | |
| - unit-tests | |
| - integration-tests | |
| - integration-coverage | |
| - integration-test-selection | |
| - docker-build | |
| - terraform-validate | |
| - security-scan | |
| - e2e-tests | |
| - azure-role-parity | |
| - aws-iam-parity | |
| - gcp-secret-scope | |
| - git-secrets-allowlist | |
| - ecr-delete-selection | |
| - rds-deletion-protection-scope | |
| - cloud-sql-delete-scope | |
| - aws-tfstate-platform-key | |
| - azure-kv-access-policy | |
| - migration-down-gate | |
| if: always() | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Check all jobs | |
| # Allowlist on success instead of denylisting 'failure' and | |
| # 'cancelled'. A job that never dispatched reports 'skipped', and the | |
| # denylist form reported that as a pass -- so a gate could satisfy this | |
| # summary by not running at all, which is the same shape as the scanner | |
| # gap in #1836. Anything that is not exactly 'success' now fails, and | |
| # names itself in the log. | |
| env: | |
| NEEDS_JSON: ${{ toJSON(needs) }} | |
| run: | | |
| # jq is preinstalled on the GitHub Ubuntu runner image (no new deps). | |
| not_success="$(jq -r 'to_entries[] | |
| | select(.value.result != "success") | |
| | " \(.key): \(.value.result)"' <<<"$NEEDS_JSON")" | |
| if [[ -n "$not_success" ]]; then | |
| echo "::error::not every required CI job succeeded" | |
| echo "$not_success" | |
| exit 1 | |
| fi | |
| echo "All CI checks passed!" | |
| - name: Post status | |
| run: | | |
| { | |
| echo "## CI Status" | |
| echo "" | |
| echo "✅ All CI checks completed successfully!" | |
| } >> "$GITHUB_STEP_SUMMARY" |