2929 pull-requests : read
3030 statuses : write
3131 steps :
32- # Trusted main definition: no candidate checkout, artifacts, or executable inputs.
32+ # Execute only the helper belonging to this immutable workflow revision.
33+ - name : Check out the trusted review policy
34+ uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35+ with :
36+ ref : ${{ github.workflow_sha }}
37+ persist-credentials : false
38+
3339 - name : Re-evaluate current PR and queue after review changes
40+ env :
41+ GH_TOKEN : ${{ github.token }}
42+ GIT_CONFIG_COUNT : ' 2'
43+ GIT_CONFIG_KEY_0 : credential.helper
44+ GIT_CONFIG_VALUE_0 : ' '
45+ GIT_CONFIG_KEY_1 : credential.https://github.com.helper
46+ GIT_CONFIG_VALUE_1 : ' !gh auth git-credential'
3447 uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
3548 with :
3649 script : |
@@ -48,12 +61,37 @@ jobs:
4861 run.head_sha !== signal.head_sha || run.path.split('@', 1)[0] !== '.github/workflows/castiron-custom-code.yml') {
4962 throw new Error('Unexpected or superseded review signal');
5063 }
64+ async function mergeBase(base, head) {
65+ const {data: comparison} = await github.rest.repos.compareCommitsWithBasehead({
66+ ...context.repo, basehead: `${base}...${head}`, per_page: 1,
67+ });
68+ const sha = comparison.merge_base_commit?.sha;
69+ if (!/^[0-9a-f]{40}$/.test(sha || '')) throw new Error('Invalid policy merge base');
70+ return sha;
71+ }
72+ async function requiresHuman(pr, mainSha) {
73+ const path = require('node:path');
74+ const fs = require('node:fs');
75+ const root = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'castiron-review-'));
76+ try {
77+ const result = await exec.getExecOutput('python3', ['-I',
78+ 'scripts/castiron/custom_code_policy.py', 'review-required',
79+ '--repository', repo, '--repo', path.join(root, 'objects.git'),
80+ '--base', pr.base.sha, '--head', pr.head.sha, '--main', mainSha], {silent: true});
81+ const decision = JSON.parse(result.stdout);
82+ if (typeof decision.requires_human !== 'boolean') throw new Error('Invalid review decision');
83+ return decision.requires_human;
84+ } finally {
85+ fs.rmSync(root, {recursive: true, force: true});
86+ }
87+ }
5188 const dirtyTargets = new Set();
5289 // Retry discovery within this job timeout while targets are changing.
5390 while (true) {
5491 const candidates = [];
5592 let pulls = run.pull_requests?.length ? run.pull_requests : await github.paginate(
5693 github.rest.repos.listPullRequestsAssociatedWithCommit, {...context.repo, commit_sha: run.head_sha});
94+ const hasAssociation = pulls.length > 0;
5795 if (!pulls.length) pulls = await github.paginate(github.rest.pulls.list, {
5896 ...context.repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`,
5997 });
@@ -62,18 +100,41 @@ jobs:
62100 if (pr.state !== 'open' || pr.base.ref !== 'next' || pr.base.repo.full_name !== repo ||
63101 (item.head?.sha && pr.head.sha !== item.head.sha) || !/^[0-9a-f]{40}$/.test(pr.head.sha)) continue;
64102 if (run.head_sha !== pr.head.sha) {
65- const {data: merge} = await github.rest.git.getRef({...context.repo, ref: `pull/${pr.number}/merge`});
66103 const {data: commit} = await github.rest.repos.getCommit({...context.repo, ref: run.head_sha});
67- if (merge.object.sha !== run.head_sha || commit.parents.length !== 2 ||
68- commit.parents[0].sha !== pr.base.sha || commit.parents[1].sha !== pr.head.sha) continue;
104+ if (commit.parents.length !== 2 || commit.parents[1].sha !== pr.head.sha ||
105+ !/^[0-9a-f]{40}$/.test(commit.parents[0].sha) ||
106+ await mergeBase(commit.parents[0].sha, pr.base.sha) !== commit.parents[0].sha) continue;
107+ // Without a commit association, the live merge ref must identify this PR.
108+ if (!hasAssociation) {
109+ const {data: merge} = await github.rest.git.getRef({...context.repo, ref: `pull/${pr.number}/merge`});
110+ if (merge.object.sha !== run.head_sha || commit.parents[0].sha !== pr.base.sha) continue;
111+ }
69112 }
70113 candidates.push(pr);
71114 }
72115 if (!candidates.length) return; // Main, closed, and superseded PR reviews have no next target.
73- if (candidates.length !== 1) throw new Error('Review signal identifies ambiguous next PRs');
74- const pr = candidates[0];
116+ // Commit associations may include other PRs sharing the same head.
117+ const matching = candidates.length === 1 ? candidates : candidates.filter(pr =>
118+ Number.isSafeInteger(run.head_repository?.id) && run.head_repository.id > 0 &&
119+ pr.head.repo?.id === run.head_repository.id && typeof run.head_branch === 'string' &&
120+ run.head_branch.length > 0 && pr.head.ref === run.head_branch);
121+ if (matching.length !== 1) throw new Error('Review signal identifies ambiguous next PRs');
122+ const pr = matching[0];
75123 const files = await github.paginate(github.rest.pulls.listFiles, {...context.repo, pull_number: pr.number});
76124 if (!files.some(file => file.filename === '.castiron-ratchet.json')) return;
125+ const {data: main} = await github.rest.git.getRef({...context.repo, ref: 'heads/main'});
126+ const mainSha = main.object.sha;
127+ const requiresReview = await requiresHuman(pr, mainSha);
128+ if (!requiresReview) {
129+ const [{data: current}, {data: target}, main] = await Promise.all([
130+ github.rest.pulls.get({...context.repo, pull_number: pr.number}),
131+ github.rest.git.getRef({...context.repo, ref: 'heads/next'}),
132+ mainSha && github.rest.git.getRef({...context.repo, ref: 'heads/main'}),
133+ ]);
134+ if (current.head.sha !== pr.head.sha || current.base.sha !== pr.base.sha ||
135+ target.object.sha !== pr.base.sha || (main && main.data.object.sha !== mainSha)) continue;
136+ return;
137+ }
77138 // Read authoritative reviews, including edits: submittedAt alone misses
78139 // removal/restoration of an attestation. Never trust candidate event artifacts.
79140 let reviewCursor = null;
0 commit comments