chore(release): promote DAG reliability fixes to main #225
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |