Skip to content

Commit d66580b

Browse files
committed
ci: remove the OpenSpec archive gate rather than keep a red that means "busy"
The check failed `main` while `openspec/changes/` held an unarchived change directory. That is exactly the state a forward-merging stack is in for its whole life: the change directory lands with the first slice and is archived by the last, so `main` runs red for however long the stack takes to drain. A signal that is expected to be red is not a signal. This is the second version of the same mistake — the first was a PR-time gate that had to infer stack position, fired on in-flight work, and taught people to ignore red. Moving it to `main` removed the false positives on pull requests and reintroduced the same problem one level up. Both versions measured "work is in progress". The thing worth detecting is work that STALLED — a change directory whose branches are gone and whose tasks were never finished — and neither gate could tell those apart, because the directory looks identical in both cases. Removing it now; a replacement that measures abandonment rather than activity is a separate piece of design, and blocking this stack on that design would be the tail wagging the dog when the server team is waiting on our nightly. `Validate specs` and `Check spec requirement visibility` are untouched. They answer a different question — whether the specs are well-formed and fully read — which does not depend on stack position and is green throughout. Documentation follows the code, since three places described a gate that would no longer exist: CLAUDE.md's stacked-PR section, its gotchas list, and the iterate-pr skill. Leaving those would have reproduced the drift this stack has spent its time removing — a shape described in prose in several places, none of them true.
1 parent cb929ee commit d66580b

3 files changed

Lines changed: 17 additions & 58 deletions

File tree

‎.agents/skills/iterate-pr/SKILL.md‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,20 @@ Run `${CLAUDE_SKILL_ROOT}/scripts/fetch_pr_checks.py` to get structured failure
241241
#### No PR check asks whether the OpenSpec change is archived
242242

243243
A change is archived exactly once, at the END of the work, so an unarchived
244-
directory under `openspec/changes/` is the normal state of a pull request. There
245-
is no PR-time gate for it — the earlier one had to infer stack position to avoid
246-
firing on in-flight work, and a check that is expected-red on most of a stack
247-
teaches people to ignore red.
248-
249-
The archive signal lives on `main` instead: a step in `validate.yml` runs on
250-
push events only and fails while `main` carries an unarchived change directory.
251-
If you see it red on `main`, the fix is to archive the change via the OpenSpec
252-
archive flow — which moves `openspec/changes/<name>/` to
253-
`openspec/changes/archive/YYYY-MM-DD-<name>/` — or to land the stack that is
254-
still holding it open.
244+
directory under `openspec/changes/` is the normal state of a pull request.
245+
There is no gate for it, in either direction.
246+
247+
There were two, and both measured the wrong thing. A PR-time gate had to infer
248+
stack position to avoid firing on in-flight work, and a check that is
249+
expected-red on most of a stack teaches people to ignore red. Moving it to
250+
`main` fixed the false positives and introduced a worse problem: a
251+
forward-merging stack leaves its change directory on `main` until the final
252+
slice, so `main` ran red for the whole time the stack was draining. A signal
253+
that is expected to be red is not a signal.
254+
255+
What is worth detecting is work that stalled and was abandoned. Neither gate
256+
measured that; both measured "work is in progress". A replacement is a separate
257+
piece of design.
255258

256259
Practically, on a PR: archive when the PR is the last in the chain, and leave
257260
the change directory alone otherwise. Nothing will fail either way.

‎.github/workflows/validate.yml‎

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -117,47 +117,3 @@ jobs:
117117
# requirement below it becomes invisible — valid, unread, and green.
118118
- name: Check spec requirement visibility
119119
run: node .github/scripts/openspec-visibility.cjs
120-
121-
# Main only, deliberately. An unarchived change directory is the NORMAL
122-
# state of a pull request — a change is archived exactly once, on the last
123-
# slice of the work — so any PR-time gate has to guess at stack position to
124-
# avoid firing on in-flight work, and it guesses wrong often enough that
125-
# people learn to ignore it. `main` has no such ambiguity: whatever sits
126-
# under openspec/changes/ once the work has landed is debris, and this is
127-
# the one place that can be said without qualification.
128-
#
129-
# This replaces both a merge-time archive gate (false positives on every
130-
# mid-stack PR) and a nightly staleness sweep (a second opinion about the
131-
# same directory, dated from git history). Neither is needed: main going
132-
# red is a standing signal that there is something to clean up, and it
133-
# clears the moment the change is archived.
134-
#
135-
# Known and accepted: a stack that merges FORWARD lands its change
136-
# directory on main and archives it only on the final slice, so main runs
137-
# red for as long as that stack is draining. Nothing is blocked by it —
138-
# branch protection reads the PR's own `Validate`, not main's — and the
139-
# alternative is dating directories from git history to guess at intent,
140-
# which is the check this one replaces. Merging the stack DOWN (see
141-
# CLAUDE.md) keeps main clean throughout.
142-
- name: Check for unarchived OpenSpec changes on main
143-
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
144-
run: |
145-
set -euo pipefail
146-
147-
if [ ! -d openspec/changes ]; then
148-
echo "openspec/changes does not exist — nothing to check."
149-
exit 0
150-
fi
151-
152-
UNARCHIVED=$(find openspec/changes -mindepth 1 -maxdepth 1 -type d ! -name archive)
153-
154-
if [ -n "$UNARCHIVED" ]; then
155-
echo "::error::main is carrying unarchived OpenSpec change directories:"
156-
echo "$UNARCHIVED" | sed 's|^| - |'
157-
echo ""
158-
echo "The work has landed, so these are debris. Archive each one (e.g. /openspec-archive-change <name>), which moves it to openspec/changes/archive/YYYY-MM-DD-<name>/, and push to main."
159-
echo "If a change is still in flight on an open stack, land or close that stack — main should not hold an in-progress change once its branches are gone."
160-
exit 1
161-
fi
162-
163-
echo "No unarchived OpenSpec changes on main — OK."

‎CLAUDE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The proposal states which of these the change is, and why. Decide it while writi
107107

108108
The deciding question between forward and down is only this: **can each unit reach production on its own without breaking anything?** If landing unit 1 alone would leave `check` broken, tests failing, or a migration half-applied, the answer is no and the stack merges down. Do not assume forward because it is tidier. Verify it, since "each unit is safe" is a claim about behavior, not intent.
109109

110-
Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. No PR check asks about that. An unarchived change directory is the normal state of a pull request, so a PR-time gate can only guess at stack position, and it guessed wrong often enough to be ignored. The only check is on `main` (a step in `validate.yml`, push events only), which goes red while `main` carries an unarchived change directory. A stack that merges **down** keeps `main` clean throughout; a stack that merges **forward** leaves `main` red until its final slice archives the change. Nothing is blocked by that red (branch protection reads each PR's own `Validate`), but it is a standing reminder that the stack is unfinished.
110+
Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. **No check asks about that anywhere any more.** A PR-time gate could only guess at stack position, and guessed wrong often enough to be ignored; a `main`-only gate replaced it and turned `main` red for the entire time a forward-merging stack was draining, which is a red that means "work is in progress" rather than "something is wrong". A signal that is expected to be red is not a signal. Archiving is now a step you perform on the tip, and the thing worth detecting — work that stalled and was abandoned — is not what either gate measured.
111111

112112
### One changeset, at the bottom of the stack, grown as the stack grows
113113

@@ -129,7 +129,7 @@ The placement rules are unchanged, because they are about review quality rather
129129
Two rules follow, and they pull in opposite directions:
130130

131131
- **A workflow that must run everywhere carries no `branches:` filter at all.** Lint, typecheck, and tests have no interest in where a PR eventually merges. `validate.yml`, `changeset.yml`, and `stack-breadcrumb.yml` all carry no filter, which is why they kept running on #103. If you add such a workflow, also name `ready_for_review` in `types:`. It is not in the default set (`opened`/`synchronize`/`reopened`), so without it a draft marked ready gets no fresh run until someone happens to push again.
132-
- **A 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. Better still, ask a question that does not depend on stack position at all: `changeset.yml` diffs against `main` rather than against its base, and the archive check moved off pull requests entirely.
132+
- **A 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. Better still, ask a question that does not depend on stack position at all: `changeset.yml` diffs against `main` rather than against its base. The archive check tried the other route — moving off pull requests onto `main` — and was removed instead, because on `main` it reported an in-flight stack as a fault.
133133

134134
The shared point: the `on:` filter is not a reliable answer to "where does this PR land." Let the workflow run, and decide inside it.
135135

@@ -238,7 +238,7 @@ This happens when the **parent** PR is merged with `--delete-branch`: deleting t
238238

239239
- **Projects-classic deprecation** breaks some GraphQL-backed `gh` commands (e.g. `gh pr reopen`). Workaround: use the REST API for PR state changes (`gh api --method PATCH .../pulls/<n> -f state=open`).
240240
- **`gh pr update-branch` may not exist** in the installed `gh`; update locally (`git merge origin/main` on the up-to-date remote branch) and push.
241-
- **No PR-time OpenSpec archive check.** An unarchived change directory on a pull request is expected, not a failure. The only signal is `main`'s own `Validate` run, which goes red while `main` carries one.
241+
- **No OpenSpec archive check at all.** An unarchived change directory is expected on a pull request AND on `main` while a forward-merging stack drains. Neither is a failure, and nothing reports it. Archive the change on the tip slice; a replacement that measures abandoned work rather than in-progress work is a separate piece of design.
242242
- **Clean up local branches** once the stack lands: `git fetch --prune`, then delete the branches that merged (`git branch --merged main`).
243243

244244
## OpenSpec Apply

0 commit comments

Comments
 (0)