fix(core): preserve npm lock when plugin SDK resolves locally #217
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. | |
| 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 }} |