-
Notifications
You must be signed in to change notification settings - Fork 1
423 lines (387 loc) · 20.8 KB
/
Copy pathspecgit-accept.yml
File metadata and controls
423 lines (387 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
name: SpecGit Acceptance
on:
pull_request:
branches: ["main"]
# A draft PR fails the verdict (pr_draft), so the draft→ready
# transition must re-verdict. Listing types replaces the defaults,
# so the default activity types are listed alongside. Title and body
# edits change live acceptance evidence even when the head is unchanged.
types: [opened, synchronize, reopened, ready_for_review, edited]
workflow_dispatch:
permissions:
contents: read
issues: read
pull-requests: read
actions: read
# One verdict per head at a time (#319): a newer trigger event (a push
# after the draft opened, then ready_for_review) supersedes the older
# run of the same pull request instead of leaving parallel copies
# burning identical wait budgets. The surviving run re-verdicts fully.
concurrency:
group: specgit-accept-${{ github.ref }}
cancel-in-progress: true
jobs:
specgit-acceptance:
name: SpecGit Acceptance
# Portable gate for any adopting repository: the published CLI is
# installed at the exact version `specgit init` pinned. The adopting
# project's own toolchain (package manager, lockfile, build, layout)
# is never assumed and never invoked.
runs-on: ubuntu-latest
# This repository's required Linux unit suite normally takes about 28
# minutes, so leave enough time for it to finish and for the verdict.
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Pin execution to this event; a newer branch push must not change
# the code tested by an older run. Manual dispatch uses its own SHA.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Restore the event branch
if: github.event_name == 'pull_request' || github.ref_type == 'branch'
env:
SPECGIT_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
git check-ref-format --branch "$SPECGIT_BRANCH" >/dev/null
git switch --create "$SPECGIT_BRANCH"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.19'
package-manager-cache: false
- name: Install pinned SpecGit CLI
# Exact version on purpose (no ^): the gate must evaluate with the
# same CLI generation that wrote the binding; upgrades are a
# deliberate re-init. An isolated prefix avoids installing the
# adopting project's dependencies or running its lifecycle scripts.
run: npm install --prefix "$RUNNER_TEMP/specgit-cli" --no-save --no-audit --no-fund specgit@1.14.0
- name: Prepare approved policy for acceptance
env:
GH_TOKEN: ${{ github.token }}
SPECGIT_WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml
run: |
gh auth setup-git
node "$RUNNER_TEMP/specgit-cli/node_modules/specgit/dist/automation/workflow-policy.js"
- name: Wait for sibling checks
# The verdict must see the OTHER required checks in a terminal
# state. Sibling jobs start in parallel AND may not have registered
# their check-runs yet, so an empty poll is not "done": wait until
# every name in spec_git/policy.yaml is present with a terminal
# conclusion. This job is not in the policy, so no self-deadlock.
# #315: a terminal run only counts when it started at/after the
# delivery's ready-for-review transition — a stale green keeps
# waiting for the fresh run the transition triggers.
# All GitHub access goes through the authenticated gh CLI.
env:
GH_TOKEN: ${{ github.token }}
WAIT_REPO: ${{ github.repository }}
WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
WAIT_PR: ${{ github.event.pull_request.number || '' }}
WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml
SPECGIT_CLI_DIR: ${{ runner.temp }}/specgit-cli
run: |
node --input-type=module <<'EOF'
import { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
const { parse } = process.env.SPECGIT_CLI_DIR
? createRequire(process.env.SPECGIT_CLI_DIR + '/node_modules/specgit/package.json')('yaml')
: await import('yaml');
const policyPath = process.env.WAIT_POLICY || 'spec_git/policy.yaml';
if (!existsSync(policyPath)) {
console.error('spec_git/policy.yaml is absent at this head — an adoption PR carries no binding commit yet (expected once; merge it before enabling branch protection), and a delivery PR must carry it via specgit issue.');
process.exit(1);
}
const policy = parse(readFileSync(policyPath, 'utf8'));
const required = policy.required_checks ?? [];
// gh.cmd needs a shell on Windows; POSIX execs the binary
// directly (shell stays off where it is not needed). The
// query rides --field args (never a raw "?" URL): cmd.exe
// treats a bare "&" as a command separator, so a URL query
// would be split mid-parameter on Windows.
const listChecks = (page) =>
JSON.parse(
execFileSync(
'gh',
[
'api',
'repos/' + process.env.WAIT_REPO + '/commits/' + process.env.WAIT_SHA + '/check-runs',
'--method', 'GET',
'--field', 'per_page=' + PER_PAGE,
'--field', 'page=' + page,
],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' }
)
);
// Transient API failures (5xx, 429, network) retry with bounded
// exponential backoff — a platform blip must not fail the gate.
const MAX_ATTEMPTS = 5;
const listChecksWithRetry = async (page) => {
for (let attempt = 1; ; attempt += 1) {
try {
return listChecks(page);
} catch (error) {
const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : '');
const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text);
if (attempt >= MAX_ATTEMPTS || !transient) throw error;
const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1));
console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms');
await new Promise((r) => setTimeout(r, backoff));
}
}
};
// #315: the ready-for-review anchor rides the issue-timeline
// endpoint through gh api --field args (GET, like the listing).
const fetchTimelinePage = (page) =>
JSON.parse(
execFileSync(
'gh',
[
'api',
'repos/' + process.env.WAIT_REPO + '/issues/' + process.env.WAIT_PR + '/timeline',
'--method', 'GET',
'--field', 'per_page=' + PER_PAGE,
'--field', 'page=' + page,
],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' }
)
);
const fetchTimelineWithRetry = async (page) => {
for (let attempt = 1; ; attempt += 1) {
try {
const payload = fetchTimelinePage(page);
if (!Array.isArray(payload)) throw new Error('GitHub returned a non-array timeline payload.');
return payload;
} catch (error) {
if (error && error.message === 'GitHub returned a non-array timeline payload.') throw error;
const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : '');
const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text);
if (attempt >= MAX_ATTEMPTS || !transient) throw error;
const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1));
console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms');
await new Promise((r) => setTimeout(r, backoff));
}
}
};
/** @param {unknown} value @returns {value is number} */
function positiveIdentity(value) {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
/** @param {unknown} app */
export function isGithubActionsApp(app) {
return typeof app === 'object' && app !== null &&
(('slug' in app && app.slug === 'github-actions') || ('id' in app && app.id === 15368));
}
/**
* One ownership decision for the provider and embedded wait program.
* Callers prove the head and list completeness before passing normalized rows.
* Pending owners stay pending; callers decide which jobs must wait for them.
* @template {import('./actions-ownership.mjs').ActionsWorkflow} T
* @param {readonly T[]} workflows
* @returns {import('./actions-ownership.mjs').ActionsOwnership<T>}
*/
export function createActionsOwnership(workflows) {
/** @type {Map<string, T>} */
const latest = new Map();
/** @type {Map<number, T>} */
const owners = new Map();
const ids = new Set();
for (const workflow of workflows) {
const check = workflow.check;
if (typeof workflow.key !== 'string' || !workflow.key ||
!positiveIdentity(check.id) || !positiveIdentity(workflow.checkSuiteId) ||
!positiveIdentity(workflow.runAttempt) || typeof check.startedAt !== 'string' ||
!Number.isFinite(Date.parse(check.startedAt)) || owners.has(workflow.checkSuiteId) ||
ids.has(check.id) || !['queued', 'in_progress', 'completed', 'waiting', 'pending', 'requested'].includes(check.status)) {
throw new Error('GitHub returned incomplete or ambiguous Actions workflow ownership.');
}
owners.set(workflow.checkSuiteId, workflow);
ids.add(check.id);
const previous = latest.get(workflow.key);
const started = Date.parse(check.startedAt);
if (!previous || started > Date.parse(previous.check.startedAt ?? '') ||
(started === Date.parse(previous.check.startedAt ?? '') && check.id > previous.check.id)) {
latest.set(workflow.key, workflow);
}
}
return {
latest: [...latest.values()],
currentFor(checkSuiteId) {
if (!positiveIdentity(checkSuiteId)) {
throw new Error('GitHub returned an Actions check without a check-suite identity.');
}
const owner = owners.get(checkSuiteId);
if (!owner) throw new Error('The Actions check has no proven owning workflow run.');
return latest.get(owner.key)?.check.id === owner.check.id ? owner : null;
},
};
}
const isActions = (check) => isGithubActionsApp(check.app);
const listWorkflowsWithRetry = async (page) => {
for (let attempt = 1; ; attempt++) {
try {
return JSON.parse(execFileSync('gh', [
'api', 'repos/' + process.env.WAIT_REPO + '/actions/runs', '--method', 'GET',
'--field', 'head_sha=' + process.env.WAIT_SHA, '--field', 'per_page=' + PER_PAGE, '--field', 'page=' + page,
], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' }));
} catch (error) {
const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : '');
if (attempt >= 5 || !/HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text)) throw error;
await new Promise((resolve) => setTimeout(resolve, Math.min(30000, 2000 * 2 ** (attempt - 1))));
}
}
};
const currentExecutionChecks = async (checks) => {
if (!checks.some(isActions)) return checks;
const workflows = [];
const positive = (value) => Number.isSafeInteger(value) && value > 0;
let total;
for (let page = 1; page <= 10; page++) {
const payload = await listWorkflowsWithRetry(page);
if (!Array.isArray(payload?.workflow_runs) || payload.workflow_runs.length > PER_PAGE
|| !Number.isSafeInteger(payload.total_count) || payload.total_count < 0) throw new Error('GitHub Actions workflow evidence is malformed.');
if (payload.total_count > 1000) throw new Error('GitHub Actions workflow evidence exceeds the 1000-run API limit.');
if (total !== undefined && total !== payload.total_count) throw new Error('GitHub Actions workflow evidence changed during pagination.');
total = payload.total_count;
for (const run of payload.workflow_runs) {
if (!positive(run?.workflow_id) || run.head_sha !== process.env.WAIT_SHA
|| typeof run.event !== 'string' || run.event.length === 0) throw new Error('GitHub Actions workflow ownership is missing, invalid, or ambiguous.');
workflows.push({ key: JSON.stringify([run.workflow_id, run.event]), checkSuiteId: run.check_suite_id,
runAttempt: run.run_attempt, check: { id: run.id, startedAt: run.run_started_at, status: run.status } });
}
if (payload.workflow_runs.length < PER_PAGE) break;
if (page === 10) throw new Error('GitHub Actions workflow evidence reached the pagination limit.');
}
if (workflows.length !== total) throw new Error('GitHub Actions workflow evidence is incomplete.');
const ownership = createActionsOwnership(workflows);
return checks.flatMap((check) => {
if (!isActions(check)) return [check];
let owner;
try { owner = ownership.currentFor(check.check_suite?.id); }
catch { throw new Error('A GitHub Actions check has no verified workflow owner.'); }
if (owner === null) return [];
// A rerun can retain completed jobs from its prior attempt until new jobs register.
return [owner.check.status === 'completed' ? check : { ...check, status: 'in_progress', conclusion: null }];
});
};
const terminal = new Set(['completed']);
const PER_PAGE = 100;
// #300: page the listing to exhaustion — a head with more than
// PER_PAGE check-runs must still expose every required name.
const fetchAllCheckRuns = async () => {
const runs = [];
for (let page = 1; ; page += 1) {
const payload = await listChecksWithRetry(page);
runs.push(...(payload.check_runs ?? []));
if (!payload.check_runs || payload.check_runs.length < PER_PAGE) break;
}
return runs;
};
// #315: the evidence anchor — created_at of the latest
// ready_for_review event on the pull request's issue timeline,
// paged to exhaustion through the same transport seam. Empty
// WAIT_PR (a push or workflow_dispatch event) means no anchor
// and no freshness bound; a fetch failure fails the step
// loudly instead of silently unbounding freshness.
const fetchAnchor = async () => {
if (!process.env.WAIT_PR) return null;
let anchor = null;
let anchorTime = null;
for (let page = 1; ; page += 1) {
const events = await fetchTimelineWithRetry(page);
if (!Array.isArray(events)) throw new Error('GitHub returned a non-array timeline payload.');
for (const event of events) {
if (event && event.event === 'ready_for_review') {
if (typeof event.created_at !== 'string' || event.created_at === ''
|| Number.isNaN(Date.parse(event.created_at))) {
throw new Error('GitHub returned a ready-for-review event without a valid timestamp.');
}
const eventTime = Date.parse(event.created_at);
if (anchor === null || anchorTime === null || eventTime > anchorTime) {
anchor = event.created_at;
anchorTime = eventTime;
}
}
}
if (!Array.isArray(events) || events.length < PER_PAGE) return anchor;
}
};
// Poll deadline sits BELOW the job's timeout-minutes (45) on
// purpose: when the deadline loses the race against a slow
// sibling, the script exits with its own diagnosis instead of
// being killed by the job timeout mid-line.
// The required Linux unit suite normally takes about 28 minutes.
const deadline = Date.now() + 40 * 60 * 1000;
while (Date.now() < deadline) {
// #315: re-read the anchor every cycle — the transition
// event landing after this job started, or the fresh runs
// registering late, self-heal on the next poll.
let anchor;
try {
anchor = await fetchAnchor();
} catch (error) {
console.error('Could not read the ready-for-review anchor: '
+ (error && error.message ? error.message : String(error)));
process.exit(1);
}
const runs = await currentExecutionChecks(await fetchAllCheckRuns());
// #119: re-runs keep every same-name run; terminality is
// decided on the truth run — latest started_at, ties broken
// by the higher check-run id (docs/reference.md) — never on
// response position.
const truth = new Map();
const startedTime = (run) => {
if (typeof run.started_at !== 'string') return Number.NEGATIVE_INFINITY;
const parsed = Date.parse(run.started_at);
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
};
for (const r of runs) {
const cur = truth.get(r.name);
const runTime = startedTime(r);
const currentTime = cur === undefined ? Number.NEGATIVE_INFINITY : startedTime(cur);
const later = cur === undefined
|| runTime > currentTime
|| (runTime === currentTime && (r.id || 0) > (cur.id || 0));
if (later) truth.set(r.name, r);
}
const truthRunFor = (name) => {
if (truth.has(name)) return truth.get(name);
const retried = [...truth.keys()].find((k) => k.startsWith(name + ' ('));
return retried === undefined ? undefined : truth.get(retried);
};
// #315: a required check settles only when its truth run is
// terminal AND (when an anchor exists) started at/after the
// ready-for-review transition — a stale green keeps waiting.
const missing = [];
const stale = [];
const anchorTime = anchor === null ? null : Date.parse(anchor);
for (const name of required) {
const run = truthRunFor(name);
if (run === undefined || !terminal.has(run.status)) {
missing.push(name);
} else if (anchorTime !== null && (Number.isNaN(anchorTime) || startedTime(run) < anchorTime)) {
stale.push(name);
}
}
if (missing.length === 0 && stale.length === 0) {
console.log('All required checks are in a terminal state.');
process.exit(0);
}
if (missing.length > 0) {
console.log('Waiting for: ' + missing.join(', '));
}
if (stale.length > 0) {
console.log('Waiting for a fresh run after ready for review: ' + stale.join(', '));
}
await new Promise((r) => setTimeout(r, 10000));
}
console.error('Timed out waiting for sibling checks.');
process.exit(1);
EOF
- name: specgit finish
run: '"$RUNNER_TEMP/specgit-cli/node_modules/.bin/specgit" finish --json'
env:
GH_TOKEN: ${{ github.token }}