Skip to content

chore(promote): develop → main — wire the Done column, and give the closer lookup a token (backend#1411) - #127

Merged
LukasWodka merged 12 commits into
mainfrom
develop
Aug 3, 2026
Merged

chore(promote): develop → main — wire the Done column, and give the closer lookup a token (backend#1411)#127
LukasWodka merged 12 commits into
mainfrom
develop

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Promotes #126 — wiring the Done column into the automation (backend#1411). Until this lands none of it does anything, because all 122 callers pin @main.

Safety check done before opening this

#125 ("retire the WIP-limit nudge") merged to develop yesterday and deletes a reusable that 12 repos still reference. If that deletion were on develop, this promotion would carry it to main and every one of those 12 repos would go red — and a reviewer approving "promote the Done wiring" would be merging a reusable deletion they never reviewed.

Verified against the API, not the local clone:

developmain
wip-limit-check.yml (the reusable)presentpresent
wip-limit-caller.yml (.github's own caller)absentabsent

So #125 removed only this repo's own caller, and correctly held the reusable back until the remaining callers are gone. git log origin/main..origin/develop is exactly one commit#126. This promotion carries nothing else.

What goes live

Done / Cancelled are terminal in both rank() functions (10, above Prod's 9)Two cards already sit in Done. Before this, advance-deploy-env returned 0 for it, so the next back-merge push demoted them; fr-gate had no case, so a single Done card blocked every prod promotion carrying it.
kanban-archive archives DoneOtherwise Done cards never leave the board.
Hand-closed completed issues route to DoneImmediately, on the close event, instead of waiting for reconcile's cron. Done is not a deploy state, which is why the router's old "don't invent a deploy state here" objection no longer applies.
The closer lookup finally has a tokenIt has never once run — the step had no GH_TOKEN, so gh api graphql failed on every invocation, silently, because a failed lookup collapsed into "no closer" and that meant "leave Status unchanged". The entire closed-by-PR routing in that step has been dead; reconcile's cron has been doing all of it.

Expect this

The Done guards refuse rather than overwrite, so if a hand-closed issue sits in a deploy column you will now see a ::notice:: saying it was not set to Done. That is deliberate — see the D8 conflict written up on backend#1411, which needs a human decision and is knowingly left violating one of D8's two competing rules rather than asserting something false.

Review history worth knowing

Bugbot found four issues on #126 and all four were real: an error handler that meant "change nothing" and started meaning "assert Done"; reconcile mislabelling deployed work; the router missing the guard I had just added to reconcile; and the pre-existing missing token. Three of the four were paths that previously ended in "leave it alone" — introducing a new terminal value means every such path needs re-examining, including in files the diff never touched.

🤖 Generated with Claude Code


Note

High Risk
Changes promotion gates, closure routing, and archival behavior for every repo that pins these workflows at @main; mis-routing or a broken closer guard could hide or mis-label board state org-wide.

Overview
This promotion wires Done as a first-class terminal kanban status (RFC-BACKEND-1405 D8 / backend#1411) and adds org-wide caller-inventory drift detection (backend#1415).

Done / Cancelled as terminal (rank 10) in advance-deploy-env and fr-gate so back-merge pushes cannot demote Done cards and Done no longer blocks prod promotions. kanban-archive now archives Done alongside Prod/Cancelled.

Closure and reconcile routing change materially: kanban-closure-router gets PROJECTS_KANBAN_TOKEN for the issue closer GraphQL lookup (previously always failed), routes genuine hand-closes to Done, distinguishes ERROR / NOEVENT / NONE so API failures do not assert Done, and refuses Done when the card is already in a deploy column. kanban-reconcile stops blanket Prod for completed closed issues—uses Done only when there is no closer, derives deploy stage from the closer when the router missed, and skips when evidence is missing.

New drift guard:repo-inventory.yml (explicit required/exempt/divergent per repo), scripts/caller-drift.py, offline selftest, and .github/workflows/caller-drift.yml (scheduled + path-filtered PR checks, fail-closed audit, issue comments on failure only).

Reviewed by Cursor Bugbot for commit 5eaad72. Bugbot is set up for automated code reviews on this repo. Configure here.

feat(kanban): wire the Done column into the automation (backend#1411)
Comment thread.github/workflows/kanban-reconcile.yml
… from the column
Bugbot on .github#127, and it is the third variant of the same hole -- this time in the
guard I added for the second one.
My reconcile check used the CARD'S COLUMN as evidence for "did anything deploy". That is
circular: the column is exactly what is wrong when kanban-closure-router misses a close.
The router's closer lookup can fail, and it then deliberately leaves Status alone -- so
the card stays in Backlog or Code review while the work has actually shipped to dev.
Reconcile then read that column, saw no deploy state, and asserted Done. kanban-archive
hides it the next day. Precisely the fail-WRONG outcome the router's guard was written
to prevent, reached through the backstop instead.
Done needs TWO things true: no deploy state on the card, AND nothing closed the issue.
Reconcile now asks the second question directly instead of inferring it:
no closer -> Done
closed by PR or Commit -> SKIP; it shipped, the router owns the stage
lookup failed -> SKIP; unreadable is not evidence of absence
already in a deploy col -> SKIP, as before
Verified by extracting the decision and running the table.
Also: REPO here is the bare repository name, not owner/name, so ${REPO#*/} was correct
only by accident (no slash to strip). Uses $REPO directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(kanban): reconcile must check for a closer, not infer it from the column (backend#1411)
Comment thread.github/workflows/kanban-reconcile.yml Outdated
…the card
Bugbot on .github#127, and it is the flip side of my own previous fix.
I stopped reconcile asserting Done for a closed-completed issue that has a PR or Commit
closer but sits in a non-deploy column. That removed a false claim and created a worse
outcome: the card is closed, in an active column, and archive only takes terminal ones
-- so it stayed there permanently. That is the invariant this job exists to enforce,
broken by the job itself.
Skipping was never the right answer, because that case IS the router-miss this job
backstops. So do what the router failed to do: read the closing PR's base ref and derive
the stage from it.
no closer -> Done
closed by PR, base main/master -> Prod
closed by PR, base staging -> FR on staging
closed by PR, base develop -> On dev
Commit closer, or unknown base -> On dev (the router's own default)
lookup unreadable -> SKIP, unreadable is not evidence
already in a deploy column -> SKIP, the stage is already recorded
The mapping is character-for-character the router's, deliberately. When those two
disagree the faster one wins and the slower one silently undoes it, which was Bugbot's
third finding on .github#126 -- the same two files, the same rule, out of step.
ON_DEV_OPT and FR_STAGING_OPT were only resolved in a later step, so the classify step
could not have set those columns even if it wanted to. Declared there now.
Verified by extracting the decision and running all nine combinations rather than
reading the case arms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodkaand others added 2 commits August 3, 2026 09:35
Nothing in the org detected a missing caller. merge-settings-drift.yml reads
three booleans. kanban-reconcile.yml:436 probes a single filename to decide
board scope, so a 403 there is indistinguishable from "repo not tracked". A
caller that is simply absent produces no run, no annotation and no red check,
and the absence of a signal is not a signal — which is how eight repos drifted
unnoticed and how e2e-test-agent#1 closed without routing.
What the guard now detects that nothing did:
- a required caller missing from a repo, on the develop-first branch
- a caller pinned to anything other than @main
- a caller present where the inventory says exempt (a stale exemption)
- a caller for a reusable the inventory has never heard of
- add-to-kanban.yml / stale-backlog.yml drifted in content — they are copies,
not callers, so no other mechanism would ever notice
- a repo that joined the org, or left it, without the inventory following
- a repo whose visibility or release-train membership contradicts the
inventory, cross-checked against release-train/repos.yml
repo-inventory.yml is the source of truth: every repo carries an entry for
every reusable and every copy, and each is `required` or carries a written
reason. A missing key is a guard failure, not a default; an exemption with no
reason is rejected before any network call. Absence can never be implicit.
Matching is on the resolved `uses:` value of a parsed workflow, never on
filename. Two filename conventions are mixed within nearly every repo,
kanban-closure-router's callers match neither, and code-quality.yml:60 is a
commented-out example that a grep would count as a caller.
Fail-closed throughout: a 403, a rate limit, a truncated git tree, an
unparseable workflow, an unreadable inventory or an empty org listing each
produce a failure, never an all-clear. scripts/tests/caller-drift-selftest.py
asserts all of that offline with a stubbed gh, and the audit job needs it.
The sync job that opens PRs for missing callers is deliberately deferred.
Refs tracebloc/backend#1415, tracebloc/backend#1408
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The inventory is read from the checkout, but every repo's caller state -
including tracebloc/.github's own - is read from its audit branch over the API.
A PR that adds a caller to .github and flips that entry to `required` in the
same commit therefore fails, because the caller is not on develop yet.
That is the fail-closed direction, and reading .github's workflows from the
checkout instead would break the develop-first policy on a run triggered from
main. Documented rather than papered over, with the remedy: land the caller
first, flip the entry after.
Refs tracebloc/backend#1415
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 3, 2026
saadqbal
saadqbal previously approved these changes Aug 3, 2026
fix(kanban): reconcile derives the missed stage instead of stranding the card (backend#1411)
feat(ci): caller inventory and drift guard (backend#1415)
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f7a50e8. Configure here.

Comment thread.github/workflows/kanban-closure-router.yml
The caller-drift guard went red on its first real run — on .github#127, the
develop -> main promotion, which is the worst place to discover it:
tracebloc-website: release-train/repos.yml says on-train=True, the
inventory says False.
The guard is right. release-train#14 ("enroll tracebloc-website in the release
train") is merged, so repos.yml on main lists the repo; the inventory was
written from the pre-merge state and is one merge behind. release-train has no
develop branch, so the guard reads main there and sees the enrollment
immediately.
Nothing is actually ungated, despite the finding's wording: fr-gate.yml is
already `required` for this repo and fr-gate-caller.yml is present on develop.
Only the recorded fact was stale.
Verified by running the guard locally against the live org, before and after:
before: 1 drift finding(s) (reproduces CI exactly)
after: No drift. Every repo read, every entry matched.
both: Audited 20 of 20 on the develop-first branch
selftest: exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bugbot on .github#127, High. The closer lookup's --jq collapsed FIVE distinct
answers into the single token NONE, and NONE now means Done, which
kanban-archive hides the next day:
ClosedEvent present, closer null -> NONE genuinely hand-closed
no ClosedEvent at all -> NONE the timeline said nothing
timelineItems.nodes null -> NONE ditto
issue null -> NONE nothing was read
repository null -> NONE nothing was read
Only the first is evidence of a hand-close. The rest are absence of evidence,
and just one of them needs to happen once -- a close-event race, a null read --
for shipped work to be terminalized and archived. Command failure was already
separated out as ERROR (Bugbot's earlier finding on .github#126); this is the
same mistake one level in, in the success path.
The filter now answers three ways instead of two, so "the timeline had no
ClosedEvent" is its own token:
jq: if $e == null then "NOEVENT|" else <typename>|<baseRef> end
Both shells already fail closed on an unrecognised token, so NOEVENT lands in
exactly the right place without touching either decision:
router: not in the PullRequest|Commit|NONE allowlist -> UNREADABLE -> no change
reconcile: no NONE / PullRequest / Commit arm matches -> SKIP, no Done asserted
That is the allowlist doing its job, so the only behaviour change is that a
no-evidence read no longer masquerades as a hand-close.
Also fixes two log lines that would now lie: reconcile called NOEVENT a failed
lookup when the read succeeded and simply found nothing, and the router's
warning said "could not read" for a case where it read fine. NOEVENT gets its
own message; the router's says "could not confirm".
Verified by extracting both decision blocks and running every token through
them under bash -e:
jq filter 9 response shapes, incl. last-event-wins -> correct token each
router PR/develop|staging|main -> On dev|FR on staging|Prod
Commit -> On dev, NONE -> Done
NOEVENT, ERROR, empty -> Status unchanged + warning
reconcile NONE -> Done, PR/Commit -> stage derived
NOEVENT, ERROR -> SKIP, no Done
actionlint clean on both files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(inventory): tracebloc-website is on the release train (backend#1415)
fix(kanban): "no ClosedEvent" is not a hand-close (backend#1411)
@LukasWodka
LukasWodka merged commit ee105ce into mainAug 3, 2026
5 checks passed
LukasWodka added a commit that referenced this pull request Aug 27, 2026
…nd#2722) (#361)
* fix(kanban): a completed issue is terminal, not a deploy state (backend#2722)
Closed issues were landing in deploy columns because two mechanisms deliberately
put them there, not because either was failing.
1. kanban-closure-router.yml mirrored the closing PR's Status onto the issue, so
a PR-closed issue inherited On dev / FR on staging / Prod. Its own declared
contract said so.
2. advance-deploy-env.yml then marched those cards onward (backend#1600), because
issues parked at On dev never advanced when the code shipped and drifted
permanently -- all 20 drifted cards on 2026-08-06 were closed issues, 0 PRs.
#1600 was right that a closed issue must not be stranded and wrong about where to
put it. `Done` is terminal, so nothing can drift, and kanban-archive sweeps it off
the board daily -- which answers #1600 more completely than advancing did.
So: the router routes a completed issue to Done regardless of how it was closed,
and the closing-issue block in advance-deploy-env is removed. Both halves, because
fixing only one looks fixed while the other drags the cards straight back in.
The closer lookup goes too. It existed only to tell a hand-close from a PR-close
so they could route differently; with one destination there is nothing to tell
apart. It was also the source of two fail-wrong defects (.github#126, #127), and a
call never made cannot fail. CLOSER_TYPE / CLOSING_PR_BASE were read nowhere else.
`permission-issues` drops from advance-deploy-env with the only reads that needed it.
Checked rather than assumed: fr-gate ranks Done 11 against On dev 6, so terminal
cards satisfy both gates instead of blocking promotions.
Cost of the status quo, measured: 117 closed issues cleared out of deploy columns
by hand in one session (18 + 55 from FR on staging, 44 from On dev). Two of those
passes were functional-review batches, so the next prod payload would have read 73
items larger than the work it contained.
Guarded now, because it was not: flipping the router's destination left all 29
selftest suites green. kanban-deploy-state-selftest asserts both halves, reading
the completed arm out of the workflow rather than restating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(kanban): the weekly backstop must terminalise too (backend#2722)
Bugbot, High. The router now sends every completed issue to Done, but
kanban-reconcile.yml still derived a deploy stage from the closer`s base branch
and wrote On dev / FR on staging / Prod for exactly those cards. Fixing one
writer and not the other is worse than fixing neither: the weekly sweep would
have put every card back, the slower job silently undoing the faster one --
which is the failure .github#295`s own comment was written to prevent, arriving
from the other direction.
A deploy column is a property of a PR. A finished ISSUE belongs in Done whether
or not its fix shipped, and issue cards parked in deploy columns are what stops
kanban-archive clearing them.
The closer lookup stays, because having one still distinguishes the two cases in
the LOG -- but both arms now reach the same column, which is the rule.
Second finding, same shape one layer out: the selftest read only the router and
advance-deploy-env, so a green run confirmed the two EDITED files rather than
the invariant. It now extracts the reconcile`s closer arm too and asserts it
writes only DONE_OPT, names no deploy column, and reaches no branch->stage
mapper. Mutation-proved: restoring the On dev write reddens it.
Also removes two now-dead option ids shellcheck flagged, and the comment that
justified them, which my own change had made false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(kanban): realign the mapper selftest with a terminal completed issue (backend#2722)
The selftest already encoded #2722 for the router -- one call site, not two --
but still required kanban-reconcile.yml to keep one. Removing reconcile`s
router-miss derivation made that false, so three assertions had to follow:
* reconcile`s mapper call sites: 1 -> 0. Its only one was the closed-completed
issue arm. The merged-PR lookback still places PR cards in deploy columns but
always mapped its branches INLINE, so the count did not move because of it.
* the DEST-arm invariant is inverted rather than deleted. It asserted every
Status with an option id needed an arm, or the backstop silently skipped the
repos an override exists for (.github#304) -- true while reconcile DERIVED a
column, meaningless now there is no $DEST. It now asserts there are no arms,
plus that the extractor can still find one in a fixture, so "no arms" cannot
be confused with "the reader broke".
* the stderr-preservation loop drops reconcile, which has no stderr to preserve.
Its absence is asserted above rather than left implicit.
All 15 selftests pass and actionlint reports 0 across every workflow.
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