Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/scripts/wait-for-push-checks.sh
Original file line number Diff line number Diff line change
@@ -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."
27 changes: 24 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: validation

on:
push:
branches: [main, staging]

permissions:
contents: read

jobs:
checks:
name: checks
uses: ./.github/workflows/checks.yml
202 changes: 202 additions & 0 deletions scripts/wait-for-push-checks.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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')
})
})
Loading