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
51 changes: 48 additions & 3 deletions .github/scripts/stack-breadcrumb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,20 @@ function ownDescription(body) {
.trimEnd();
}

/** Wrap a description as a carried region for `number_`. */
function renderCarriedRegion(number_, description) {
/**
* Wrap a description as a carried region for `number_`, headed by `heading`.
*
* The heading records HOW this PR joined the body and is fixed at authoring
* time: a down-merge (tip→root) carries a child into its parent as
* `Contains #N` (the default); a forward-merge (root→tip) carries a parent into
* the redirected root as `Built on top of #N`. Re-homing an already-carried
* region never re-renders it, so its original heading — its provenance —
* survives regardless of the direction of a later merge.
*/
function renderCarriedRegion(number_, description, heading = "Contains") {
return [
`<!-- PR:${number_} -->`,
`# Contains #${number_}`,
`# ${heading} #${number_}`,
"",
description,
`<!-- /PR:${number_} -->`,
Expand Down Expand Up @@ -272,6 +281,41 @@ function carryForward(parentBody, childNumber, childBody) {
return canonicalizeBody(body, getRegion(body) ?? "");
}

/**
* Carry a merged PARENT's body into `childBody` — the forward-merge (root→tip)
* mirror of `carryForward`. When a stack lands INCREMENTALLY, the bottom PR
* merges to the default branch first and the next PR up is retargeted onto the
* default branch, becoming the new "redirected root". That surviving child
* absorbs the parent it was built on as `<!-- PR:parent -->` headed
* `Built on top of #parent`, and re-homes the parent's own already-carried
* regions as flat top-level siblings — each upserted by key, so the child ends
* with a deduped, flat set of everything landed beneath it.
*
* Where `carryForward` keeps the surviving parent's descendants ("Contains"),
* `carryBackward` keeps the surviving child's ancestors ("Built on top of"), so
* the PR that finally reaches the default branch still holds the full legacy of
* the stack — read from the opposite end. Re-homed regions keep whatever heading
* they were first carried under (provenance is never rewritten); only the parent
* being freshly absorbed here is rendered with the forward heading. A final
* `canonicalizeBody` re-lays the accumulated body in canonical order (breadcrumb
* → description → carried), keeping the child's existing breadcrumb.
*/
function carryBackward(childBody, parentNumber, parentBody) {
let body = upsertCarriedRegion(
childBody,
parentNumber,
renderCarriedRegion(
parentNumber,
ownDescription(parentBody),
"Built on top of"
)
);
for (const { number, region } of extractCarriedRegions(parentBody)) {
body = upsertCarriedRegion(body, number, region);
}
return canonicalizeBody(body, getRegion(body) ?? "");
}

// ---------------------------------------------------------------------------
// Canonical body layout
//
Expand Down Expand Up @@ -669,6 +713,7 @@ module.exports = {
ownDescription,
upsertCarriedRegion,
carryForward,
carryBackward,
HERE_PREFIX,
HERE_SUFFIX,
STACK_HEADING,
Expand Down
65 changes: 65 additions & 0 deletions .github/scripts/stack-breadcrumb.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const {
ownDescription,
upsertCarriedRegion,
carryForward,
carryBackward,
} = require("./stack-breadcrumb.cjs");

const DEFAULT_BRANCH = "main";
Expand Down Expand Up @@ -656,3 +657,67 @@ test("carryForward: emits canonical order — breadcrumb, then description, then
`${breadcrumb}\n\nParent 83 desc.\n\n<!-- PR:84 -->\n# Contains #84\n\nGroup 84 work.\n<!-- /PR:84 -->`
);
});

// ─── PR carry-backward (forward-merge, root→tip) ─────────────────────────────

test("carryBackward: absorbs the merged parent under a 'Built on top of' heading", () => {
// #82 (the root) merged to the default branch; #83 is retargeted onto it and
// becomes the redirected root, absorbing #82.
const child83 =
"Group 83 work.\n\n<!-- stack root=82 pr=82,83:82 -->\n(tree)\n<!-- /stack -->";
const result = carryBackward(child83, 82, "Root 82 desc.");
assert.match(
result,
/<!-- PR:82 -->\n# Built on top of #82\n\nRoot 82 desc\.\n<!-- \/PR:82 -->/
);
});

test("carryBackward: re-homes the merged parent's carried regions, keeping their headings", () => {
// #82 already carried #81 downward ("Contains"); when #82 merges forward into
// #83, #81 rides along as a flat sibling and KEEPS its original heading —
// provenance is never rewritten by the direction of a later merge.
const carried81 = [
"<!-- PR:81 -->",
"# Contains #81",
"",
"Earlier work.",
"<!-- /PR:81 -->",
].join("\n");
const parent82 = `Root 82 desc.\n\n${carried81}`;
const child83 =
"Group 83 work.\n\n<!-- stack root=82 pr=82,83:82 -->\n(tree)\n<!-- /stack -->";

const result = carryBackward(child83, 82, parent82);

// Both ancestors are present, sorted by number below the tree.
assert.deepEqual(
extractCarriedRegions(result).map((r) => r.number),
[81, 82]
);
// #82 is freshly absorbed with the forward heading …
assert.match(result, /# Built on top of #82/);
// … while #81 rides along with the heading it was first carried under.
assert.ok(result.includes(carried81));
});

test("carryBackward: is idempotent — re-absorbing does not duplicate", () => {
const child =
"Child work.\n\n<!-- stack root=1 pr=1,2:1 -->\n(t)\n<!-- /stack -->";
const once = carryBackward(child, 1, "Root work.");
const twice = carryBackward(once, 1, "Root work.");
assert.equal(once, twice);
assert.equal(extractCarriedRegions(twice).length, 1);
});

test("carryBackward: emits canonical order — breadcrumb, then description, then carried", () => {
const breadcrumb =
"<!-- stack root=82 pr=82,83:82 -->\n(tree)\n<!-- /stack -->";
// Child authored description-first; absorbing the parent must still land the
// breadcrumb on top, the child's prose next, and the carried parent last.
const child83 = `Group 83 work.\n\n${breadcrumb}`;
const result = carryBackward(child83, 82, "Root 82 desc.");
assert.equal(
result,
`${breadcrumb}\n\nGroup 83 work.\n\n<!-- PR:82 -->\n# Built on top of #82\n\nRoot 82 desc.\n<!-- /PR:82 -->`
);
});
114 changes: 114 additions & 0 deletions .github/workflows/openspec-rot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: OpenSpec Rot Check

# A daily sweep of `main` for OpenSpec debris — the direction-agnostic backstop
# the merge-time archive gate structurally cannot provide.
#
# Incremental-forward stacks land the change directory on `main` UNARCHIVED and
# only archive it on the final slice (invariant I1: main ends clean, but may
# transiently carry an in-flight change while the stack drains). If that stack
# stalls or is abandoned part-way, the unarchived change sits on `main`
# indefinitely. This sweep catches two failure modes on `main`:
#
# DONE — a change whose tasks are ALL checked but which was never moved under
# archive/. Debris regardless of age → fails immediately. The tip
# archive gate catches this at merge time; this is defence in depth
# for an admin-merge or botched down-merge that slipped past it.
# STALE — any unarchived change that is NOT done (work unfinished, tasks.md
# empty/placeholder, or no tasks.md) with NO git activity in its
# directory for STALE_DAYS. A stalled/abandoned stack rotting on main.
# Fails after the window. Because every non-done change is treated as
# live, an abandoned change can't slip the net by having an empty
# tasks.md — the exact case this backstop exists for.
#
# Age is read from git history, never file mtime: a fresh `actions/checkout`
# stamps every file's mtime to checkout time, so mtime would always look fresh.
on:
schedule:
# Daily near the start of the Pacific business day. Cron is fixed-UTC and
# can't follow DST, so 15:17 UTC lands at 8:17am PDT / 7:17am PST — early
# either way. The :17 is deliberately off the top of the hour (GitHub delays
# runs scheduled on the heavily-contended :00).
- cron: "17 15 * * *"
workflow_dispatch:

permissions:
contents: read

env:
STALE_DAYS: "7"

jobs:
rot:
name: "openspec: rot"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history — git log needs it to date each change dir

- name: Fail on stale or complete-but-unarchived OpenSpec changes
run: |
set -euo pipefail

if [ ! -d openspec/changes ]; then
echo "openspec/changes does not exist — nothing to check."
exit 0
fi

stale_days="${STALE_DAYS:-7}"
now=$(date +%s)
cutoff=$(( stale_days * 24 * 3600 ))
fail=0

while IFS= read -r dir; do
[ -z "$dir" ] && continue
name=$(basename "$dir")
tasks="$dir/tasks.md"

if [ -f "$tasks" ]; then
incomplete=$(grep -cE '^[[:space:]]*- \[ \]' "$tasks" || true)
complete=$(grep -cE '^[[:space:]]*- \[[xX]\]' "$tasks" || true)
else
incomplete=0
complete=0
fi
incomplete=${incomplete:-0}
complete=${complete:-0}
total=$(( incomplete + complete ))

# DONE: every task checked but not archived. Debris now, not later.
if [ "$total" -gt 0 ] && [ "$incomplete" -eq 0 ]; then
echo "::error::openspec/changes/$name: all $total task(s) complete but the change is not archived. Move it under openspec/changes/archive/ (e.g. /openspec-archive-change $name)."
fail=1
continue
fi

# Anything unarchived that is not DONE is LIVE by definition — its
# work is unfinished, its tasks.md is empty/placeholder (new changes
# start with an empty one), or it has no tasks.md at all. So there is
# no "not-live" branch that could let an abandoned change slip the
# net; staleness applies to every non-DONE change.
#
# Age is the last commit touching ANY file in the change directory
# (NOT mtime — a fresh checkout stamps mtime to checkout time). Dating
# the whole folder means real activity on proposal.md / specs counts
# as "not abandoned", not just edits to tasks.md.
last=$(git log -1 --format=%ct -- "$dir" || true)
if [ -z "$last" ]; then
echo "::warning::openspec/changes/$name: unarchived but has no git history to date; cannot assess staleness."
continue
fi
age=$(( now - last ))
if [ "$age" -gt "$cutoff" ]; then
days=$(( age / 86400 ))
echo "::error::openspec/changes/$name: live but untouched for ${days}d (> ${stale_days}d). A stalled/abandoned stack is leaving OpenSpec debris on main — land or close it, then archive the change."
fail=1
fi
done < <(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive)

if [ "$fail" -ne 0 ]; then
echo ""
echo "OpenSpec rot detected — see the errors above."
exit 1
fi
echo "No OpenSpec rot — OK."
111 changes: 107 additions & 4 deletions .github/workflows/stack-breadcrumb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ name: Stack Breadcrumb
# `stack`-prefixed check that a shape change left failing
# on a PR that is no longer the tip.
#
# A separate CARRY-FORWARD job runs on a member's MERGE: it embeds the merged
# PR's body into its parent PR as a keyed `<!-- PR:N -->` region below the tree,
# so an atomic collapse (tip→root) accumulates the full legacy on the PR that
# lands on the default branch. Incremental delivery has no parent → a no-op.
# Two CARRY jobs run on a member's MERGE, embedding the merged PR's body into a
# surviving PR as a keyed `<!-- PR:N -->` region below the tree:
# CARRY-FORWARD (merge into a NON-default base, tip→root) — an atomic collapse
# accumulates the merged CHILD into its parent ("Contains #N").
# CARRY-BACKWARD (merge into the DEFAULT branch, root→tip) — an incremental
# land absorbs the merged PARENT into the redirected root (the
# child GitHub retargets onto the default branch), headed
# "Built on top of #N".
# Either way the PR that finally reaches the default branch holds the full stack
# legacy; a merge with no surviving stack neighbour is a clean no-op.
#
# 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 @@ -199,6 +205,103 @@ jobs:
await github.rest.pulls.update({ owner, repo, pull_number: parent.number, body: newBody });
core.info(`carried #${merged.number} into parent #${parent.number}`);

carry-backward:
name: Absorb a merged parent into the redirected root
# On a member's merge INTO THE DEFAULT BRANCH (an incremental forward land),
# embed the merged PR's body into the open child that was built on it — the
# PR GitHub retargets onto the default branch to become the new root — as a
# keyed `<!-- PR:N -->` region headed "Built on top of #N". A merge to the
# default branch with no such child (a standalone PR, or the genuine last
# slice) has nothing to absorb into → a clean no-op.
if: >-
github.event_name == 'pull_request'
&& github.event.action == 'closed'
&& github.event.pull_request.merged == true
&& github.event.pull_request.base.ref == github.event.repository.default_branch
&& github.event.pull_request.head.repo.full_name == github.repository
&& github.event.sender.login != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout needs this (top-level permissions: {} grants no defaults)
pull-requests: write # edit the redirected-root PR body
concurrency:
# Serialize retries of THIS merge event; convergent upserts make it safe,
# and we must not cancel an absorb mid-write.
group: stack-carry-backward-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
# Trusted script from the default branch — never PR-supplied code.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}

- uses: actions/github-script@v7
with:
script: |
const scriptPath = `${process.env.GITHUB_WORKSPACE}/.github/scripts/stack-breadcrumb.cjs`;
let stack;
try {
stack = require(scriptPath);
} catch (error) {
if (error.code === "MODULE_NOT_FOUND") {
core.info("stack-breadcrumb script not on the default branch yet; skipping until merged.");
return;
}
throw error;
}
const { owner, repo } = context.repo;
const merged = context.payload.pull_request;
const defaultBranch = context.payload.repository.default_branch;

// The redirected root is the open child built on the merged PR. Find
// it two ways, because merging the root may have deleted its head
// branch and made GitHub retarget the child onto the default branch:
// 1. base still points at the merged root's head branch (branch
// not deleted, or the retarget hasn't landed yet);
// 2. otherwise the child's breadcrumb marker records the merged PR
// as its parent, and its base is now the default branch.
// A branching stack can have several such children (each becomes an
// independent root); absorb the parent into every one.
const rawPulls = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const sameRepo = rawPulls.filter(
(pr) => pr.head.repo?.full_name === `${owner}/${repo}`
);
const children = sameRepo.filter((pr) => {
if (pr.base.ref === merged.head.ref) return true;
if (pr.base.ref !== defaultBranch) return false;
const marker = stack.parseStackComment(pr.body ?? "");
return marker !== undefined && marker.parents.get(pr.number) === merged.number;
});
if (children.length === 0) {
core.info(`#${merged.number} merged into "${merged.base.ref}" with no open child built on it — nothing to absorb (standalone or last slice).`);
return;
}

// Fetch the merged body fresh — the event payload can lag a
// last-moment edit.
const { data: mergedFresh } = await github.rest.pulls.get({
owner, repo, pull_number: merged.number,
});

for (const child of children) {
const { data: childFresh } = await github.rest.pulls.get({
owner, repo, pull_number: child.number,
});
const newBody = stack.carryBackward(
childFresh.body ?? "",
merged.number,
mergedFresh.body ?? ""
);
if (newBody === (childFresh.body ?? "")) {
core.info(`#${merged.number} already absorbed into #${child.number}; nothing to do.`);
continue;
}
await github.rest.pulls.update({ owner, repo, pull_number: child.number, body: newBody });
core.info(`absorbed #${merged.number} into redirected root #${child.number}`);
}

reconcile:
name: Reconcile breadcrumb across the stack
if: github.event_name == 'repository_dispatch' || github.event_name == 'workflow_dispatch'
Expand Down
Loading