Skip to content

fix(specgit): restore delivery state after failed bootstrap #207

fix(specgit): restore delivery state after failed bootstrap

fix(specgit): restore delivery state after failed bootstrap #207

Workflow file for this run

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.
types: [opened, synchronize, reopened, ready_for_review]
# No workflow_dispatch (local specialization): dispatch is the privileged
# context that fires CodeQL's cache-poisoning taint rule on the head_ref
# checkout (false positive: no cache use, read-only token,
# persist-credentials: false), and on dispatch events head_ref is empty so
# the verdict would evaluate the default branch — the wrong tree. Delivery
# here always goes through a PR.
permissions:
contents: read
issues: read
pull-requests: 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
# Local specialization: must exceed the slowest required sibling
# (Unit Tests (linux) runs ~28min on PRs) — the verdict waits for every
# policy check to reach a terminal state before evaluating.
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Check out the PR head branch by name so HEAD is on the branch
# (not the detached merge ref): the execution context gate reads
# live git. Falls back to the default ref on non-PR events.
ref: ${{ github.head_ref || github.ref }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install pinned SpecGit CLI
# Local specialization — install GLOBALLY, not `npm install --no-save
# specgit@X`: a workspace-local install reads this bun workspace's
# package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL,
# #434/#459). Exact pin on purpose: the gate must evaluate with the
# same CLI generation that wrote the binding (1.10.1 re-init).
run: npm install -g --no-audit --no-fund specgit@1.10.1
- 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 || '' }}
run: |
node --input-type=module <<'EOF'
import { existsSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
if (!existsSync('spec_git/policy.yaml')) {
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);
}
// Local specialization — minimal hand parse of policy.yaml's
// required_checks block: this bun-based repo does not expose a
// root-reachable `yaml` package (workspace catalog isolation), so
// `import { parse } from 'yaml'` would fail to resolve here.
const policy = readFileSync('spec_git/policy.yaml', 'utf8');
const section = policy.slice(policy.indexOf('required_checks:'));
const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim());
// 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));
}
}
};
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.
// Local specialization: 40min because the slowest required
// sibling (Unit Tests (linux)) runs ~28min on PRs.
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 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: specgit finish --json
env:
GH_TOKEN: ${{ github.token }}