Skip to content

Commit 4a3e62d

Browse files
authored
improvement(ci): validate pushes independently of deployments (#9642)
Run push validation independently of the deployment queue, and require the exact trusted successful run before migrations. Preserve deployment and release ordering. Validate the admission protocol with real Bash/jq boundary cases and guard mutations.
1 parent 2e513a8 commit 4a3e62d

4 files changed

Lines changed: 297 additions & 3 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
: "${GITHUB_REPOSITORY:?}"
5+
: "${GITHUB_REPOSITORY_ID:?}"
6+
: "${GITHUB_REF_NAME:?}"
7+
: "${GITHUB_SHA:?}"
8+
9+
provenance=$(cat <<'JQ'
10+
def trusted:
11+
.path == ".github/workflows/validation.yml"
12+
and .event == "push"
13+
and .head_sha == $sha
14+
and .head_branch == $branch
15+
and .repository.full_name == $repo
16+
and .repository.id == $repo_id
17+
and .head_repository.full_name == $repo
18+
and .head_repository.id == $repo_id
19+
and (.id | type == "number" and . > 0 and floor == .)
20+
and (.workflow_id | type == "number" and . > 0 and floor == .);
21+
JQ
22+
)
23+
24+
query() {
25+
jq --arg repo "$GITHUB_REPOSITORY" --arg branch "$GITHUB_REF_NAME" \
26+
--arg sha "$GITHUB_SHA" --argjson repo_id "$GITHUB_REPOSITORY_ID" "$@"
27+
}
28+
29+
run_id=''
30+
deadline=$((SECONDS + 120))
31+
while [ "$SECONDS" -lt "$deadline" ]; do
32+
response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs" --method GET \
33+
-f event=push -f branch="$GITHUB_REF_NAME" -f head_sha="$GITHUB_SHA" -F per_page=100)
34+
selection=$(query "$provenance
35+
if (.workflow_runs | type) != \"array\" or .total_count != (.workflow_runs | length)
36+
then error(\"Incomplete validation run listing\") else .workflow_runs end
37+
| map(select(trusted))
38+
| if length > 1 then error(\"Ambiguous matching push validations\")
39+
elif length == 1 then .[0] | [.id, .workflow_id] | @tsv
40+
else empty end" -r <<< "$response")
41+
if [ -n "$selection" ]; then
42+
IFS=$'\t' read -r run_id workflow_id <<< "$selection"
43+
break
44+
fi
45+
sleep 2
46+
done
47+
48+
if [ -z "$run_id" ]; then
49+
echo '::error::No trusted push validation appeared for this exact commit.' >&2
50+
exit 1
51+
fi
52+
53+
gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" --exit-status --interval 30 --compact
54+
response=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
55+
query -e --argjson run_id "$run_id" --argjson workflow_id "$workflow_id" "$provenance
56+
trusted and .id == \$run_id and .workflow_id == \$workflow_id
57+
and .status == \"completed\" and .conclusion == \"success\"" <<< "$response" > /dev/null
58+
echo "Push validation $run_id passed for $GITHUB_SHA."

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,30 @@ permissions:
3333
jobs:
3434
checks:
3535
name: checks
36-
if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request'
36+
if: github.event_name == 'pull_request'
3737
uses: ./.github/workflows/checks.yml
3838

39+
push-checks:
40+
name: push-checks
41+
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
42+
runs-on: ubuntu-24.04
43+
# Validation can chain a 25-minute build and a 30-minute acceptance job.
44+
timeout-minutes: 90
45+
permissions:
46+
contents: read
47+
actions: read
48+
checks: read
49+
steps:
50+
- name: Checkout admission gate
51+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
52+
with:
53+
sparse-checkout: .github/scripts
54+
persist-credentials: false
55+
- name: Require this commit's independent push validation
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
run: bash .github/scripts/wait-for-push-checks.sh
59+
3960
# Detect if this is a version release commit (e.g., "v0.5.24: ...")
4061
# Smallest runner on purpose: a few seconds of pure shell over the commit
4162
# message, no checkout and no install.
@@ -116,14 +137,14 @@ jobs:
116137
contents: read
117138
id-token: write
118139
name: migrate
119-
needs: [checks]
140+
needs: [push-checks]
120141
# Explicit need results instead of the implicit success(): a skipped job
121142
# anywhere in the transitive needs chain silently fails implicit success()
122143
# and cascade-skips the deploy chain (migrate -> promote ->
123144
# CodeDeploy) — this bit us on 2026-07-23. State requirements explicitly.
124145
if: >-
125146
!cancelled() &&
126-
needs.checks.result == 'success' &&
147+
needs.push-checks.result == 'success' &&
127148
github.event_name == 'push' &&
128149
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
129150
uses: ./.github/workflows/migrate.yml

‎.github/workflows/validation.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: validation
2+
3+
on:
4+
push:
5+
branches: [main, staging]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
checks:
12+
name: checks
13+
uses: ./.github/workflows/checks.yml
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { fileURLToPath } from 'node:url'
3+
import { describe, expect, it } from 'vitest'
4+
5+
const script = fileURLToPath(new URL('../.github/scripts/wait-for-push-checks.sh', import.meta.url))
6+
const repository = 'example/project'
7+
const sha = 'a'.repeat(40)
8+
9+
function validation(overrides: Record<string, unknown> = {}) {
10+
return {
11+
id: 101,
12+
workflow_id: 11,
13+
path: '.github/workflows/validation.yml',
14+
event: 'push',
15+
head_sha: sha,
16+
head_branch: 'staging',
17+
repository: { id: 23, full_name: repository },
18+
head_repository: { id: 23, full_name: repository },
19+
status: 'completed',
20+
conclusion: 'success',
21+
...overrides,
22+
}
23+
}
24+
25+
interface GateOptions {
26+
listings?: unknown[]
27+
terminal?: unknown
28+
listStatus?: number
29+
terminalStatus?: number
30+
watchStatus?: number
31+
}
32+
33+
/** Executes the real admission shell and jq against independent CLI response fixtures. */
34+
function runGate(options: GateOptions = {}) {
35+
const initial = options.listings ?? [{ total_count: 1, workflow_runs: [validation()] }]
36+
const fixtures = initial.map((value) => JSON.stringify(value))
37+
return spawnSync(
38+
'bash',
39+
[
40+
'-c',
41+
`set -euo pipefail
42+
poll=0
43+
gh() {
44+
if [ "$1" = run ] && [ "$2" = watch ]; then
45+
printf 'WATCH %s\\n' "$3"
46+
return "$WATCH_STATUS"
47+
fi
48+
case "$2" in
49+
repos/example/project/actions/runs)
50+
if [ "$LIST_STATUS" -ne 0 ]; then return "$LIST_STATUS"; fi
51+
if [ "$poll" -eq 0 ]; then printf '%s\\n' "$FIRST_LIST";
52+
else printf '%s\\n' "$NEXT_LIST"; fi
53+
;;
54+
repos/example/project/actions/runs/101)
55+
if [ "$TERMINAL_STATUS" -ne 0 ]; then return "$TERMINAL_STATUS"; fi
56+
printf '%s\\n' "$TERMINAL_RESPONSE"
57+
;;
58+
*) return 97 ;;
59+
esac
60+
}
61+
sleep() {
62+
poll=$((poll + 1))
63+
if [ "$poll" -gt 1 ]; then SECONDS=$((SECONDS + 121)); fi
64+
}
65+
source "$1"`,
66+
'push-validation-fixture',
67+
script,
68+
],
69+
{
70+
encoding: 'utf8',
71+
env: {
72+
PATH: process.env.PATH,
73+
GITHUB_REPOSITORY: repository,
74+
GITHUB_REPOSITORY_ID: '23',
75+
GITHUB_REF_NAME: 'staging',
76+
GITHUB_SHA: sha,
77+
FIRST_LIST: fixtures[0],
78+
NEXT_LIST: fixtures[1] ?? fixtures[0],
79+
TERMINAL_RESPONSE: JSON.stringify(options.terminal ?? validation()),
80+
LIST_STATUS: String(options.listStatus ?? 0),
81+
TERMINAL_STATUS: String(options.terminalStatus ?? 0),
82+
WATCH_STATUS: String(options.watchStatus ?? 0),
83+
},
84+
timeout: 3_000,
85+
}
86+
)
87+
}
88+
89+
const unrelatedRuns = [
90+
['another workflow', { path: '.github/workflows/ci.yml' }],
91+
['a manual run', { event: 'workflow_dispatch' }],
92+
['another commit', { head_sha: 'b'.repeat(40) }],
93+
['another branch', { head_branch: 'main' }],
94+
['another repository name', { repository: { id: 23, full_name: 'other/project' } }],
95+
['another repository ID', { repository: { id: 24, full_name: repository } }],
96+
['another source repository name', { head_repository: { id: 23, full_name: 'other/project' } }],
97+
['another source repository ID', { head_repository: { id: 24, full_name: repository } }],
98+
['missing source repository', { head_repository: null }],
99+
['missing workflow path', { path: undefined }],
100+
['a string run ID', { id: '101' }],
101+
['a nonpositive run ID', { id: 0 }],
102+
['a fractional run ID', { id: 101.5 }],
103+
['a string workflow ID', { workflow_id: '11' }],
104+
['a nonpositive workflow ID', { workflow_id: 0 }],
105+
['a fractional workflow ID', { workflow_id: 11.5 }],
106+
] as const
107+
108+
describe('independent push-validation admission', () => {
109+
it('admits the matching trusted successful validation', () => {
110+
const result = runGate()
111+
expect(result.status, result.stderr).toBe(0)
112+
expect(result.stdout).toContain(`Push validation 101 passed for ${sha}.`)
113+
})
114+
115+
it('waits for the matching run to register without admitting another workflow', () => {
116+
const result = runGate({
117+
listings: [
118+
{ total_count: 1, workflow_runs: [validation({ path: '.github/workflows/ci.yml' })] },
119+
{
120+
total_count: 1,
121+
workflow_runs: [validation({ status: 'in_progress', conclusion: null })],
122+
},
123+
],
124+
})
125+
expect(result.status, result.stderr).toBe(0)
126+
expect(result.stdout).toContain('WATCH 101')
127+
})
128+
129+
it('selects the validation among unrelated runs for the same commit', () => {
130+
const result = runGate({
131+
listings: [
132+
{
133+
total_count: 2,
134+
workflow_runs: [validation({ id: 102, path: '.github/workflows/ci.yml' }), validation()],
135+
},
136+
],
137+
})
138+
expect(result.status, result.stderr).toBe(0)
139+
})
140+
141+
it.each(unrelatedRuns)('denies admission based on %s', (_name, overrides) => {
142+
const result = runGate({
143+
listings: [{ total_count: 1, workflow_runs: [validation(overrides)] }],
144+
})
145+
expect(result.status).not.toBe(0)
146+
expect(result.stdout).not.toContain('WATCH')
147+
expect(result.stderr).toContain('No trusted push validation')
148+
})
149+
150+
it.each([
151+
['no registered run', { total_count: 0, workflow_runs: [] }],
152+
[
153+
'an ambiguous run',
154+
{ total_count: 2, workflow_runs: [validation(), validation({ id: 102 })] },
155+
],
156+
['a truncated listing', { total_count: 2, workflow_runs: [validation()] }],
157+
['a missing count', { workflow_runs: [validation()] }],
158+
['malformed runs', { total_count: 1, workflow_runs: null }],
159+
])('denies admission with %s', (_name, listing) => {
160+
const result = runGate({ listings: [listing] })
161+
expect(result.status).not.toBe(0)
162+
expect(result.stdout).not.toContain('WATCH')
163+
})
164+
165+
it('propagates a listing API failure', () => {
166+
const result = runGate({ listStatus: 42 })
167+
expect(result.status).toBe(42)
168+
expect(result.stdout).not.toContain('WATCH')
169+
})
170+
171+
it('propagates the official watcher failure', () => {
172+
const result = runGate({ watchStatus: 43 })
173+
expect(result.status).toBe(43)
174+
expect(result.stdout).not.toContain('passed for')
175+
})
176+
177+
it('propagates a terminal API failure', () => {
178+
const result = runGate({ terminalStatus: 44 })
179+
expect(result.status).toBe(44)
180+
expect(result.stdout).not.toContain('passed for')
181+
})
182+
183+
it.each(['failure', 'cancelled', 'skipped', 'neutral', 'timed_out', null])(
184+
'rejects a terminal conclusion of %s even if the watcher exits successfully',
185+
(conclusion) => {
186+
const result = runGate({ terminal: validation({ conclusion }) })
187+
expect(result.status).not.toBe(0)
188+
expect(result.stdout).not.toContain('passed for')
189+
}
190+
)
191+
192+
it.each([
193+
['a run still in progress', { status: 'in_progress' }],
194+
['a replaced run', { id: 102 }],
195+
['a replaced workflow', { workflow_id: 12 }],
196+
...unrelatedRuns,
197+
])('revalidates terminal provenance against %s', (_name, overrides) => {
198+
const result = runGate({ terminal: validation(overrides) })
199+
expect(result.status).not.toBe(0)
200+
expect(result.stdout).not.toContain('passed for')
201+
})
202+
})

0 commit comments

Comments
 (0)