Skip to content

chore(release): promote DAG reliability fixes to main #225

chore(release): promote DAG reliability fixes to main

chore(release): promote DAG reliability fixes to main #225

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. 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.13.1
- 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 }}