Skip to content

chore(auth): enumerate permission actions and resources, and check the TS unions against them #1905

chore(auth): enumerate permission actions and resources, and check the TS unions against them

chore(auth): enumerate permission actions and resources, and check the TS unions against them #1905

Workflow file for this run

# 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"