From 1297ea284c4853f46230a8ee6e1778c7cf1d8867 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 6 Jul 2026 17:03:47 -0700 Subject: [PATCH 1/3] ci: re-run stale stack-dependent checks on stack shape changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "True" the stack: when a PR is stacked on/unstacked from another, GitHub does not re-run the other PR's checks, so a stack-position-dependent gate (the OpenSpec archive check) can stay failing on a PR that is no longer the tip. Hang a recheck off the existing breadcrumb dispatch (stage 2, from the default branch): for each PR in the affected stack, re-run any workflow run that has a FAILED job whose name is `stack`-prefixed. Green/skipped stack checks and non-stack failures (tests) are left alone, so nothing re-runs mid-stack when everything is already correct. - Name the stack-position-dependent jobs with a `stack` prefix so failures are identifiable: pr-check-openspec's jobs become "stack: position" and "stack: openspec-archived". - Add hasFailedStackJob() to the shared zero-dep module (unit-tested) — the reconcile job uses it to decide which runs to re-run; grant it actions: write. BREAKING for branch protection: renaming `check-openspec-archived` changes its check name. Any required-status-check config referencing the old name must be updated to "stack: openspec-archived" (and "stack: position") after this merges, or merges will hang waiting on a check that no longer reports. --- .github/scripts/stack-breadcrumb.cjs | 20 ++++++++++ .github/scripts/stack-breadcrumb.test.cjs | 44 +++++++++++++++++++++ .github/workflows/pr-check-openspec.yml | 3 +- .github/workflows/stack-breadcrumb.yml | 47 ++++++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index 959454a9..006eee28 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -334,6 +334,25 @@ async function reconcile(root, deps) { return { root, updated, skipped, frozen, failed }; } +// --------------------------------------------------------------------------- +// Stale stack-check detection (for re-running "trued" checks) +// --------------------------------------------------------------------------- + +/** + * Whether a workflow run is a stale stack-dependent check that should be + * re-run. A run qualifies when it has a job that FAILED and whose name is + * `stack`-prefixed (the convention for stack-position-dependent gates, e.g. + * `stack: openspec-archived`). Passing/skipped stack jobs and non-stack + * failures (tests) do NOT qualify — so nothing is re-run when the stack is + * mid-flight and its checks are green or appropriately skipped. + */ +function hasFailedStackJob(jobs) { + return (jobs ?? []).some( + (job) => + job.conclusion === "failure" && /^stack\b/i.test(String(job.name ?? "")) + ); +} + module.exports = { parseStackComment, getRegion, @@ -346,6 +365,7 @@ module.exports = { findAllRoots, planUpdate, reconcile, + hasFailedStackJob, HERE_PREFIX, HERE_SUFFIX, STACK_HEADING, diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs index 7a971dd8..dc55693f 100644 --- a/.github/scripts/stack-breadcrumb.test.cjs +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -15,6 +15,7 @@ const { findAllRoots, planUpdate, reconcile, + hasFailedStackJob, } = require("./stack-breadcrumb.cjs"); const DEFAULT_BRANCH = "main"; @@ -313,3 +314,46 @@ test("reconcile: a lone open root with only merged descendants still renders", a assert.deepEqual(result.frozen, [11, 12]); assert.match(calls[0][1], /pr=10,11,12/); }); + +test("hasFailedStackJob: true when a stack-prefixed job failed", () => { + assert.equal( + hasFailedStackJob([ + { name: "stack: position", conclusion: "success" }, + { name: "stack: openspec-archived", conclusion: "failure" }, + ]), + true + ); +}); + +test("hasFailedStackJob: false when the only failure is not a stack job", () => { + assert.equal( + hasFailedStackJob([ + { name: "stack: position", conclusion: "success" }, + { name: "Validate", conclusion: "failure" }, + ]), + false + ); +}); + +test("hasFailedStackJob: false for a skipped stack job (not a failure)", () => { + assert.equal( + hasFailedStackJob([ + { name: "stack: openspec-archived", conclusion: "skipped" }, + ]), + false + ); +}); + +test("hasFailedStackJob: 'stack' must be a name prefix, not merely contained", () => { + assert.equal( + hasFailedStackJob([ + { name: "Reconcile breadcrumb across the stack", conclusion: "failure" }, + ]), + false + ); +}); + +test("hasFailedStackJob: false for empty/missing jobs", () => { + assert.equal(hasFailedStackJob([]), false); + assert.equal(hasFailedStackJob(undefined), false); +}); diff --git a/.github/workflows/pr-check-openspec.yml b/.github/workflows/pr-check-openspec.yml index 1984b939..3a81b214 100644 --- a/.github/workflows/pr-check-openspec.yml +++ b/.github/workflows/pr-check-openspec.yml @@ -15,7 +15,7 @@ permissions: jobs: stack-position: - name: Detect stack position + name: "stack: position" runs-on: ubuntu-latest outputs: is_tip: ${{ steps.detect.outputs.is_tip }} @@ -51,6 +51,7 @@ jobs: fi check-openspec-archived: + name: "stack: openspec-archived" needs: stack-position if: needs.stack-position.outputs.is_tip == 'true' runs-on: ubuntu-latest diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index b0002670..f63e7e80 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -8,7 +8,10 @@ name: Stack Breadcrumb # STAGE 1 (dispatch) — on PR shape-change events, resolve the affected # root(s) and fire a `repository_dispatch`. # STAGE 2 (reconcile) — on that dispatch (or a manual `workflow_dispatch`), -# render and propagate the breadcrumb, serialized per root. +# render and propagate the breadcrumb, serialized per +# root, AND "true" the stack: re-run any stale +# `stack`-prefixed check that a shape change left failing +# on a PR that is no longer the tip. # # Stage 1 fires the dispatch with the built-in GITHUB_TOKEN: although that token # normally suppresses recursive workflow runs, `repository_dispatch` is a @@ -117,6 +120,7 @@ jobs: permissions: contents: read # checkout pull-requests: write # edit PR bodies + actions: write # re-run stale stack-dependent checks ("trueing" the stack) # One reconcile per root at a time. A burst for one root collapses to the # latest run; convergent writes make cancellation safe. concurrency: @@ -146,6 +150,7 @@ jobs: baseRefName: pr.base.ref, body: pr.body ?? "", state: "open", + headSha: pr.head.sha, })); // Preserve construction history: pull in stack members recorded in @@ -277,6 +282,46 @@ jobs: core.info(`reconciled root #${root}: ${JSON.stringify(result)}`); failures.push(...result.failed); } + + // "Trueing" the stack: a stack shape change can leave a PR's + // stack-position-dependent checks (e.g. the OpenSpec archive gate) + // failing on a PR that is no longer the tip. GitHub won't re-run a + // PR's checks when a *different* PR changes, so do it here — re-run + // any workflow run on an affected (open) PR that has a FAILED + // `stack`-prefixed job. Failing tests and green/skipped stack checks + // are left alone. + const rechecked = new Set(); + for (const root of roots) { + const tree = stack.buildTree(root, allPullRequests); + for (const number of tree.members) { + if (rechecked.has(number)) continue; + rechecked.add(number); + const pr = allPullRequests.find((p) => p.number === number); + if (!pr || pr.state !== "open" || !pr.headSha) continue; + const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { + owner, repo, head_sha: pr.headSha, per_page: 100, + }); + const seenWorkflows = new Set(); + for (const run of runs) { + // Consider only the latest run per workflow for this head SHA. + if (seenWorkflows.has(run.workflow_id)) continue; + seenWorkflows.add(run.workflow_id); + // A still-running latest run will report fresh on its own. + if (run.status !== "completed") continue; + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, repo, run_id: run.id, per_page: 100, + }); + if (!stack.hasFailedStackJob(jobs)) continue; + try { + await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id }); + core.info(`recheck: re-ran "${run.name}" (run ${run.id}) on PR #${number}`); + } catch (error) { + core.info(`recheck: could not re-run run ${run.id} on PR #${number}: ${String(error)}`); + } + } + } + } + if (failures.length > 0) { core.setFailed(`Failed to update ${failures.length} PR body/bodies: ${failures.map((n) => `#${n}`).join(", ")}. See logs above.`); } From c2a6d6c22b24f4f80299d41ac3830b7ee5cf05b9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 6 Jul 2026 17:10:25 -0700 Subject: [PATCH 2/3] ci: skip job fetch for non-failed runs in stack recheck Address Copilot review on #54: only fetch a run's jobs when the run's overall conclusion is 'failure'. A non-failed run can't contain a failed job, so this avoids a listJobsForWorkflowRun call per workflow per PR and reduces rate-limit pressure on large stacks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/stack-breadcrumb.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index f63e7e80..f31d1de3 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -306,8 +306,10 @@ jobs: // Consider only the latest run per workflow for this head SHA. if (seenWorkflows.has(run.workflow_id)) continue; seenWorkflows.add(run.workflow_id); - // A still-running latest run will report fresh on its own. - if (run.status !== "completed") continue; + // A still-running latest run will report fresh on its own; a + // run that did not fail overall cannot contain a failed job, so + // skip the job fetch for it entirely. + if (run.status !== "completed" || run.conclusion !== "failure") continue; const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { owner, repo, run_id: run.id, per_page: 100, }); From d5731be444fd047986e19c190f05f717fa3fc886 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 6 Jul 2026 17:19:08 -0700 Subject: [PATCH 3/3] docs: clarify hasFailedStackJob operates on a run's jobs Address Copilot review on #54: the JSDoc said 'workflow run' while the parameter is the run's job list; reword so the input (jobs) and the decision (re-run the run) are both accurate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stack-breadcrumb.cjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index 006eee28..95c0fd8d 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -339,12 +339,12 @@ async function reconcile(root, deps) { // --------------------------------------------------------------------------- /** - * Whether a workflow run is a stale stack-dependent check that should be - * re-run. A run qualifies when it has a job that FAILED and whose name is - * `stack`-prefixed (the convention for stack-position-dependent gates, e.g. - * `stack: openspec-archived`). Passing/skipped stack jobs and non-stack - * failures (tests) do NOT qualify — so nothing is re-run when the stack is - * mid-flight and its checks are green or appropriately skipped. + * Given a workflow run's `jobs`, decide whether that run is a stale + * stack-dependent check to re-run: it qualifies when one of its jobs FAILED and + * that job's name is `stack`-prefixed (the convention for stack-position- + * dependent gates, e.g. `stack: openspec-archived`). Passing/skipped stack jobs + * and non-stack failures (tests) do NOT qualify — so nothing is re-run when the + * stack is mid-flight and its checks are green or appropriately skipped. */ function hasFailedStackJob(jobs) { return (jobs ?? []).some(