Skip to content

chore: add auto-add to engineer kanban workflow - #2

Merged
saadqbal merged 3 commits into
developfrom
chore/add-kanban-workflow
Apr 24, 2026
Merged

chore: add auto-add to engineer kanban workflow#2
saadqbal merged 3 commits into
developfrom
chore/add-kanban-workflow

Conversation

@LukasWodka

Copy link
Copy Markdown
Contributor

Summary

Adds a 14-line GitHub Actions workflow that auto-adds every new issue and pull request from this repo to the engineer kanban project:

https://github.com/orgs/tracebloc/projects/2

Fires on issues: opened|reopened|transferred and pull_request: opened|reopened|ready_for_review. No effect on existing items.

Why

GitHub Projects v2's built-in "Auto-add to project" workflow only supports one repo per project. This action-based approach is GitHub's recommended workaround to get cross-repo visibility on a single kanban.

Dependencies

Uses the org-level secret PROJECTS_KANBAN_TOKEN (already configured with project + repo scopes, visible to all repositories).

Testing

After merge, open any new issue or PR and confirm it appears in the Backlog column of the kanban within a few seconds. If it doesn't, check the Actions tab for the workflow run.

Related

One of ~18 identical PRs being rolled out across the tracebloc org so every repo's work lands on the shared engineer kanban.

🤖 Generated with Claude Code

@v1 is not a valid tag — action publishes full semver only. Pin to v1.0.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

Re-triggering workflow after v1.0.2 pin.

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

Re-triggering after fixing the second workflow file.

@LukasWodkaLukasWodka reopened this Apr 24, 2026
@LukasWodka
LukasWodka changed the base branch from main to developApril 24, 2026 15:42
@saadqbal
saadqbal merged commit bfd73f0 into developApr 24, 2026
1 check passed
LukasWodka pushed a commit that referenced this pull request Apr 29, 2026
`gh api -F` performs type inference: digit-only strings are sent as
JSON numbers. Two Status option IDs on project #2 are all-digit
(`Done`=98236657, `Validation`=90729828), which made the
`updateProjectV2ItemFieldValue` mutation reject them with
`Variable $o of type String! was provided invalid value`.
Switch the four `-F` flags on the mutation call to `-f` so they're
always serialized as strings. The GraphQL types involved are
`ID!`/`String!`, never numeric, so `-f` is correct for all four.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Jul 28, 2026
add-to-kanban is fire-and-forget (one shot on 'opened', no retry), so a
single dropped webhook leaves a permanent silent gap - e.g. tracebloc-engine#511
was never on the board, which blocked its dev->staging promotion. The weekly
reconciler previously only fixed the Status of items already on the board; it
now also sweeps every board-tracked repo for open + recently-merged PRs/issues
with no project #2 entry and adds them in the right starting column (open PR ->
Code review, draft -> In progress, issue -> Backlog, merged -> On dev/FR on
staging/Prod by containment). Archived items count as present, so the daily
archiver is never fought. Cap: aborts if > 60 look missing (scope bug guard).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LukasWodka
LukasWodka deleted the chore/add-kanban-workflow branch August 1, 2026 21:45
LukasWodka added a commit that referenced this pull request Aug 21, 2026
…rusted
saadqbal on .github#295, and his framing is stronger than Bugbot Medium.
Bugbot said a NEW literal in advance-deploy-env.yml would slip past. The
fact is thirteen Status names are in that file RIGHT NOW, in `rank()`, and
dropping it from WRITERS took all thirteen outside the check. The file
documents its own failure mode: an unknown Status returns "" from rank(),
the guard falls through to strict equality, and the card then BLOCKS every
prod promotion carrying it. A read-side name that does not resolve breaks at
least as loudly as a write-side one -- so "WRITERS is a write-side name" was
technically defensible and wrong about what the check is for.
That was the SECOND wrong removal on this PR; the router was the first.
And the naive fix would have failed the check, which is worth knowing before
someone tries it: twelve of the thirteen are live board options, and the
thirteenth is `Staging (human review)` -- the phantom column this PR own
headline finding identified. So, in order:
1. RETIRE THE SHIM. It ranked alongside `FR on staging` to keep the monotonic
guard stable across the #1592 rename INSTANT, and that instant has passed --
measured against project #2. Keeping a shim for a completed rename is how
this same file came to accept an override naming a nonexistent column.
2. Restore the file to WRITERS, where its remaining twelve get checked.
Two hand-removals, two errors, is enough: `unlisted_namers()` derives against
the tuple now. Any workflow naming a board column on a CODE line while absent
from WRITERS is a finding, with a two-row exemption list carrying reasons.
It found two on its first run, which is the argument for it existing:
kanban-archive.yml:104 selects the three terminal columns to archive, and
wip-limit-check.yml:47 defaults its column input to `Code review`. Neither
writes a Status; neither name was checked. A rename would have left the
archiver archiving nothing and the WIP check counting an empty column -- both
indistinguishable from a quiet board.
Three things the tests caught while doing this, all mine: the guard fired
inside the stubbed-board cases and short-circuited main() (stubbed in run(),
like cross_check, with the reason written down); my own cases got that stub
because run() patches permanently, so they use kcc_fresh as the cross_check
cases already did; and the paths-filter regex matched only CONSECUTIVE list
items, so my interleaved comment truncated the block and produced a false
"uncovered" that took two rounds to read as a parser artefact.
16/16, all 10 selftests green, ruff and actionlint clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Aug 21, 2026
…does not exist (#295)
* fix(2243): one branch to Status mapping, and it surfaced a column that does not exist
Two workflows decide a card Status from the branch a merge landed on, and
they held THREE copies of the rule between them: advance-deploy-env had one
and READ the per-repo `.kanban.yml` override; kanban-closure-router had two
and IGNORED it. Both write Status, and a PR merged to develop fires both --
the router on pull_request closed, advance on the push -- so with a
`.kanban.yml` present they would write DIFFERENT statuses for the same merge
and run ordering decided which stuck.
The router could not have honoured the override even in principle: it never
checks the caller out. That is why the fix is one shared mapping rather than
a second copy of the yq read -- the override has to be FETCHED.
WHAT THIS FOUND, which is the part worth reading. `Staging (human review)`
was an accepted override value in advance-deploy-env and is NOT a column on
the board -- measured against project #2, whose Status options are Backlog,
North Stars, Ready, In progress, Code review, On dev, Staging (agent
review), FR on staging, Ready for prod, Prod, Done, Cancelled. A repo that
had used it would have had its write rejected for naming a column that does
not exist. Nothing caught it because the vocabulary lived in a shell `case`
that kanban-columns-check.py never read.
So kanban-columns-check now IMPORTS the mapping instead of regex-scraping
two workflows for it. That is strictly stronger -- it reads the data
structure rather than a rendering of it -- and it SHRINKS the regex surface
rather than growing it. Both rewired workflows come out of WRITERS with the
reason stated, since their literals are legitimately gone.
The fold happens in `written_names`, not in `main`: the selftest substitutes
`written_names` to control its input against a fake board, and folding into
main silently widened what the selftest could not see. Caught by that
selftest going red.
A GUARD SO THE MAPPING CANNOT BE RE-COPIED. Its first version matched any
`STATUS="On dev"` and flagged three innocent sites -- a sibling-merge
holding state and two no-base-ref floors. Those are policy defaults for
cases where there IS no branch, not copies of the mapping, and a guard that
cannot tell the difference gets argued with and then switched off. Narrowed
to a branch-keyed case arm, with both directions asserted.
22 selftest cases, 10/10 kanban-columns, all 10 .github selftests green,
ruff clean, actionlint clean, make check green.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): five findings, and two of them defeated the feature entirely
Bugbot on .github#295. Two Highs, and both meant the override still never
applied -- so the PR would have shipped a refactor with the bug intact.
HIGH 1 -- PyYAML is not on the runner. `read_override` imported `yaml`
lazily and treated ImportError exactly like a parse miss: a warning and an
empty override. Neither rewired workflow installs it, so a real
`.kanban.yml` would have been silently ignored and both writers would have
kept the defaults -- failing open in the one environment that matters.
Parsed with `yq` now, which is what advance-deploy-env used before this
refactor, so it is a dependency this path already had. A present file with
no parser REFUSES rather than defaulting: an unread override is not "no
override".
HIGH 2 -- the router `case` clobbered any non-stock override. Both sites
called the mapper and then a `case` over the four stock branch names
overwrote ANY other result with the On dev floor. So a `.kanban.yml` key
for a branch outside those four was computed and discarded on the same run
-- including the rfcs override this change exists to unblock. Keyed on
whether the mapper ANSWERED now, which also removes the last branch-name
list from that workflow.
MEDIUM -- kanban-reconcile held the second copy, justified by "identical,
deliberately" (.github#126). backend#2243 made that justification unsafe:
once the router honours an override, an identical copy is no longer
identical, and the weekly sweep would move an overridden card BACK onto a
deploy column -- the slower one silently undoing the faster one, exactly as
that comment predicted, through the mechanism it was written to prevent.
It calls the mapper now; the Status-name to option-id case that remains is
a different relation, not a second copy of the rule.
MEDIUM -- a mapping-only PR never ran the board-name check, because
`branch_status_map.py` was not in kanban-columns.yml `paths:` even though
the check now imports it. Added, with its own selftest assertion: WRITERS
does not name it (it is not a workflow), so the existing WRITERS-vs-paths
case could never have covered it.
MEDIUM -- I dropped kanban-closure-router from WRITERS on the claim its
literals were gone. Only its BRANCH MAPPING moved; it still writes
Cancelled, Done and On dev, and Cancelled/Done are not in the imported
mapping -- so the conformance check could have gone green while those two
named columns the board no longer has. Restored, with the reason.
advance-deploy-env stays out because it now writes no literal at all, which
is checkable rather than asserted.
Also fixed a shell syntax break of my own: replacing the `case` heads left
their arms and `esac` behind. actionlint caught it.
11/11 kanban-columns, 22/22 branch-status-map, all 10 .github selftests
green, ruff and actionlint clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): two Highs, both mine, both the same defect through a different door
Bugbot on .github#295 round two.
HIGH -- reconcile had no checkout. My own note said "branch_status_map.py
is local: this job only ever runs in tracebloc/.github" -- true of the
REPOSITORY and false of the WORKSPACE. A hosted runner starts empty, so the
stage-derivation step would have failed on a missing file and taken the
weekly backstop down mid-sweep. A comment that is confidently right about
the wrong noun.
HIGH -- read_override failed open on a fetch failure. It caught every `gh
api` error and returned an empty map, identical to "no .kanban.yml". So a
present override behind a 403, a 5xx or a rate limit was silently ignored
and both writers applied the defaults: the exact override-ignore defect
this change exists to close, reached by a different door. The parse and
missing-`yq` paths already refused; this one did not.
404 is now the only failure that means "no override", matched on the
message the way promote-repo.sh does it since `gh` exits non-zero for both.
Everything else refuses.
Four cases, and they assert `SystemExit` specifically rather than "it
raised" -- a different exception would mean a different path, which is the
rule this repo has on bare assertRaises.
One of those cases failed on its first run for a reason worth keeping: the
loop set the stub AFTER the call, so the 403 iteration ran against the 404
stub from the case above and reported a false failure. The test caught its
own off-by-one.
26/26 branch-status-map, all 10 .github selftests green, ruff and
actionlint clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): the override was read from the wrong ref, and reconcile could not survive it
Bugbot on .github#295, two Highs.
HIGH -- `read_override` defaulted to the API HEAD, i.e. the repo DEFAULT
branch, and no caller passed a ref. So an override present on `develop` but
not yet on `main` was silently ignored by both writers. And that is a
REGRESSION rather than a new limitation: advance-deploy-env previously read
`.kanban.yml` off the checked-out PUSHED branch. My own docstring argued for
the default on the grounds that a closed PR head may be gone -- true, and
irrelevant, because every branch these callers map is a BASE branch that
persists. Right reasoning, wrong input.
All four call sites pass the ref now, and the selftest asserts it by reading
the call sites: the failure is a MISSING argument, which no unit call can
show. Verified non-vacuous by dropping one ref and watching it redden.
HIGH -- the mapper call could take the weekly sweep down. Making
read_override REFUSE on an unreadable `.kanban.yml` was right for a single
card and wrong inside a loop: a bare command substitution under `set -euo
pipefail` let ONE unreadable repo truncate the whole backstop mid-sweep,
while the sibling closer lookups beside it already soft-fail.
It soft-fails now and SKIPS the item rather than defaulting. Falling back to
the default mapping would be the fail-open this PR removed, one layer out --
the router already moved that card correctly, and this sweep exists to fix
misses, not to overrule with a guess.
33/33 branch-status-map, all 10 .github selftests green, actionlint and ruff
clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): the one arm that was silently empty while the docstring said otherwise
Bugbot Medium, and saadqbal made the argument better than Bugbot did: this
is not merely an inconsistency with the other arms, it CONTRADICTS the
contract documented two lines above it.
The docstring claimed "a malformed one is also empty, and SAYS SO on
stderr". True of a YAML/JSON parse failure, which raises with ::error::.
False of a `branch_status_map` that parses fine and is a list: no message,
no exit code, {} returned. So the file had three refuse-loudly paths, one
legitimate empty, and one that was silently empty while the docs said it
announced itself.
A documented contract that one path quietly breaks is worse than an
undocumented one, because the next reader trusts it -- and I was that
reader, twice, on this PR.
The arm refuses now, and the docstring is a TABLE of all five outcomes
rather than prose that drifted from three of them. The cases assert the five
as a SET, not one at a time, because the defect was an inconsistency BETWEEN
them. Plus the inverse -- a real mapping still comes back -- so the guard
cannot degenerate into "refuse anything".
37/37, all 10 .github selftests green, ruff clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): WRITERS was wrong twice, so it is checked now instead of trusted
saadqbal on .github#295, and his framing is stronger than Bugbot Medium.
Bugbot said a NEW literal in advance-deploy-env.yml would slip past. The
fact is thirteen Status names are in that file RIGHT NOW, in `rank()`, and
dropping it from WRITERS took all thirteen outside the check. The file
documents its own failure mode: an unknown Status returns "" from rank(),
the guard falls through to strict equality, and the card then BLOCKS every
prod promotion carrying it. A read-side name that does not resolve breaks at
least as loudly as a write-side one -- so "WRITERS is a write-side name" was
technically defensible and wrong about what the check is for.
That was the SECOND wrong removal on this PR; the router was the first.
And the naive fix would have failed the check, which is worth knowing before
someone tries it: twelve of the thirteen are live board options, and the
thirteenth is `Staging (human review)` -- the phantom column this PR own
headline finding identified. So, in order:
1. RETIRE THE SHIM. It ranked alongside `FR on staging` to keep the monotonic
guard stable across the #1592 rename INSTANT, and that instant has passed --
measured against project #2. Keeping a shim for a completed rename is how
this same file came to accept an override naming a nonexistent column.
2. Restore the file to WRITERS, where its remaining twelve get checked.
Two hand-removals, two errors, is enough: `unlisted_namers()` derives against
the tuple now. Any workflow naming a board column on a CODE line while absent
from WRITERS is a finding, with a two-row exemption list carrying reasons.
It found two on its first run, which is the argument for it existing:
kanban-archive.yml:104 selects the three terminal columns to archive, and
wip-limit-check.yml:47 defaults its column input to `Code review`. Neither
writes a Status; neither name was checked. A rename would have left the
archiver archiving nothing and the WIP check counting an empty column -- both
indistinguishable from a quiet board.
Three things the tests caught while doing this, all mine: the guard fired
inside the stubbed-board cases and short-circuited main() (stubbed in run(),
like cross_check, with the reason written down); my own cases got that stub
because run() patches permanently, so they use kcc_fresh as the cross_check
cases already did; and the paths-filter regex matched only CONSECUTIVE list
items, so my interleaved comment truncated the block and produced a false
"uncovered" that took two rounds to read as a parser artefact.
16/16, all 10 selftests green, ruff and actionlint clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): restoring the file to WRITERS did not cover its rank() names
Bugbot High + Medium on .github#295, and both are about claims I wrote one
commit earlier.
HIGH -- putting advance-deploy-env.yml back in WRITERS covered NONE of
rank()s twelve names. `LITERAL` matches an assignment idiom and a `case` arm
has no `=`; WRITERS membership also makes `unlisted_namers` SKIP the file. So
all twelve stayed invisible -- and `Backlog`, `North Stars` and `Ready` were
collected from nowhere at all -- while the comment I had just added claimed
they were covered. Measured, not argued: 12 of 12 unattributed before, 0
after.
`written_names` now collects every board column a WRITERS file NAMES, not
only the ones it assigns, through one `names_in` helper that `unlisted_namers`
also uses. Two copies of "does this file name a column" is the drift this
file keeps finding in itself.
MEDIUM -- the stale-exemption check the docstring promised did not exist.
`unlisted_namers` said stale entries "are reported by the caller for the same
reason mint-scope.py reports its own", and main() never diffed them. It does
now, with three expiry conditions asserted separately because they are
different facts: the file is gone, it joined WRITERS, or it names no column
any more. Plus the inverse, so a live exemption is not a permanent red. The
exempt dict moved to module level so both functions read ONE copy.
AND FIXING THE HIGH NEARLY CREATED ITS TWIN. The staleness guard tested `if
not found`, and `found` now has three contributors -- so case-arm names alone
could have satisfied it while `LITERAL` matched nothing: the guard disarmed by
the very change that broadened the collection. It is keyed on the assignment
pass now, and I broke the pattern to watch it fire.
7 cases (23/23), all 10 selftests green, make check 41/41, ruff clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): a refused override must not cost the router its write
Bugbot High on .github#295, and it is NEW -- the old in-file `case` could
not fail this way.
`read_override` refuses on an unreadable `.kanban.yml`, and a bare command
substitution under `set -euo pipefail` then exits the target step before any
Status is published. The update step non-empty guard skips, and the
project built-in "Item closed" automation sets `Cancelled` and archives
shipped-via-parent work -- the .github#157 no-write path this same file
documents, reached by a door I opened. And it fires exactly when the first
repo adopts the override this change exists to unblock.
THE POLICY DIFFERS BY CALLER, and that is the substance rather than a
detail. reconcile SKIPS the item: it is a backstop that fixes misses, so
writing a guessed column would overrule a router that already got it right.
The router writes the DEFAULT: publishing nothing is strictly worse than
ignoring an override for one run, because one is recoverable and the other
leaves a card Cancelled.
So `read_override` still refuses, and `--no-override` is how a caller asks
for the answer it can safely fall back to. Neither caller gets a silent
default -- the router logs a ::warning:: naming the repo, the branch and the
reason.
The fallback is proven to consult NOTHING by running it with `gh` removed
from PATH, and the same call WITHOUT the flag is asserted to refuse -- so
the flag is doing the work rather than a silent default.
The call-site assertion failed on its own first run and the fix is the
interesting part: it counted the new fallback calls and demanded a ref of
them. A `--no-override` call consults no file by definition, so demanding a
ref would demand the opposite of its purpose. It now distinguishes the two
shapes, and additionally asserts the router has a fallback in BOTH arms
while reconcile has NONE -- the per-caller policy, pinned.
47/47, all 10 selftests green, make check 41/41, actionlint and ruff clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(2243): refusing has to be a WRITE where another system supplies the default
saadqbal on .github#295, correcting my own fix from one commit ago -- and the
qualifier he adds is the part worth keeping:
"Refuse rather than guess" assumes that doing nothing is safe. At a
decision point whose default is supplied by ANOTHER system, refusing has
to be expressed AS A WRITE, not as an absence.
My first fix wrote the DEFAULT MAPPING on an unreadable override. That denies
the built-in Item-closed automation its opening, which was the point -- but it
claims a promotion happened on a read that failed, which is exactly the
"silently apply the default mapping" this PR removed everywhere else.
The router writes the non-terminal HOLDING STATE now (`On dev`) and labels the
card `override-unreadable` for the weekly pass, modelled on the sibling-merge
arm three lines down, which keeps its Status write for precisely this reason.
The holding state claims nothing: it is an explicit WE COULD NOT TELL. And it
is still a write, because the built-in automation acts on the close
independently of this workflow -- a loud red run does not protect the card,
the automation wins the race.
`--no-override` is gone from the router. It stays in the CLI because the flag
itself is right and tested; nothing calls it today, which the selftest now
asserts rather than leaving to be discovered.
THE THREE CONSUMERS NO-WRITE PATHS ARE PINNED AS A SET, because that is what
the finding is about -- three callers, three different correct answers, each
for a stated reason:
router writes the holding state + label; another system would decide
reconcile skips the item; it fixes misses, nothing else acts on its silence
advance no fallback; a push has no competing automation
Reverting one arm to the old default-mapping fallback drops the suite to 48/2,
so the distinction is checked rather than described.
50/50, all 10 selftests green, make check 41/41, actionlint clean.
backend#2243
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Aug 22, 2026
…ploys nothing can say so (#304)
* fix(2242): `Done` is a Status an override may name, so a repo that deploys nothing can say so
`rfcs` ships no artifact and has no environment, so RFC-BACKEND-1405 D8 puts its
merged work in `Done` -- completed, nothing deployed. backend#2242 is blocked on
saying that: `ENV_FOR_STATUS` declared the five DEPLOY columns, and backend#2324
(correctly) made an undeclared Status a refusal -- so the semantically right
override was the one shape the new guard rejected.
The accept list is the set of Statuses a MERGE can legitimately mean, not the set
of stages a deploy passes through. `Done` -> `none`, and `none` is a real option on
the board's `Deploy environment` field (measured on project #2, 2026-08-22), so
acceptance still carries an environment that AGREES with the Status.
Three consumers were checked before adding it, because `Done` is terminal and
nothing else in the table is:
advance-deploy-env `rank()` already scores Done 11, above Prod -- the monotonic
guard advances into it and nothing demotes out.
kanban-closure-router its Done guard refuses to write Done OVER a deploy state; a
merged PR's card sits in `Code review`, which
`classify_column` returns `no` for, so the write lands.
Both facts are already asserted by
kanban-deploy-state-selftest.py.
kanban-reconcile its weekly sweep pulls NON-TERMINAL columns only, so it
never sees these cards.
That last one was load-bearing and unasserted. Reconcile's `drift-to-prod` arm
writes `Prod` for any merged PR whose sha reached the prod branch and consults no
`.kanban.yml` at all -- deliberately, because `resolve_prod_branch` refuses to
trust a repo-controlled file (D27-L4). The sweep filter is the only thing standing
between that arm and every overridden card, so it is a machine check now:
`branch-status-map-selftest.py` parses the filter out of the workflow and asserts
no Status declaring `none` appears in it. Derived, not restated; fails loudly on a
filter it cannot parse; and the predicate is exercised in both directions so a
guard that returns [] for everything cannot pass as a satisfied invariant.
Mutation-proved by adding "Done" to that list -- 1 failed, restored.
Refs backend#2242, backend#2243, backend#2324
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(2242): the backstop can write `Done` too, and a check now says it must
Bugbot (.github#304, Medium) was right, and my comment was wrong. It said
kanban-reconcile's router-miss arm has "no option id for Done and skips" -- but
`DONE_OPT` is resolved in that very step and already written by the arm beside it,
so the skip was an omission, not a limit.
The consequence is the shape this PR would otherwise have opened rather than found:
widening the accept list without widening reconcile's `case "$DEST"` leaves exactly
the repos the override exists for with no weekly backstop. A router miss parks the
card in an active column, archive only takes terminal ones, and it stays there
forever -- the invariant .github#127 fixed for every other mapping.
Adding the arm is safe for the same reason writing DONE_OPT beside it is: the
enclosing `_skip` guard has already run `classify_column "$COL"` and required `no`,
so the card is in neither a deploy state nor an unplaceable column.
AND THE INVARIANT IS A CHECK, keyed on the OPTION ID rather than on "every declared
Status needs an arm" -- checking is what made that distinction (rule 8). Two
declared Statuses have no arm on purpose: nothing writes `Staging (agent review)`
yet (RFC-BACKEND-1552 D5, read-only until backend#1578) and `Ready for prod` is a
human `/fr-pass` act (D6), and reconcile resolves no option id for either. So the
real defect shape is "the job knows the id but cannot write it", and that is what
`branch-status-map-selftest.py` now asserts: both the arms and the resolved ids are
parsed out of the workflow, the parse fails closed with anchor assertions, and one
predicate serves the assertion and both mutation directions.
Mutation-proved by deleting the new arm (anchor verified applied, grep = 0):
173 passed, 1 failed, naming `Done`. Restored -> 174 passed, 0 failed.
actionlint clean; `make check` green.
Refs backend#2242
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(2242): the option-id extractor stopped at the first `)`, so one arm was unguarded
Bugbot (.github#304, High) again, and again right. `OPT_ID`'s non-greedy
`\$\(opt(?:_either)?\s+(.+?)\)` stops at the FIRST `)` -- and the one argument list
that matters contains one:
$(opt_either 'Staging (human review)' 'FR on staging')
OLD regex captured: ["'Staging (human review"]
So `FR on staging` was never collected. The invariant went green with that arm
deleted, and the anchor assertion did not notice, because `Done` -- which has no
parens -- was present. An under-collecting extractor reporting a clean sweep of a
SUBSET is exactly the shape `kanban-columns-check.py`'s `cross_check` exists for,
reproduced one file over, one commit after I wrote a docstring about deriving rather
than restating.
Three changes, and the third is the one that would have caught it:
* SCANNED, NOT REGEXED. `_arglist` walks the `$(...)` with a depth counter and is
quote-aware, so a `)` inside a quoted column name is part of the name. An
unterminated call returns "" -- a parse failure, not "no arguments".
* THE ANCHOR NAMES THE HARD CASE. It now requires `FR on staging` and
`Staging (human review)` -- reachable only through `opt_either` -- and >= 8 ids.
An anchor a broken parser can pass is not an anchor.
* THE MUTATION COVERS THAT PATH. Deleting the `FR on staging` arm is now a case.
With only `Done` mutated, the guard was vacuous for every parenthesised name.
Mutation-proved on the real workflow (anchor verified, grep = 0): deleting the
`FR on staging` arm gives 172 passed, 3 failed, the primary assertion naming
`FR on staging`. Restored -> 175 passed, 0 failed. `make check` green.
Refs backend#2242
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal