From 603792d03f82c36eac9839bcba274776dc6c807a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 2 Aug 2026 14:53:54 -0700 Subject: [PATCH 1/5] docs: record how changesets work on a merge-down stack Require Changeset runs only on PRs based on `main` and reads the PR's own diff, so a mid-stack PR never runs it and the bottom PR is the only place a changeset can satisfy it. Labelling mid-stack PRs `skip-changeset` implies a decision that was never needed; putting the changeset at the base means every branch above inherits it. Grow that one file as the stack lands, so a reviewer evaluates a release note matching the diff in front of them rather than the whole future change. Also notes in iterate-pr that a lower PR on a merge-down stack can be legitimately red, with the instruction to verify that against the change's own tasks rather than assume it. --- .agents/skills/iterate-pr/SKILL.md | 21 +++++++++++++++++++++ CLAUDE.md | 11 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/.agents/skills/iterate-pr/SKILL.md b/.agents/skills/iterate-pr/SKILL.md index 6d8cadd4..908594b0 100644 --- a/.agents/skills/iterate-pr/SKILL.md +++ b/.agents/skills/iterate-pr/SKILL.md @@ -264,6 +264,27 @@ Then: This rule applies ONLY to the OpenSpec Archive Check. Every other check is handled normally regardless of stack position. +#### Stacked PRs: two other failures that are structural, not regressions + +**`Require a changeset` on the bottom PR.** The workflow runs only on PRs based +on `main` and looks for a `.changeset/*.md` added in that PR's own diff, so a +mid-stack PR never runs it and the bottom PR is the only place a changeset can +satisfy it. If it fails, move the changeset DOWN to the bottom branch rather +than labelling anything `skip-changeset` — every branch above inherits it, since +a child contains its ancestors' commits. Extend that one file as later PRs land; +never add a second changeset per PR. + +**Test failures on a merge-down stack.** When the delivery shape is +_stacked, merging down_, no unit is independently correct — that is the +definition of the shape. A lower PR can legitimately be red because the unit +that completes it has not landed yet (e.g. rules are relocated but dispatch +arrives in the next PR). Before treating such a failure as a regression, check +the branch out and run the suite: if the failures match what the change's own +tasks predict for that unit, they are structural and clear as the stack +accumulates. Say so on the PR rather than inventing throwaway assertions to make +an intermediate state look green. Verify it — do not assume a red check is +expected because the shape is merge-down. + ### 5. Fix CI Failures For each failure in the script output: diff --git a/CLAUDE.md b/CLAUDE.md index 4eb18065..80be1c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,17 @@ The deciding question between forward and down is only this: **can each unit rea Note how this interacts with the archive gate (see the OpenSpec archive check below): a change is archived exactly once, on whichever PR is the tip. Mid-stack PRs are expected to carry an unarchived change directory and the gate skips them. +### One changeset, at the bottom of the stack, grown as the stack grows + +`require-changeset.yml` is `on: pull_request: branches: [main]` and looks for a `.changeset/*.md` **added in that PR's own diff**. Two consequences: + +- **A mid-stack PR never runs the check at all** — its base is a feature branch. Do not label it `skip-changeset`; the label implies a decision that was never needed and reads as "this change ships no release note." +- **The changeset belongs on the bottom PR**, the one that targets `main`. That is also the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check fails on the PR that actually merges. + +Put it at the base and every branch above inherits it, since a child contains its ancestors' commits. + +**Grow it incrementally as the stack lands.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them. When you extend it, edit the same file on the branch you are working on — never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once. + ### Landing a stack: merge _down_, then one merge to `main` Merge each PR **down** into its parent's branch, from the tip to the bottom: From 2944a9976c3c7f86f2dd91e9d203d5b780671b75 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 2 Aug 2026 15:46:31 -0700 Subject: [PATCH 2/5] fix(ci): bypass the changeset requirement on mid-stack PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a stack, one changeset describes the whole change and lives on the bottom PR — the one targeting `main`, which is where the check has to pass. Branches above inherit that file, so it is never *added* in a child's own diff and every mid-stack PR failed. Labelling them `skip-changeset` records a deliberate "ships no release note," which is false, so the workflow bypasses on the base ref instead. The `on: branches: [main]` filter does not hold for stacked PRs in practice — the workflow still runs on a PR based on a feature branch — so the base is re-checked inside the step rather than trusted. Observed on #80, #81, and #73, all of which ran and failed the check with a non-main base. --- .agents/skills/iterate-pr/SKILL.md | 18 +++++++++++------- .github/workflows/require-changeset.yml | 18 ++++++++++++++++++ CLAUDE.md | 10 ++++++---- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.agents/skills/iterate-pr/SKILL.md b/.agents/skills/iterate-pr/SKILL.md index 908594b0..c1ca7edd 100644 --- a/.agents/skills/iterate-pr/SKILL.md +++ b/.agents/skills/iterate-pr/SKILL.md @@ -266,13 +266,17 @@ handled normally regardless of stack position. #### Stacked PRs: two other failures that are structural, not regressions -**`Require a changeset` on the bottom PR.** The workflow runs only on PRs based -on `main` and looks for a `.changeset/*.md` added in that PR's own diff, so a -mid-stack PR never runs it and the bottom PR is the only place a changeset can -satisfy it. If it fails, move the changeset DOWN to the bottom branch rather -than labelling anything `skip-changeset` — every branch above inherits it, since -a child contains its ancestors' commits. Extend that one file as later PRs land; -never add a second changeset per PR. +**`Require a changeset` on the bottom PR.** The check looks for a +`.changeset/*.md` added in that PR's own diff, so the bottom PR — the one +targeting `main` — is the only place a changeset can satisfy it. If it fails +there, move the changeset DOWN to the bottom branch rather than labelling +anything `skip-changeset`; every branch above inherits it, since a child +contains its ancestors' commits. Extend that one file as later PRs land; never +add a second changeset per PR. + +Mid-stack PRs bypass the check on their base ref. If you see one failing it, +that bypass is missing — fix the workflow rather than reaching for the label, +which would wrongly record the change as shipping no release note. **Test failures on a merge-down stack.** When the delivery shape is _stacked, merging down_, no unit is independently correct — that is the diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml index 89292e47..d0276c5e 100644 --- a/.github/workflows/require-changeset.yml +++ b/.github/workflows/require-changeset.yml @@ -29,12 +29,30 @@ jobs: - name: Check for a changeset env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_REF: ${{ github.event.pull_request.head.ref }} LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | set -euo pipefail + # Mid-stack PR. On a stack, exactly one changeset describes the whole + # change and it lives on the bottom PR — the one targeting `main`, + # which is where this check has to pass. Every branch above inherits + # that file, so it is never *added* in a child's own diff and the + # check below would fail every one of them. Bypassing here keeps that + # from being papered over with a `skip-changeset` label, which would + # wrongly record "this change ships no release note." + # + # The `on:` block already filters to `branches: [main]`, but that + # filter does not hold for stacked PRs in practice — the workflow + # still runs on a PR based on a feature branch — so the base is + # re-checked here rather than trusted. + if [ "$BASE_REF" != "main" ]; then + echo "Base is '$BASE_REF', not 'main' — mid-stack PR, so the bottom PR of the stack carries the changeset." + exit 0 + fi + # The changesets "Version Packages" PR consumes changesets (removing # them is its whole job), so it legitimately has none. Bypass it by its # well-known bot branch name so it needs no manual `skip-changeset`. diff --git a/CLAUDE.md b/CLAUDE.md index 80be1c4c..1915ea6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,12 +88,14 @@ Note how this interacts with the archive gate (see the OpenSpec archive check be ### One changeset, at the bottom of the stack, grown as the stack grows -`require-changeset.yml` is `on: pull_request: branches: [main]` and looks for a `.changeset/*.md` **added in that PR's own diff**. Two consequences: +`require-changeset.yml` looks for a `.changeset/*.md` **added in that PR's own diff**. On a stack that means: -- **A mid-stack PR never runs the check at all** — its base is a feature branch. Do not label it `skip-changeset`; the label implies a decision that was never needed and reads as "this change ships no release note." -- **The changeset belongs on the bottom PR**, the one that targets `main`. That is also the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check fails on the PR that actually merges. +- **The changeset belongs on the bottom PR**, the one that targets `main`. That is the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check would fail on the PR that actually merges. +- **Mid-stack PRs bypass the check automatically**, because the base branch is not `main`. They inherit the base's changeset rather than adding one, so there is nothing for the check to find. **Do not label them `skip-changeset`** — the label records a deliberate "this change ships no release note," which is false here, and the bypass already handles it. -Put it at the base and every branch above inherits it, since a child contains its ancestors' commits. +Note the workflow's `on: branches: [main]` filter does **not** keep it from running on a PR based on a feature branch; the base is re-checked inside the step for exactly that reason. If you see mid-stack PRs failing this check, the bypass is missing, not the changeset. + +Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits. **Grow it incrementally as the stack lands.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them. When you extend it, edit the same file on the branch you are working on — never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once. From fb84caa15dcea7a7d89c6ee543c2720fb135463e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 2 Aug 2026 16:36:41 -0700 Subject: [PATCH 3/5] docs(iterate-pr): warn that propagate_stack leaves the repo on a stack branch Branching "from main" immediately after a propagate picks up the last rebased branch instead, so the new PR carries the entire stack and an OpenSpec change directory it never touched. Hit while opening this PR. --- .agents/skills/iterate-pr/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/skills/iterate-pr/SKILL.md b/.agents/skills/iterate-pr/SKILL.md index c1ca7edd..86c024d8 100644 --- a/.agents/skills/iterate-pr/SKILL.md +++ b/.agents/skills/iterate-pr/SKILL.md @@ -102,6 +102,14 @@ Cascade-rebases a branch's descendants onto their parents to carry a fix up the uv run ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.py --root [--dry-run] [--no-push] ``` +**It leaves the repo checked out on the last branch it rebased, not the branch +you started on.** Anything you do next inherits that as its base — branching +"from main" right after a propagate silently creates a branch on top of the +whole stack, and the resulting PR carries every stack commit plus a change +directory it never touched. Run `git checkout main` (or explicitly +`git checkout -B origin/main`) before starting unrelated work, and check +`git log --oneline origin/main..HEAD` before opening the PR. + ## Workflow ### 1. Identify PR From 514788b027a24b14c55287e9fe5586452d6a35d6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 3 Aug 2026 09:40:04 -0700 Subject: [PATCH 4/5] docs: correct the changeset guard's framing and the propagate_stack claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pushed back on three statements, and two of them were wrong. `propagate_stack.py` does return you to the branch you started on — normal completion, rebase conflict, balloon guard, and push failure all check it out before returning. Only a failure to check out a *child* (exit 6, typically that branch being held by a worktree) returns early without it. That is the path that actually bit, so the warning now names it instead of describing the script's normal behavior. The `branches: [main]` claim is reframed rather than dropped. It is documented to filter on the base branch, and the guard should be unreachable — but #73, #80, and #81 each produced a failing `Require a changeset` check run with an `openspec/partition-engine-*` base, and a workflow that never triggers produces no check run at all. The comment now cites that evidence and presents the check as a defensive guard, without teaching that Actions filters are unreliable in general. Also replaces the "structural failures are acceptable" guidance with the opposite rule: a mid-stack PR that is red is incomplete, and the fix belongs in it. That advice let this stack sit on 20 failures that turned out to be two real defects. --- .agents/skills/iterate-pr/SKILL.md | 62 +++++++++++++++++-------- .github/workflows/require-changeset.yml | 25 ++++++---- CLAUDE.md | 4 +- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/.agents/skills/iterate-pr/SKILL.md b/.agents/skills/iterate-pr/SKILL.md index 86c024d8..b04d23a4 100644 --- a/.agents/skills/iterate-pr/SKILL.md +++ b/.agents/skills/iterate-pr/SKILL.md @@ -102,13 +102,19 @@ Cascade-rebases a branch's descendants onto their parents to carry a fix up the uv run ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.py --root [--dry-run] [--no-push] ``` -**It leaves the repo checked out on the last branch it rebased, not the branch -you started on.** Anything you do next inherits that as its base — branching -"from main" right after a propagate silently creates a branch on top of the -whole stack, and the resulting PR carries every stack commit plus a change -directory it never touched. Run `git checkout main` (or explicitly -`git checkout -B origin/main`) before starting unrelated work, and check -`git log --oneline origin/main..HEAD` before opening the PR. +**One failure path leaves the repo on another branch.** Normal completion, a +rebase conflict, a balloon guard trip, and a push failure all `checkout` the +branch you started on before returning. The exception is when checking out a +_child_ fails (exit 6) — most often because that branch is checked out in a +worktree — which returns immediately, leaving you on whichever branch it had +reached. + +That matters because anything you do next inherits it as a base: branching "from +main" right after one of these silently creates a branch on top of the whole +stack, and the resulting PR carries every stack commit plus an OpenSpec change +directory it never touched. After a non-zero exit, run `git checkout main` (or +`git checkout -B origin/main`), and check `git log --oneline origin/main..HEAD` +before opening the PR. ## Workflow @@ -283,19 +289,35 @@ contains its ancestors' commits. Extend that one file as later PRs land; never add a second changeset per PR. Mid-stack PRs bypass the check on their base ref. If you see one failing it, -that bypass is missing — fix the workflow rather than reaching for the label, -which would wrongly record the change as shipping no release note. - -**Test failures on a merge-down stack.** When the delivery shape is -_stacked, merging down_, no unit is independently correct — that is the -definition of the shape. A lower PR can legitimately be red because the unit -that completes it has not landed yet (e.g. rules are relocated but dispatch -arrives in the next PR). Before treating such a failure as a regression, check -the branch out and run the suite: if the failures match what the change's own -tasks predict for that unit, they are structural and clear as the stack -accumulates. Say so on the PR rather than inventing throwaway assertions to make -an intermediate state look green. Verify it — do not assume a red check is -expected because the shape is merge-down. +look at that guard rather than reaching for the label, which would wrongly +record the change as shipping no release note. + +### Never leave a PR on red + +**No PR merges with failing tests, including mid-stack.** "Merging down" changes +where a PR merges, not whether its tests pass — and a red branch you merge into +its parent carries that red upward. + +It is tempting on a stack to explain a failure as structural: this unit +relocates something and the unit that consumes it lands next, so of course it +fails. **Treat that explanation as a bug report about the split, not a reason to +proceed.** If unit N alone leaves the suite red, unit N is incomplete — the +change that makes it self-consistent belongs _in_ it. In practice that means the +PR that moves something also repoints every reader and updates the tests that +assert the old shape; the next PR then generalizes. + +This is not hypothetical. A stack here sat on 20 failures rationalized as +"dispatch arrives in the next PR." The real causes were a migration that +relocated files while every reader kept the old paths, and a command that +discovered rules _before_ running the migration that creates them. Both were +real defects, in the released code path, that the structural story explained +away. An empty scan reports success, so the symptom was "no findings" rather +than an error — which is exactly why it looked like an artifact of the split. + +So: never annotate a red check as expected and move on. Check the branch out, +run the suite, and fix the unit until it is green on its own. The only failures +that survive that treatment are ones you can point at a specific missing commit +for — and if that commit is in this stack, it belongs in this PR. ### 5. Fix CI Failures diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml index d0276c5e..b2ed5ce4 100644 --- a/.github/workflows/require-changeset.yml +++ b/.github/workflows/require-changeset.yml @@ -39,15 +39,24 @@ jobs: # Mid-stack PR. On a stack, exactly one changeset describes the whole # change and it lives on the bottom PR — the one targeting `main`, # which is where this check has to pass. Every branch above inherits - # that file, so it is never *added* in a child's own diff and the - # check below would fail every one of them. Bypassing here keeps that - # from being papered over with a `skip-changeset` label, which would - # wrongly record "this change ships no release note." + # that file, so it is never *added* in a child's own diff, and without + # this guard the check below fails every one of them. That must not be + # papered over with a `skip-changeset` label, which would wrongly + # record "this change ships no release note." # - # The `on:` block already filters to `branches: [main]`, but that - # filter does not hold for stacked PRs in practice — the workflow - # still runs on a PR based on a feature branch — so the base is - # re-checked here rather than trusted. + # `on: pull_request` with `branches: [main]` is documented to filter on + # the PR's BASE branch, which should make this unreachable. It was not: + # PRs #73, #80, and #81 — bases `openspec/partition-engine-*`, never + # `main` — each produced a failing `Require a changeset` check run + # (e.g. run 30786954198, event `pull_request`, head + # `openspec/partition-engine-2-dispatch`, associated PR #80 with a + # feature-branch base). A workflow that never triggers produces no + # check run at all, so those runs are the evidence. + # + # Treat this as a defensive guard: if the trigger filter behaves as + # documented it costs one string comparison, and where it does not, it + # is what keeps a mid-stack PR from being told to add a second + # changeset. if [ "$BASE_REF" != "main" ]; then echo "Base is '$BASE_REF', not 'main' — mid-stack PR, so the bottom PR of the stack carries the changeset." exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index 1915ea6e..3310b6b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,9 +91,9 @@ Note how this interacts with the archive gate (see the OpenSpec archive check be `require-changeset.yml` looks for a `.changeset/*.md` **added in that PR's own diff**. On a stack that means: - **The changeset belongs on the bottom PR**, the one that targets `main`. That is the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check would fail on the PR that actually merges. -- **Mid-stack PRs bypass the check automatically**, because the base branch is not `main`. They inherit the base's changeset rather than adding one, so there is nothing for the check to find. **Do not label them `skip-changeset`** — the label records a deliberate "this change ships no release note," which is false here, and the bypass already handles it. +- **Mid-stack PRs bypass the check**, because the base branch is not `main`. They inherit the base's changeset rather than adding one, so there is nothing for the check to find. **Do not label them `skip-changeset`** — the label records a deliberate "this change ships no release note," which is false here, and the bypass already handles it. -Note the workflow's `on: branches: [main]` filter does **not** keep it from running on a PR based on a feature branch; the base is re-checked inside the step for exactly that reason. If you see mid-stack PRs failing this check, the bypass is missing, not the changeset. +The bypass is an in-step check on the base ref. `on: pull_request` with `branches: [main]` is documented to filter on the base branch, which should make that redundant — but it did not hold here: #73, #80, and #81 all produced failing `Require a changeset` check runs with `openspec/partition-engine-*` bases, and a workflow that never triggers produces no check run at all. Keep the guard, and if you see a mid-stack PR failing this check, look there rather than reaching for the label. Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits. From 612e7e83794deaabc8f1b0da7bb00de261359946 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 3 Aug 2026 09:53:43 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20name=20the=20mechanism=20=E2=80=94?= =?UTF-8?q?=20branches:=20[main]=20matches=20a=20stack's=20eventual=20targ?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `branches: [main]` reads as "only PRs whose base is main," and filtered that way when a PR had one base. Under GitHub's stacked-PR support a stacked PR targets `main` eventually, so the filter matches the eventual target and these workflows run on mid-stack PRs too — which is why #73, #80, and #81 each produced a failing `Require a changeset` run with an `openspec/partition-engine-*` base. That makes the base-ref guard required rather than defensive, and generalizes: a workflow whose correctness depends on "is this the PR that merges to main" has to establish that itself. Corrects the OpenSpec archive-check section too, which told readers a stacked PR would not run that check at all. --- .agents/skills/iterate-pr/SKILL.md | 18 +++++++++++++----- .github/workflows/require-changeset.yml | 24 ++++++++++++------------ CLAUDE.md | 4 +++- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.agents/skills/iterate-pr/SKILL.md b/.agents/skills/iterate-pr/SKILL.md index b04d23a4..7ed9b354 100644 --- a/.agents/skills/iterate-pr/SKILL.md +++ b/.agents/skills/iterate-pr/SKILL.md @@ -246,11 +246,11 @@ exactly once, at the END of the work — so a PR that still carries an in-progre change directory will fail this check. Archiving on an intermediate PR is wrong: it would remove the change docs before the implementation PRs above it merge. -Note the workflow's trigger is currently `pull_request.branches: [main]`, so it -only RUNS on PRs whose base is `main`. A stacked PR based on another feature -branch won't run (or fail) this check at all — so there is nothing to ignore -there. The check matters for PRs that target `main`: typically the bottom of a -stack, plus any PR later retargeted to `main` as the stack merges down. +The workflow's trigger is `pull_request.branches: [main]`, but do NOT read that +as "it only runs on PRs whose base is `main`." Under GitHub's stacked-PR support +a stacked PR targets `main` eventually and the filter matches that eventual +target, so this workflow runs on mid-stack PRs as well. Expect to see the check +on every PR in a stack and decide from stack position, not from the `on:` block. When the archive check does run and fail, decide ONE thing before treating it as actionable: **is this PR the last in the chain (the tip)?** A PR is the tip when @@ -292,6 +292,14 @@ Mid-stack PRs bypass the check on their base ref. If you see one failing it, look at that guard rather than reaching for the label, which would wrongly record the change as shipping no release note. +**Do not read `on: pull_request: branches: [main]` as "this only runs on the +bottom PR."** Under GitHub's stacked-PR support a stacked PR targets `main` +eventually, and the filter matches that eventual target — so these workflows run +on mid-stack PRs too. A workflow that must act only on the PR merging to `main` +has to establish that from the base ref or its stack position. When judging +whether a check "should even be running here," check the stack rather than the +`on:` block. + ### Never leave a PR on red **No PR merges with failing tests, including mid-stack.** "Merging down" changes diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml index b2ed5ce4..54555af6 100644 --- a/.github/workflows/require-changeset.yml +++ b/.github/workflows/require-changeset.yml @@ -44,19 +44,19 @@ jobs: # papered over with a `skip-changeset` label, which would wrongly # record "this change ships no release note." # - # `on: pull_request` with `branches: [main]` is documented to filter on - # the PR's BASE branch, which should make this unreachable. It was not: - # PRs #73, #80, and #81 — bases `openspec/partition-engine-*`, never - # `main` — each produced a failing `Require a changeset` check run - # (e.g. run 30786954198, event `pull_request`, head - # `openspec/partition-engine-2-dispatch`, associated PR #80 with a - # feature-branch base). A workflow that never triggers produces no - # check run at all, so those runs are the evidence. + # This guard is required, not belt-and-braces. `branches: [main]` reads + # like "only PRs whose base is main," and that is how it filtered when + # a PR had exactly one base. Under GitHub's stacked-PR support a PR in + # a stack is understood to target `main` *eventually*, so the filter + # matches on the eventual target and the workflow runs on mid-stack + # PRs too. Observed here: #73, #80, and #81 — bases + # `openspec/partition-engine-*`, never `main` — each produced a failing + # `Require a changeset` check run (e.g. run 30786954198, event + # `pull_request`, head `openspec/partition-engine-2-dispatch`). # - # Treat this as a defensive guard: if the trigger filter behaves as - # documented it costs one string comparison, and where it does not, it - # is what keeps a mid-stack PR from being told to add a second - # changeset. + # So `branches:` no longer scopes a workflow to the bottom of a stack. + # Any job whose correctness depends on "is this the PR that merges to + # main" has to establish that itself, as this one does. if [ "$BASE_REF" != "main" ]; then echo "Base is '$BASE_REF', not 'main' — mid-stack PR, so the bottom PR of the stack carries the changeset." exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index 3310b6b8..a327b4fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,9 @@ Note how this interacts with the archive gate (see the OpenSpec archive check be - **The changeset belongs on the bottom PR**, the one that targets `main`. That is the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check would fail on the PR that actually merges. - **Mid-stack PRs bypass the check**, because the base branch is not `main`. They inherit the base's changeset rather than adding one, so there is nothing for the check to find. **Do not label them `skip-changeset`** — the label records a deliberate "this change ships no release note," which is false here, and the bypass already handles it. -The bypass is an in-step check on the base ref. `on: pull_request` with `branches: [main]` is documented to filter on the base branch, which should make that redundant — but it did not hold here: #73, #80, and #81 all produced failing `Require a changeset` check runs with `openspec/partition-engine-*` bases, and a workflow that never triggers produces no check run at all. Keep the guard, and if you see a mid-stack PR failing this check, look there rather than reaching for the label. +The bypass is an in-step check on the base ref, and it is load-bearing. **`branches: [main]` no longer means "only the PR whose base is `main`."** Under GitHub's stacked-PR support, a PR in a stack is understood to target `main` eventually, so the filter matches on that eventual target and the workflow runs on mid-stack PRs as well — observed here on #73, #80, and #81, all with `openspec/partition-engine-*` bases. + +The general rule that follows: **any workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself** — from the base ref, or by resolving stack position — and cannot lean on the `on:` filter to scope it. If you see a mid-stack PR failing this check, look at that guard rather than reaching for the label. Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits.