diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index 959454a9..95c0fd8d 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) +// --------------------------------------------------------------------------- + +/** + * 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( + (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..f31d1de3 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,48 @@ 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; 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, + }); + 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.`); }