Skip to content

Commit eea81bb

Browse files
#866 Add brokered validation approval helper (#890)
* #866 Add brokered validation approval helper * #866 Fix validation approval helper trust and staleness checks --------- Co-authored-by: svelderrainruiz <noreply@github.com>
1 parent d31d018 commit eea81bb

5 files changed

Lines changed: 1611 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
name: Validation Approval Helper
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
run_id:
7+
description: 'Target workflow run id waiting on validation approval'
8+
required: true
9+
type: string
10+
broker_mode:
11+
description: 'Use an existing decision receipt or reevaluate the broker from artifacts'
12+
required: true
13+
default: consume
14+
type: choice
15+
options:
16+
- consume
17+
- evaluate
18+
pull_request_number:
19+
description: 'Pull request number for broker reevaluation'
20+
required: false
21+
type: string
22+
artifact_run_id:
23+
description: 'Artifact source run id (defaults to target run id when blank)'
24+
required: false
25+
type: string
26+
decision_artifact_name:
27+
description: 'Decision artifact name when broker_mode=consume'
28+
required: false
29+
default: validation-approval-decision
30+
type: string
31+
attestation_artifact_name:
32+
description: 'Validation attestation artifact name when broker_mode=evaluate'
33+
required: false
34+
default: validation-agent-attestation
35+
type: string
36+
deployment_determinism_artifact_name:
37+
description: 'Deployment determinism artifact name when broker_mode=evaluate'
38+
required: false
39+
default: validation-deployment-determinism
40+
type: string
41+
approve:
42+
description: 'Approve the pending validation deployment when the helper reports ready'
43+
required: true
44+
default: false
45+
type: boolean
46+
47+
permissions:
48+
contents: read
49+
actions: read
50+
deployments: write
51+
pull-requests: read
52+
53+
jobs:
54+
validation-approval-helper:
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v5
58+
59+
- uses: actions/setup-node@v5
60+
with:
61+
node-version: '20'
62+
cache: 'npm'
63+
64+
- name: Install Node dependencies
65+
run: node tools/npm/cli.mjs ci --ignore-scripts
66+
67+
- name: Build TypeScript utilities
68+
run: node tools/npm/run-script.mjs build
69+
70+
- name: Require pull request number for broker reevaluation
71+
if: inputs.broker_mode == 'evaluate'
72+
shell: bash
73+
run: |
74+
set -euo pipefail
75+
if [[ -z "${{ inputs.pull_request_number }}" ]]; then
76+
echo "::error::pull_request_number is required when broker_mode=evaluate."
77+
exit 1
78+
fi
79+
80+
- name: Collect Copilot review signal
81+
if: inputs.broker_mode == 'evaluate'
82+
env:
83+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
84+
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
85+
run: |
86+
node dist/tools/priority/copilot-review-signal.js \
87+
--repo "${{ github.repository }}" \
88+
--pr "${{ inputs.pull_request_number }}" \
89+
--out tests/results/_agent/reviews/copilot-review-signal.json \
90+
--step-summary "$GITHUB_STEP_SUMMARY"
91+
92+
- name: Download existing broker decision artifact
93+
if: inputs.broker_mode == 'consume'
94+
uses: actions/download-artifact@v5
95+
with:
96+
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
97+
run-id: ${{ inputs.artifact_run_id || inputs.run_id }}
98+
name: ${{ inputs.decision_artifact_name }}
99+
path: tests/results/_agent/approvals/source-decision
100+
101+
- name: Download validation attestation artifact
102+
if: inputs.broker_mode == 'evaluate'
103+
uses: actions/download-artifact@v5
104+
with:
105+
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
106+
run-id: ${{ inputs.artifact_run_id || inputs.run_id }}
107+
name: ${{ inputs.attestation_artifact_name }}
108+
path: tests/results/_agent/reviews/source-attestation
109+
110+
- name: Download validation deployment determinism artifact
111+
if: inputs.broker_mode == 'evaluate'
112+
uses: actions/download-artifact@v5
113+
with:
114+
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
115+
run-id: ${{ inputs.artifact_run_id || inputs.run_id }}
116+
name: ${{ inputs.deployment_determinism_artifact_name }}
117+
path: tests/results/_agent/deployments/source-determinism
118+
119+
- name: Run validation approval helper
120+
env:
121+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
122+
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
123+
shell: bash
124+
run: |
125+
set -euo pipefail
126+
args=(
127+
"tools/priority/validation-approval-helper.mjs"
128+
"--repo" "${{ github.repository }}"
129+
"--run-id" "${{ inputs.run_id }}"
130+
"--environment" "validation"
131+
"--decision-out" "tests/results/_agent/approvals/validation-approval-decision.json"
132+
"--out" "tests/results/_agent/approvals/validation-approval-helper.json"
133+
"--step-summary" "$GITHUB_STEP_SUMMARY"
134+
)
135+
136+
if [[ "${{ inputs.approve }}" == 'true' ]]; then
137+
args+=("--approve")
138+
fi
139+
140+
if [[ "${{ inputs.broker_mode }}" == 'consume' ]]; then
141+
args+=(
142+
"--decision"
143+
"tests/results/_agent/approvals/source-decision/validation-approval-decision.json"
144+
)
145+
else
146+
args+=(
147+
"--pr" "${{ inputs.pull_request_number }}"
148+
"--signal" "tests/results/_agent/reviews/copilot-review-signal.json"
149+
"--attestation" "tests/results/_agent/reviews/source-attestation/validation-agent-attestation.json"
150+
"--deployment-determinism" "tests/results/_agent/deployments/source-determinism/validation-deployment-determinism.json"
151+
)
152+
fi
153+
154+
node "${args[@]}"
155+
156+
- name: Upload validation approval artifacts
157+
if: always()
158+
uses: actions/upload-artifact@v5
159+
with:
160+
name: validation-approval-helper-${{ github.run_id }}-${{ github.run_attempt }}
161+
path: |
162+
tests/results/_agent/approvals/validation-approval-decision.json
163+
tests/results/_agent/approvals/validation-approval-helper.json
164+
if-no-files-found: error

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"priority:review:signal": "tsc -p tsconfig.json && node dist/tools/priority/copilot-review-signal.js",
6262
"priority:validation:attestation": "tsc -p tsconfig.json && node dist/tools/priority/validation-agent-attestation.js",
6363
"priority:validation:broker": "node tools/priority/validation-approval-broker.mjs",
64+
"priority:validation:helper": "node tools/priority/validation-approval-helper.mjs",
6465
"priority:merge-sync": "node tools/priority/merge-sync-pr.mjs",
6566
"priority:event:ingest": "node tools/priority/event-ingest.mjs",
6667
"priority:decision:ledger": "node tools/priority/decision-ledger.mjs",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env node
2+
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import path from 'node:path';
6+
import { readFileSync } from 'node:fs';
7+
8+
const repoRoot = process.cwd();
9+
10+
function readRepoFile(relativePath) {
11+
return readFileSync(path.join(repoRoot, relativePath), 'utf8');
12+
}
13+
14+
test('validation approval helper workflow is workflow_dispatch-only and pinned to validation', () => {
15+
const workflow = readRepoFile('.github/workflows/validation-approval-helper.yml');
16+
17+
assert.match(workflow, /^on:\s*\r?\n\s+workflow_dispatch:/m);
18+
assert.doesNotMatch(workflow, /^\s*pull_request:/m);
19+
assert.doesNotMatch(workflow, /^\s*pull_request_target:/m);
20+
assert.match(workflow, /--environment" "validation"/);
21+
assert.doesNotMatch(workflow, /--environment" "production"/);
22+
assert.doesNotMatch(workflow, /--environment" "monthly-stability-release"/);
23+
assert.doesNotMatch(workflow, /--environment" "publish"/);
24+
});
25+
26+
test('validation approval helper workflow downloads source artifacts and uploads the decision artifacts', () => {
27+
const workflow = readRepoFile('.github/workflows/validation-approval-helper.yml');
28+
29+
assert.match(workflow, /name: Download existing broker decision artifact\s+if: inputs\.broker_mode == 'consume'\s+uses: actions\/download-artifact@v5/);
30+
assert.match(workflow, /name: Download validation attestation artifact\s+if: inputs\.broker_mode == 'evaluate'\s+uses: actions\/download-artifact@v5/);
31+
assert.match(workflow, /name: Download validation deployment determinism artifact\s+if: inputs\.broker_mode == 'evaluate'\s+uses: actions\/download-artifact@v5/);
32+
assert.match(workflow, /run-id: \$\{\{ inputs\.artifact_run_id \|\| inputs\.run_id \}\}/);
33+
assert.match(workflow, /name: Upload validation approval artifacts\s+if: always\(\)\s+uses: actions\/upload-artifact@v5/);
34+
assert.match(workflow, /validation-approval-decision\.json/);
35+
assert.match(workflow, /validation-approval-helper\.json/);
36+
});
37+
38+
test('validation approval helper workflow limits token permissions to the approval surface', () => {
39+
const workflow = readRepoFile('.github/workflows/validation-approval-helper.yml');
40+
41+
assert.match(workflow, /permissions:\s+contents: read\s+actions: read\s+deployments: write\s+pull-requests: read/ms);
42+
assert.doesNotMatch(workflow, /permissions:\s+write-all/);
43+
});

0 commit comments

Comments
 (0)