Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/scripts/stack-breadcrumb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -346,6 +365,7 @@ module.exports = {
findAllRoots,
planUpdate,
reconcile,
hasFailedStackJob,
HERE_PREFIX,
HERE_SUFFIX,
STACK_HEADING,
Expand Down
44 changes: 44 additions & 0 deletions .github/scripts/stack-breadcrumb.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
findAllRoots,
planUpdate,
reconcile,
hasFailedStackJob,
} = require("./stack-breadcrumb.cjs");

const DEFAULT_BRANCH = "main";
Expand Down Expand Up @@ -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);
});
3 changes: 2 additions & 1 deletion .github/workflows/pr-check-openspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion .github/workflows/stack-breadcrumb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.`);
}
Loading