diff --git a/.github/scripts/stack-breadcrumb.cjs b/.github/scripts/stack-breadcrumb.cjs index f033a98..60ca830 100644 --- a/.github/scripts/stack-breadcrumb.cjs +++ b/.github/scripts/stack-breadcrumb.cjs @@ -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 [ ``, - `# Contains #${number_}`, + `# ${heading} #${number_}`, "", description, ``, @@ -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 `` 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 // @@ -669,6 +713,7 @@ module.exports = { ownDescription, upsertCarriedRegion, carryForward, + carryBackward, HERE_PREFIX, HERE_SUFFIX, STACK_HEADING, diff --git a/.github/scripts/stack-breadcrumb.test.cjs b/.github/scripts/stack-breadcrumb.test.cjs index bc84564..9177967 100644 --- a/.github/scripts/stack-breadcrumb.test.cjs +++ b/.github/scripts/stack-breadcrumb.test.cjs @@ -25,6 +25,7 @@ const { ownDescription, upsertCarriedRegion, carryForward, + carryBackward, } = require("./stack-breadcrumb.cjs"); const DEFAULT_BRANCH = "main"; @@ -656,3 +657,67 @@ test("carryForward: emits canonical order — breadcrumb, then description, then `${breadcrumb}\n\nParent 83 desc.\n\n\n# Contains #84\n\nGroup 84 work.\n` ); }); + +// ─── 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\n(tree)\n"; + const result = carryBackward(child83, 82, "Root 82 desc."); + assert.match( + result, + /\n# Built on top of #82\n\nRoot 82 desc\.\n/ + ); +}); + +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 = [ + "", + "# Contains #81", + "", + "Earlier work.", + "", + ].join("\n"); + const parent82 = `Root 82 desc.\n\n${carried81}`; + const child83 = + "Group 83 work.\n\n\n(tree)\n"; + + 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\n(t)\n"; + 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 = + "\n(tree)\n"; + // 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\n# Built on top of #82\n\nRoot 82 desc.\n` + ); +}); diff --git a/.github/workflows/openspec-rot.yml b/.github/workflows/openspec-rot.yml new file mode 100644 index 0000000..3d4a20c --- /dev/null +++ b/.github/workflows/openspec-rot.yml @@ -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." diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index 72d92b9..4dc6e67 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -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 `` 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 `` 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 @@ -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 `` 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'