From 8a18a5c025e273272bb1f4d3c6793442c7c39fa4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 12:31:04 -0800 Subject: [PATCH] improvement(ci): validate pushes independently of deployments --- .github/scripts/wait-for-push-checks.sh | 58 +++++++ .github/workflows/ci.yml | 27 +++- .github/workflows/validation.yml | 13 ++ scripts/wait-for-push-checks.test.ts | 202 ++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100755 .github/scripts/wait-for-push-checks.sh create mode 100644 .github/workflows/validation.yml create mode 100644 scripts/wait-for-push-checks.test.ts diff --git a/.github/scripts/wait-for-push-checks.sh b/.github/scripts/wait-for-push-checks.sh new file mode 100755 index 00000000000..1f69bb115e5 --- /dev/null +++ b/.github/scripts/wait-for-push-checks.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITHUB_REPOSITORY:?}" +: "${GITHUB_REPOSITORY_ID:?}" +: "${GITHUB_REF_NAME:?}" +: "${GITHUB_SHA:?}" + +provenance=$(cat <<'JQ' + def trusted: + .path == ".github/workflows/validation.yml" + and .event == "push" + and .head_sha == $sha + and .head_branch == $branch + and .repository.full_name == $repo + and .repository.id == $repo_id + and .head_repository.full_name == $repo + and .head_repository.id == $repo_id + and (.id | type == "number" and . > 0 and floor == .) + and (.workflow_id | type == "number" and . > 0 and floor == .); +JQ +) + +query() { + jq --arg repo "$GITHUB_REPOSITORY" --arg branch "$GITHUB_REF_NAME" \ + --arg sha "$GITHUB_SHA" --argjson repo_id "$GITHUB_REPOSITORY_ID" "$@" +} + +run_id='' +deadline=$((SECONDS + 120)) +while [ "$SECONDS" -lt "$deadline" ]; do + response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \ + -f event=push -f branch="$GITHUB_REF_NAME" -f head_sha="$GITHUB_SHA" -F per_page=100) + selection=$(query "$provenance + if (.workflow_runs | type) != \"array\" or .total_count != (.workflow_runs | length) + then error(\"Incomplete validation run listing\") else .workflow_runs end + | map(select(trusted)) + | if length > 1 then error(\"Ambiguous matching push validations\") + elif length == 1 then .[0] | [.id, .workflow_id] | @tsv + else empty end" -r <<< "$response") + if [ -n "$selection" ]; then + IFS=$'\t' read -r run_id workflow_id <<< "$selection" + break + fi + sleep 2 +done + +if [ -z "$run_id" ]; then + echo '::error::No trusted push validation appeared for this exact commit.' >&2 + exit 1 +fi + +gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" --exit-status --interval 30 --compact +response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") +query -e --argjson run_id "$run_id" --argjson workflow_id "$workflow_id" "$provenance + trusted and .id == \$run_id and .workflow_id == \$workflow_id + and .status == \"completed\" and .conclusion == \"success\"" <<< "$response" > /dev/null +echo "Push validation $run_id passed for $GITHUB_SHA." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 705b2150f5a..dff08f6ecfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,30 @@ permissions: jobs: checks: name: checks - if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request' + if: github.event_name == 'pull_request' uses: ./.github/workflows/checks.yml + push-checks: + name: push-checks + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + runs-on: ubuntu-24.04 + # Validation can chain a 25-minute build and a 30-minute acceptance job. + timeout-minutes: 90 + permissions: + contents: read + actions: read + checks: read + steps: + - name: Checkout admission gate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + sparse-checkout: .github/scripts + persist-credentials: false + - name: Require this commit's independent push validation + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/wait-for-push-checks.sh + # Detect if this is a version release commit (e.g., "v0.5.24: ...") # Smallest runner on purpose: a few seconds of pure shell over the commit # message, no checkout and no install. @@ -116,14 +137,14 @@ jobs: contents: read id-token: write name: migrate - needs: [checks] + needs: [push-checks] # Explicit need results instead of the implicit success(): a skipped job # anywhere in the transitive needs chain silently fails implicit success() # and cascade-skips the deploy chain (migrate -> promote -> # CodeDeploy) — this bit us on 2026-07-23. State requirements explicitly. if: >- !cancelled() && - needs.checks.result == 'success' && + needs.push-checks.result == 'success' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') uses: ./.github/workflows/migrate.yml diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml new file mode 100644 index 00000000000..02ef9aaea68 --- /dev/null +++ b/.github/workflows/validation.yml @@ -0,0 +1,13 @@ +name: validation + +on: + push: + branches: [main, staging] + +permissions: + contents: read + +jobs: + checks: + name: checks + uses: ./.github/workflows/checks.yml diff --git a/scripts/wait-for-push-checks.test.ts b/scripts/wait-for-push-checks.test.ts new file mode 100644 index 00000000000..1241b032b20 --- /dev/null +++ b/scripts/wait-for-push-checks.test.ts @@ -0,0 +1,202 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const script = fileURLToPath(new URL('../.github/scripts/wait-for-push-checks.sh', import.meta.url)) +const repository = 'example/project' +const sha = 'a'.repeat(40) + +function validation(overrides: Record = {}) { + return { + id: 101, + workflow_id: 11, + path: '.github/workflows/validation.yml', + event: 'push', + head_sha: sha, + head_branch: 'staging', + repository: { id: 23, full_name: repository }, + head_repository: { id: 23, full_name: repository }, + status: 'completed', + conclusion: 'success', + ...overrides, + } +} + +interface GateOptions { + listings?: unknown[] + terminal?: unknown + listStatus?: number + terminalStatus?: number + watchStatus?: number +} + +/** Executes the real admission shell and jq against independent CLI response fixtures. */ +function runGate(options: GateOptions = {}) { + const initial = options.listings ?? [{ total_count: 1, workflow_runs: [validation()] }] + const fixtures = initial.map((value) => JSON.stringify(value)) + return spawnSync( + 'bash', + [ + '-c', + `set -euo pipefail + poll=0 + gh() { + if [ "$1" = run ] && [ "$2" = watch ]; then + printf 'WATCH %s\\n' "$3" + return "$WATCH_STATUS" + fi + case "$2" in + repos/example/project/actions/runs) + if [ "$LIST_STATUS" -ne 0 ]; then return "$LIST_STATUS"; fi + if [ "$poll" -eq 0 ]; then printf '%s\\n' "$FIRST_LIST"; + else printf '%s\\n' "$NEXT_LIST"; fi + ;; + repos/example/project/actions/runs/101) + if [ "$TERMINAL_STATUS" -ne 0 ]; then return "$TERMINAL_STATUS"; fi + printf '%s\\n' "$TERMINAL_RESPONSE" + ;; + *) return 97 ;; + esac + } + sleep() { + poll=$((poll + 1)) + if [ "$poll" -gt 1 ]; then SECONDS=$((SECONDS + 121)); fi + } + source "$1"`, + 'push-validation-fixture', + script, + ], + { + encoding: 'utf8', + env: { + PATH: process.env.PATH, + GITHUB_REPOSITORY: repository, + GITHUB_REPOSITORY_ID: '23', + GITHUB_REF_NAME: 'staging', + GITHUB_SHA: sha, + FIRST_LIST: fixtures[0], + NEXT_LIST: fixtures[1] ?? fixtures[0], + TERMINAL_RESPONSE: JSON.stringify(options.terminal ?? validation()), + LIST_STATUS: String(options.listStatus ?? 0), + TERMINAL_STATUS: String(options.terminalStatus ?? 0), + WATCH_STATUS: String(options.watchStatus ?? 0), + }, + timeout: 3_000, + } + ) +} + +const unrelatedRuns = [ + ['another workflow', { path: '.github/workflows/ci.yml' }], + ['a manual run', { event: 'workflow_dispatch' }], + ['another commit', { head_sha: 'b'.repeat(40) }], + ['another branch', { head_branch: 'main' }], + ['another repository name', { repository: { id: 23, full_name: 'other/project' } }], + ['another repository ID', { repository: { id: 24, full_name: repository } }], + ['another source repository name', { head_repository: { id: 23, full_name: 'other/project' } }], + ['another source repository ID', { head_repository: { id: 24, full_name: repository } }], + ['missing source repository', { head_repository: null }], + ['missing workflow path', { path: undefined }], + ['a string run ID', { id: '101' }], + ['a nonpositive run ID', { id: 0 }], + ['a fractional run ID', { id: 101.5 }], + ['a string workflow ID', { workflow_id: '11' }], + ['a nonpositive workflow ID', { workflow_id: 0 }], + ['a fractional workflow ID', { workflow_id: 11.5 }], +] as const + +describe('independent push-validation admission', () => { + it('admits the matching trusted successful validation', () => { + const result = runGate() + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain(`Push validation 101 passed for ${sha}.`) + }) + + it('waits for the matching run to register without admitting another workflow', () => { + const result = runGate({ + listings: [ + { total_count: 1, workflow_runs: [validation({ path: '.github/workflows/ci.yml' })] }, + { + total_count: 1, + workflow_runs: [validation({ status: 'in_progress', conclusion: null })], + }, + ], + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('WATCH 101') + }) + + it('selects the validation among unrelated runs for the same commit', () => { + const result = runGate({ + listings: [ + { + total_count: 2, + workflow_runs: [validation({ id: 102, path: '.github/workflows/ci.yml' }), validation()], + }, + ], + }) + expect(result.status, result.stderr).toBe(0) + }) + + it.each(unrelatedRuns)('denies admission based on %s', (_name, overrides) => { + const result = runGate({ + listings: [{ total_count: 1, workflow_runs: [validation(overrides)] }], + }) + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain('WATCH') + expect(result.stderr).toContain('No trusted push validation') + }) + + it.each([ + ['no registered run', { total_count: 0, workflow_runs: [] }], + [ + 'an ambiguous run', + { total_count: 2, workflow_runs: [validation(), validation({ id: 102 })] }, + ], + ['a truncated listing', { total_count: 2, workflow_runs: [validation()] }], + ['a missing count', { workflow_runs: [validation()] }], + ['malformed runs', { total_count: 1, workflow_runs: null }], + ])('denies admission with %s', (_name, listing) => { + const result = runGate({ listings: [listing] }) + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain('WATCH') + }) + + it('propagates a listing API failure', () => { + const result = runGate({ listStatus: 42 }) + expect(result.status).toBe(42) + expect(result.stdout).not.toContain('WATCH') + }) + + it('propagates the official watcher failure', () => { + const result = runGate({ watchStatus: 43 }) + expect(result.status).toBe(43) + expect(result.stdout).not.toContain('passed for') + }) + + it('propagates a terminal API failure', () => { + const result = runGate({ terminalStatus: 44 }) + expect(result.status).toBe(44) + expect(result.stdout).not.toContain('passed for') + }) + + it.each(['failure', 'cancelled', 'skipped', 'neutral', 'timed_out', null])( + 'rejects a terminal conclusion of %s even if the watcher exits successfully', + (conclusion) => { + const result = runGate({ terminal: validation({ conclusion }) }) + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain('passed for') + } + ) + + it.each([ + ['a run still in progress', { status: 'in_progress' }], + ['a replaced run', { id: 102 }], + ['a replaced workflow', { workflow_id: 12 }], + ...unrelatedRuns, + ])('revalidates terminal provenance against %s', (_name, overrides) => { + const result = runGate({ terminal: validation(overrides) }) + expect(result.status).not.toBe(0) + expect(result.stdout).not.toContain('passed for') + }) +})