Status: bonsai-status-sync half COMPLETE (shipped v1.11.0, waved 2026-08-02); claude.yml half TABLED. Written: 2026-07-31 against main @ 9b70acf (tag v1.6.0 = 0a3934f).
Refreshed: 2026-08-02 against main @ a54c91e (tag v1.9.0). Three releases landed underneath the
original draft — v1.7.0 (Slack alerting), v1.8.0 (audit context + artifact leg), v1.9.0 (store-secret rename).
Every file:line citation below was re-verified again on 2026-08-02 against the v1.11.0 release branch
(templates/github/claude.yml is 477 lines there, not the 460 it was at a54c91e); every number is
recomputed. Citations are
now path-qualified, because several filenames exist in both templates/github/ (short caller stubs) and
.github/workflows/ (long reusables) with entirely different content — the original draft cited both under
one bare name.
The README has flagged this as "future work" since the repo split. This document is the scope to execute it.
APPROVED and IN PROGRESS —
bonsai-status-sync.yml(Phases 1–3). The copy-per-repo cost is real and this half was never gated on anything. Phase 1 landed 2026-08-02 (b394c6d, tagv1.10.0): the reusable is.github/workflows/bonsai-status-sync.yml(tagv1.10.0). The caller stub followed in the v1.11.0 repin (PR #26, same day) —templates/github/bonsai-status-sync.ymlis now a 67-line stub, not the 190-line copy. The fleet has NOT been waved: consumer repos still run the 190-line copy. See Phase 1 landed below, and read README release-order step 3 before waving — this is the wave its traps were written for.TABLED —
claude.yml(Phase 0 and Phases 4–8). Whether Claude App token minting survives inside a cross-repo reusable is a question for another day.claude.ymlstays a full per-repo file, and remains the kit's one drift surface. Do not start Phase 4 without re-opening this decision.DEFERRED —
identity-unification-scope.md. Not ready to drop the Claude App, so the Phase 0 spike is not retired by that project shipping first.Three open decisions were closed by this decision or by direct verification — see Open decisions below.
As of the Phase 1 conversion (2026-08-02) this argument is half-resolved: bonsai-status-sync.yml now has a
central reusable, and the kit copy dropped from 190 lines to a 67-line stub at the v1.11.0 repin. claude.yml's 477
lines remain copied — still the single largest thing in the kit, and the drift surface that outlives this
conversion. The original framing follows.
Of the 916-line kit, 650 lines (71%) were the two files copied verbatim rather than called —
claude.yml (460) and bonsai-status-sync.yml (190). The five caller stubs totalled 154 lines and are
mechanical.
At v1.6.0 this read 594 of 837 — the same 71%. The flat ratio hides the trend: over three releases the
copied-per-repo surface grew 56 lines and the called surface grew by zero, and every one of those 56
lines is in claude.yml (bonsai-status-sync.yml is byte-identical to v1.6.0). There is now also a third
copied-and-hand-edited file — shopify-tool-smoke.yml, 89 → 112 lines — which is why open decision 1 got more
expensive rather than less.
Every drift incident traces to that split. Avara shipped the prompt-hijack bug because claude.yml is
copied, so a subset of upstream changes could be hand-carried into it. The store handle needs preserving on
re-copy because it is a hand-edited line in a copied file. DRIVER_AGENTS_REF is duplicated because it lives
in copied files.
Conversion does not eliminate waves — SHA-pinned stubs still need a pin bump per change. It changes what a wave is: from re-copying 650 lines into every target while hand-preserving per-repo edits, to changing one SHA string.
Implementation drift becomes structurally impossible — there is no downstream logic left to edit. Be
precise about the scope of that claim: caller configuration drift does not disappear. SHOPIFY_STORE_NAME,
permissions:, concurrency:, and the secret mapping still live in each repo's stub and can still diverge,
and DRIVER_AGENTS_REF stays hand-edited fleet-wide for as long as shopify-tool-smoke.yml remains a copied
file (open decision 1). What conversion removes is the 650 lines of logic that a wave could hand-carry a
subset of — which is the specific failure that produced the Avara incident.
Secondary win: actions/checkout (templates/github/claude.yml:130), claude-code-action (:309) and
actions/upload-artifact (:473, added by v1.8.0) move out of templates/ and into .github/workflows/,
which .github/dependabot.yml (directory: "/") actually scans — converting three documented manual pins
into bot-managed ones.
Six claims were raised as hard blockers during research. All six were refuted under adversarial challenge.
Nothing structurally prevents this. But one undocumented behaviour decides whether claude.yml ships in this
form at all, and it must be settled before any work on claude.yml.
It does not gate the whole project. bonsai-status-sync.yml has no OIDC path and no App token, so Phases
1–3 + 5 (9–10h) are unaffected by the spike's outcome and unaffected by the identity decision. That is
exactly why they were split off and approved on 2026-08-02 while this spike was tabled. Everything below about
Phase 0 concerns claude.yml only.
Skip this entire section if
identity-unification-scope.mdships first. Without the Claude App there is no OIDC exchange to validate, and this spike has nothing to test.
claude-code-action mints the Claude App installation token by POSTing its OIDC token to Anthropic's
github-app-token-exchange. Since 2025-08-12 that endpoint validates that the workflow file is
content-identical to the version on the repository's default branch (error:
workflow_not_found_on_default_branch).
The unknown: does it validate workflow_ref (the consumer's caller stub) or also job_workflow_ref
(our SHA-pinned reusable)?
- If only the caller stub → fine. The stub always runs from the default branch, so it validates trivially.
- If also the reusable → every SHA pin becomes invalid the moment
workflows/mainadvances past the tag, and the immutable-SHA policy (decided 2026-06-17) would have to be abandoned for@mainrefs. Unacceptable for a workflow that mintscontents: write.
Evidence it does not validate the reusable (strong but circumstantial): on
anthropics/claude-code-action#443, Anthropic's
ashwin-ant shipped a cross-repo fix on 2025-08-19; SHxKM (2026-01-19) ran precisely our shape — consumer
stub → cross-repo reusable pinned to a non-default branch of another repo — and reported it "resolved once I
merged the workflow PR in the calling repository." Nobody in the thread reports a SHA-pinned cross-repo
reusable. Issue #443 is still open.
Spike design (~3–4h):
-
In
DriverDigital/workflows, add a throwaway.github/workflows/spike-claude.yml—workflow_call,id-token: write, the realclaude-code-actionstep, nothing else. -
In
vite-plugin-shopify-clean, add.github/workflows/spike-claude-caller.ymlwith the full permissions block, calling the reusable at an explicit 40-hex SHA pin —uses: DriverDigital/workflows/.github/workflows/spike-claude.yml@<SHA>— never@main. Record that SHA verbatim in the run notes, because the whole result is meaningless if the pin silently equalled HEAD. Merge to the default branch — mandatory, see "no pre-merge test path" below. Trigger the caller onissues: [opened], matching the realclaude.yml. -
Push one trivial commit to
workflows/mainfirst, so the pinned SHA is provably behindmainHEAD. This is the assertion nobody thinks to make: a pilot run at a pin that happens to equalmainHEAD passes and then breaks the fleet on the next commit tomain.Correction (2026-08-02). The original draft reassured that this condition "exists naturally," because
main@9b70acfwas then one untagged commit pastv1.6.0. That is no longer true —mainHEAD is now exactlya54c91e= tagv1.9.0, so a pin at the current tag equals HEAD and the spike would produce a false pass. The gap must now be created deliberately. Do not skip this step. -
Open one issue as
driver-digital-agentscontaining@claude. -
PASS = log shows the OIDC exchange succeeding and a
pull_requestopenedwebhook whoseuser.loginis literallyclaude[bot]. FAIL = a green run with::warning::Skipping action due to workflow validationand no PR. -
Delete both files. Record the run URL in the release notes either way.
If Phase 0 fails: convert bonsai-status-sync.yml only, leave claude.yml as a full per-repo file, and
revisit when #443 closes.
Anthropic's documented workaround on #443 is to supply your own github_token. Never do this as an ad-hoc
fix mid-pilot. It changes the PR author from claude[bot] to driver-digital-agents, which breaks the rail
split: ticketed-review's stub gates round 1 on pull_request.user.login == 'claude[bot]', and
pr-first-review gates on != 'claude[bot]'.
Correction (2026-07-31). An earlier revision of this document said such PRs would "route to the PR-first rail instead of the ticketed rail". That is wrong, and the truth is worse. The ticketed stub's
if:is false, so the reusable is never invoked — no check run at all. The PR does reachpr-first-review, whose!= 'claude[bot]'guard now passes, but itshas_ticketstep finds the Bonsai uuid and skips. Both rails end green having done nothing, and the PR is reviewed by nobody.
This is now a deliberate project, not a forbidden shortcut. Dropping the Claude App and unifying on
driver-digital-agents is scoped in identity-unification-scope.md, which
fixes the rail gates as a requirement rather than discovering them as a failure. The distinction is entirely
whether the gates move in the same change.
If that project ships first, the Phase 0 spike above ceases to exist — no App token means no OIDC
exchange, no default-branch validation, and no job_workflow_ref question. The claude.yml stub also stops
needing id-token: write, which was what made it the most privileged stub in the kit. Sequencing
identity-first is therefore the cheaper order.
1. The claude.yml stub cannot be thin in the privilege sense. A called workflow's permissions can only be
downgraded, never elevated. The stub must declare all five at workflow level:
permissions:
contents: write
pull-requests: write
issues: write
id-token: write # omit this and the identity DEGRADES rather than failing loudly
actions: readTwo distinct failure modes, both reported verbatim on #443: with no block, Could not fetch an OIDC token. Did you remember to add 'id-token: write'; with permissions in the reusable but not the caller, a hard
startup-validation error naming the exact shortfall. Make "stub declares all five verbatim" a cutover
checklist item.
2. The actor gate goes INSIDE the reusable, as jobs.claude.if:, carrying claude.yml:90-102 and the
67-89 comment block unchanged. The concern that this starts a runner is incorrect for a job-level if: —
a reusable's jobs are expanded into the caller's run and scheduled normally; a false job-level if: marks the
job skipped and no runner is provisioned, so no write-scoped token and no OAuth secret is ever materialised.
This keeps the gate in one file that a single repin updates fleet-wide, instead of a copy on each of Palmers'
8 branches. Prove the negative during the pilot: post a plain comment (no @claude) and confirm the job
shows skipped with zero runner minutes. If it unexpectedly provisions one, fall back to caller-level
jobs.<id>.if — same expression moved up one file, a 10-line stub edit, not a redesign.
3. SHOPIFY_STORE_NAME must become a with: input. A reusable-calling job may only use
name/uses/with/secrets/strategy/needs/if/concurrency/permissions — no env:, no steps:. So the current
job-level env: knob (templates/github/claude.yml:120-128, the file's only job-level env key) has nowhere
to go but with:. This fixes an active latent bug: the kit README's re-copy instruction says to preserve
Dependabot pins but says nothing about SHOPIFY_STORE_NAME, so the next wave that re-copies claude.yml into
Avara would reset "avara" to "" and the provisioning step would self-skip silently, since it is designed
to degrade quietly when unset.
Correction (2026-08-02).
SHOPIFY_STORE_NAMEnow has a second consumer. At v1.6.0 it was read only by the provisioning script (:200,:207-211,:238). v1.8.0 also interpolates it into the audit artifact name attemplates/github/claude.yml:475. Theinputs.shopify_store_namevalue must be threaded to both sites — wiring only the provisioning step leaves the artifact namedshopify-audit--<run_id>-<attempt>, which uploads successfully and is therefore another silent failure.
4. concurrency stays in the stub, workflow-level, matching all five existing stubs.
5. Status strings, the UUID regex, and the driver-digital-agents + 261291955 gate stay hardcoded in the
reusables. Making the actor gate an input would let a caller widen it.
6. Context semantics confirmed against official docs — all of these keep meaning exactly what they mean
today, because in a called workflow "the github context is always associated with the caller workflow":
github.event.*— the caller's full payload, unchanged. Already proven in-repo:.github/workflows/pr-first-review.ymlisworkflow_call-only and readsgithub.event.pull_request.*in production at:55,:56,:61(the fork guard),:80, and:113(the checkout ref). If a reusable could not see the caller'sgithub.event, the fork guard would compare an empty string and the rail would be broken on every run.Correction (2026-08-02). The original draft said this contradicts a stale "a reusable cannot see
github.event" claim independabot-report.yml's header. The actual comment is narrower than that paraphrase — it says specificallygithub.event.workflow_run— and it exists in two files:.github/workflows/dependabot-report.yml:16-17andtemplates/github/dependabot-report.yml:6. Both are still wrong (thegithubcontext in a called workflow comes from the caller, so a reusable invoked from aworkflow_run-triggered stub would see it), but fix both, and quote them accurately. Note the design — passing the context explicitly viawith:— remains defensible on provenance-auditability grounds (.github/workflows/dependabot-report.yml:47-60); only the stated justification is false.github.token— the caller repo's installation token, sogh pr view --json closingIssuesReferencesresolves unchanged.vars.BONSAI_URL— resolves against the caller's repository variables. Already proven byvars.PR_REVIEWER_HANDLEinsidepr-first-review.yml.secrets: inheritworks same-org and passes org + repo secrets. Explicitly mapping a secret not declared in the callee is an error.
7. Required-check contexts rename: claude → claude / claude, sync → sync / sync. Silent if any
repo has one pinned as a required check. Confirm none do before the wave.
.github/workflows/bonsai-status-sync.yml — zero inputs:
on:
workflow_call:
secrets:
BONSAI_BEARER_TOKEN: { required: false } # see open decision 4.github/workflows/claude.yml:
on:
workflow_call:
inputs:
shopify_store_name: { type: string, required: false, default: '' }
secrets:
CLAUDE_CODE_OAUTH_TOKEN: { required: true }
AGENTS_GH_PAT: { required: true }
DRIVER_ENGINEERING_APP_CLIENT_ID: { required: false }
DRIVER_ENGINEERING_APP_CLIENT_SECRET: { required: false }
SHOPIFY_STORE: { required: false }
SHOPIFY_ALERT_WEBHOOK: { required: false }SHOPIFY_ALERT_WEBHOOK is the trap in this block. Added by v1.7.0 (templates/github/claude.yml:196, the
#driver-agents-status Slack webhook), it is an org-level secret — so under an explicit secrets: map
it is not automatically visible to the called workflow and the stub must pass it or use secrets: inherit.
Omit it and the guard at :244 ([ -n "$SHOPIFY_ALERT_WEBHOOK" ]) simply takes the other branch: Slack
alerting on destructive Admin API calls goes silently off fleet-wide, no error, green run. Interacts
directly with open decision 2.
All store secrets must stay required: false — the script's empty-string early-exit at :200-202 is exactly
what lets non-store repos self-skip. (The client-id/secret pair was renamed from DRIVER_AGENTS_SCOPES_* by
v1.9.0; live names at :177-178.)
DRIVER_AGENTS_REF moves into the reusable (a win — it removes half the two-files-must-match hazard).
ANTHROPIC_API_KEY stays comment-only (:316).
On declaring secrets — the claim is true, but scope it precisely. Every secret referenced by a workflow
in this repo's .github/workflows/ must be declared under its own on.workflow_call.secrets, or
lint.yml goes red. This was challenged on review as false, on the grounds that secrets: inherit makes
undeclared secrets resolve at runtime. That is true at runtime and irrelevant to the lint gate: actionlint
types the secrets context of a workflow_call workflow from that file's own declaration block and never
sees the caller, so inherit cannot suppress the error. Reproduced against the pinned actionlint 1.7.12
(.github/workflows/lint.yml:40, invoked at :64-65):
property "not_declared" is not defined in object type {…} → exit 1. The repo already demonstrates the
split: templates/github/pr-first-review.yml:27 is secrets: inherit, yet the reusable it calls still
declares both secrets at .github/workflows/pr-first-review.yml:42-44. The constraint does not apply to
templates/github/*.yml, which are caller stubs with no workflow_call trigger and therefore an untyped
secrets context.
v1.8.0 added an audit-artifact upload (templates/github/claude.yml:471-477, mirrored at
templates/github/shopify-tool-smoke.yml:106-112). The step itself moves into a reusable unchanged —
always(), env.* read from $GITHUB_ENV, and upload-artifact's own ACTIONS_RUNTIME_TOKEN auth are all
unaffected by workflow_call. Two things do change:
env.SHOPIFY_STORE_NAMEin the artifact name must becomeinputs.*— see design decision 3 above.- A called workflow does not get its own run id.
github.run_idandgithub.run_attemptresolve to the caller's run. That is the desirable outcome for the collector — the artifact lands in the consuming repo's run, where the box's nightlyaudit-publish.shalready looks. But it degrades the collision guard the file calls load-bearing at:466-469:run_id+run_attemptno longer disambiguate jobs within one run. What makes that safe today is simply thatclaude.ymldeclares exactly one job (jobs.claude,:65-66) — not the concurrency group at:61-63, which serializes runs within a group and says nothing about jobs inside a run. Conversion removes that structural guarantee: call the reusable from two jobs in one caller workflow, or matrix it, and you get two uploads with a byte-identical name — the second fails, because artifacts are immutable. Write that constraint into the reusable's header.
The Phase 0 spike gates claude.yml only. bonsai-status-sync.yml has no OIDC path and no App token, so
nothing about the spike's outcome bears on it. If Phase 0 fails, Phases 1–3 still ship. The phase table below
is ordered by dependency, not by gating — do not read Phase 0 sitting at the top as a global hold.
bonsai-status-sync.yml goes first because it is the only one of the two with any pre-merge test path at
all. It has no OIDC path and one secret (BONSAI_BEARER_TOKEN,
.github/workflows/bonsai-status-sync.yml:175 — it moved out of the kit file at the v1.11.0 repin; the stub
now just forwards it).
Be precise about how much of it is provable pre-merge, because it is one leg of three. Its on: block
(templates/github/bonsai-status-sync.yml:24-33) carries issues: [opened], pull_request: [opened, reopened, ready_for_review, synchronize], and
pull_request_review: [submitted]. Only the pull_request leg runs from the PR merge ref and so tests
itself on its own cutover PR. By the same default-branch-only rule that strands claude.yml (see below),
issues and pull_request_review are inert on a cutover branch — and the issues leg is where the @claude
grep and the actor gate live (.github/workflows/bonsai-status-sync.yml:139-141), which is the logic most worth piloting. Plan to validate those
two legs after merge, and say so in the cutover PR body; do not let "testable pre-merge" imply the whole
file was exercised.
Its template is still byte-identical to the v1.6.0 draft — sha256
811148d08592919cfa19d202a68f0e54c705845ba8110cb42b354339310f39a0, unchanged across 0a3934f, 9b70acf, and
a54c91e. It has no workflow- or job-level env: at all, so it needs zero inputs, and it is the one file
in this scope whose starting state is genuinely known. Only the template side of that is verifiable from
this repo, though: the claim that the deployed fleet copies match was verified at authoring time and is not
re-verified here — re-run tools/fleet-pin-audit.sh before the wave rather than trusting this line.
claude.yml has no pre-merge test path, and this must be written into the cutover PR body. Two rules
stack: (a) issues, issue_comment, pull_request_review, pull_request_review_comment only trigger from
the default branch, so a stub on a cutover branch is inert; (b) Anthropic's exchange requires the running
file to match the default-branch version, so forcing a run returns the 401. A reviewer who tries the obvious
thing — @claude on the cutover PR — will see a red run and wrongly conclude the conversion is broken. Say
so in the PR body. Merge on review of the diff alone; validate after merge.
| Phase | Work | Est. | Status |
|---|---|---|---|
| 1 | Convert bonsai-status-sync.yml + stub + docs + lint |
3h | done 2026-08-02 |
| 2 | Pilot it (only the pull_request leg is testable pre-merge — see Sequencing) |
2h | done 2026-08-02 — passed |
| 3 | Fleet wave for bonsai-status-sync — 18 repo@branch pairs across 11 repos |
3–4h | done 2026-08-02 — waved 21 targets |
| 5 | Tag + repin the kit stubs (README's mandatory 3-step release order) | 1h | done 2026-08-02 — tag v1.11.0 + PRs #26/#27 |
| Approved subtotal | 9–10h | COMPLETE 2026-08-02 — shipped as v1.11.0 |
|
| 0 | Spike: go/no-go on OIDC-in-reusable | 3–4h | tabled |
| 4 | Convert claude.yml — move the 477 lines faithfully |
7–9h | tabled |
| 6 | Pilot claude.yml with the four assertions incl. pin-vs-HEAD |
4–6h | tabled |
| 7 | Fleet wave for claude.yml, same 18 pairs (10 single-branch repos incl. Avara → Palmers ×8) |
4–5h | tabled |
| 8 | Optional: convert shopify-tool-smoke.yml |
2–3h | tabled |
| Tabled subtotal | 20–27h |
Shipped: .github/workflows/bonsai-status-sync.yml, the reusable. Its jobs: body is byte-identical to
the old copy except one added comment; actionlint + shellcheck clean; BONSAI_BEARER_TOKEN declared
required: true. Also a new lint.yml guard that fails the build on any kit stub carrying a placeholder pin.
Deferred out of Phase 1, landed at the repin: the caller stub. A new reusable's stub cannot be pinned
until the tag containing that reusable exists, so it landed in step 2 of the release order, not here. This is
the house precedent — dependabot-keep-current's reusable landed in c362604 and its stub arrived later
already carrying a real SHA. Landed 2026-08-02 in PR #26, and repinned to 90f0d06 = v1.11.0;
the kit now installs the 67-line stub. The fleet still runs the 190-line copy until the wave.
The stub as landed (it replaced templates/github/bonsai-status-sync.yml wholesale; the pin below is the
real one, not a placeholder — kept here as the canonical source for future conversions):
name: Bonsai status sync
# CALLER STUB — install into a pipeline repo's .github/workflows/.
# Calls the central bonsai-status-sync reusable, which flips the linked Bonsai
# task's status off the GitHub issue/PR lifecycle. Passes ONE secret explicitly,
# BONSAI_BEARER_TOKEN (org-level) — never `secrets: inherit`; see the secrets block.
#
# This REPLACED a 190-line per-repo copy (converted 2026-08-02). The status
# machine, the actor gate, the linkage logic and the cascade caveat now live in
# ONE file — see DriverDigital/workflows/.github/workflows/bonsai-status-sync.yml.
# Nothing below is repo-specific: this stub is byte-identical across the fleet.
#
# WHAT STAYS HERE, AND WHY:
# • the TRIGGERS — a called workflow cannot declare `on:`, so the event
# subscription is necessarily the caller's. Keep the `types:` lists exactly
# as written; they are load-bearing (see the reusable's header for the
# dismissed-review edge and the draft guards).
# • `permissions:` — a called workflow's permissions can only be DOWNGRADED by
# the caller. Declare all three: omitting the block on a read-only-default repo
# yields a silent `startup_failure` (no check run, no notification), not a 403.
# • `concurrency:` — see the block itself; the rationale is load-bearing and
# is NOT the same as pr-first-review's. Do not "harmonise" them.
on:
issues:
types: [opened]
pull_request:
# 'reopened' is included so a closed-then-reopened non-draft PR re-asserts Internal Review
# (a bare reopen fires neither 'opened' nor 'synchronize' — without it the flip is silently
# dropped). The draft guard in the reusable still keeps a reopened draft PR as WIP.
types: [opened, reopened, ready_for_review, synchronize]
pull_request_review:
types: [submitted]
permissions:
contents: read
issues: read
pull-requests: read
# One status flip per ref at a time — a rapid push/review burst can't race conflicting writes
# onto the same task through the single shared browser lock; the backend browser lock serializes
# the actual writes. Deliberately per-ref, NOT repo-wide: with cancel-in-progress:false GitHub
# keeps only ONE pending run per group and cancels the prior pending one, so a repo-wide group
# would let an unrelated task's flip silently cancel another's during a burst. Cross-ref ordering
# (a late issue->In Progress landing after a PR->Internal Review) is a narrow window — the issue
# event precedes the PR by the whole implementation time — and self-heals: the next PR event
# re-flips.
# DO NOT set cancel-in-progress: true here to match pr-first-review's stub — that rail reviews
# once and is safe to cancel; this one WRITES STATUS and a cancelled flip is a dropped write.
concurrency:
group: bonsai-status-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
jobs:
sync:
# NOTE: the required-status-check context for this job is `sync / sync` (caller job id /
# reusable job id), NOT the bare `sync` it was as a full workflow. Verified 2026-08-02 that
# no branch in the org pins either, so this rename breaks nothing — re-check before adding one.
uses: DriverDigital/workflows/.github/workflows/bonsai-status-sync.yml@90f0d066c140c356c44d5b3d83d795c0256a7b82 # v1.11.0
# Explicit, NOT `secrets: inherit`. Two reasons: (1) `inherit` passes whatever set exists, which
# defeats the reusable's `required: true` — a missing secret would reach the curl and surface as
# an opaque 401 on the first real flip instead of failing at startup; (2) least privilege — this
# rail needs one secret, and `inherit` would hand a centrally-pinned file that fires on
# `issues: [opened]` every org secret the repo holds (AGENTS_GH_PAT, CLAUDE_CODE_OAUTH_TOKEN,
# the store credentials). Matches ticketed-review.yml's stub, which enumerates for the same reason.
secrets:
BONSAI_BEARER_TOKEN: ${{ secrets.BONSAI_BEARER_TOKEN }}Then update these three, which are correct today and become wrong the moment the stub lands:
templates/github/README.md— movebonsai-status-sync.ymlfrom Full workflows to Caller stubs, and rewrite onboarding step 5 ("Confirm the board strings … update them here") — after the swap the status strings live only in the central reusable, so a board rename is a kit release + fleet repin, not a local edit.README.md— the kit paragraph, and "Repin the five caller stubs" → six..github/workflows/lint.ymlheader — "six reusables" is already correct; check the count again if another lands.
Everything else in the conversion was verified statically. vars.BONSAI_URL was not: the claim that a
repository variable resolves against the caller rests on vars.PR_REVIEWER_HANDLE working inside
.github/workflows/pr-first-review.yml:192, which only proves it does not error — if no repo has ever set
that variable, only the untaken fallback branch has run. If the assumption is wrong, vars.BONSAI_URL
resolves against DriverDigital/workflows (where it does not exist) and silently falls back to the hardcoded
default — and that default now lives in ONE file, so a tunnel move would break all 18 targets together with
the per-repo escape hatch being the untested path.
Assertion: on the pilot repo, set BONSAI_URL as a repository variable to a deliberately bogus host and
confirm the run goes red. Thirty seconds, and it converts the assumption into evidence.
RESOLVED 2026-08-02 — the assumption held. Run log on
vite-plugin-shopify-clean:BONSAI_URL: https://pilot-bogus-host.invalidthencurl: (6) Could not resolve host, exit 6. The variable resolves against the caller, so the per-repo tunnel override survives the conversion. Leg 1 onfoundrae-blackridge@stagingseparately proved a private consumer resolves the public cross-repo reusable and reads the caller's event payload (event=pull_request action=opened draft=false -> status=Internal Review).Two design corrections worth carrying into the
claude.ymlpilot, both recorded infleet-operations.md: theissuesleg is not pilotable (its@claudegrep is mirrored byclaude.yml's gate, so tripping it wakes a real implementer on a client repo), andclosingIssuesReferencesonly populates for PRs targeting the default branch — so a scratch-base PR dodges the theme deploy but resolvesuuid=<none>and passes green having tested nothing. Assert on which host the log names, not on the run colour: a wrong-way resolution falls back to the hardcoded default and fails too.
Phase 5 moved up. It was written as "tag + repin" after the claude.yml conversion, but the
bonsai-status-sync half needs its own tag and repin to be usable at all — the new stub ships with a
placeholder pin. Do it as part of the Phase 3 wave, not after it.
If identity unification ships first, Phase 0 disappears and the total is 26–33h. And Phases 1–3 (8–11h) depend on neither Phase 0 nor the identity decision — that portion is startable now.
Estimates grew on the 2026-08-02 refresh: Phase 4's payload is 477 lines rather than 404 (6–8h → 7–9h) and
Phase 8's shopify-tool-smoke.yml went 89 → 112 lines (2h → 2–3h). The v1.6.0 table also stated 28–36h while
its own max column summed to 35.
Phase 4 note: 64% of claude.yml is comments (308 of 477 lines — it was 67% at v1.6.0), and they are the
institutional memory — the 2026-06-19 actor-gate incident, the persist-credentials 403 on private repos, the
foundrae #148 prompt-hijack, the Avara #143 install blip. Budget for moving them faithfully, not cut-and-paste.
This section was written for
claude.yml. For thebonsai-status-syncpilot (Phase 2), run the legs in the OPPOSITE order — private first. The public-first reasoning below is specific toclaude.yml's App-token path and itspersist-credentialsprivate-fetch failure, neither of which exists inbonsai-status-sync.yml. What is untested for this file is a private consumer resolving a public cross-repo reusable, and 10 of the 11 target repos are private — sofoundrae-blackridge (staging)is the leg that can actually fail, andvite-plugin-shopify-cleanis the cheap confirmation afterwards. The "do not pilot here" list below still applies for a different reason: those repos are fine for this rail (it does not needclaude[bot]history), but keep the pilot on the designated test beds anyway.Assertions for this rail are in Phase 2 pilot above — the
vars.BONSAI_URLred-run check is the one that converts the last assumption into evidence. The four assertions below areclaude.yml's and do not apply.
Leg 1: vite-plugin-shopify-clean. Public, single main, both files installed, 7 prior claude[bot]
PRs so the App-token path is already proven there, no client and no store secrets, and public logs are
readable without access friction.
Leg 2: foundrae-blackridge (staging). Private, 4 prior claude[bot] PRs, already the designated live
test bed. Only this leg exercises the private-repo fetch path — the persist-credentials note records
that stripping the credential "works on a PUBLIC repo (anonymous fetch) but 403s on a PRIVATE repo", so a
public-only pilot is a false pass for that specific failure.
Do not pilot in plugins, client-workspaces or studio-sulzer: all three carry the full kit but have
zero claude[bot] PRs ever, so you cannot tell a conversion failure from a repo that never had the App
installed. Avara is the worst first choice (only non-uniform file, plus real long-lived store credentials);
Palmers second-worst (8 branches, one claude[bot] PR ever).
The four assertions — all mechanical, all required:
- App token — log contains the OIDC exchange succeeding, and the PR webhook's
user.loginis literallyclaude[bot]. Not "a PR exists", not a prefill link, notapp/claudefromgh pr view. - Cascade — a
bonsai-status-syncrun exists whose triggering event ispull_request/opened, and the Bonsai task reads Internal Review. - No double-fire —
gh run list --workflow=claude.ymlshows exactly one run per@claudeevent, and exactly oneclaude[bot]PR per issue. - Pin-vs-HEAD — the pinned SHA is provably behind
workflows/mainHEAD, and assertion 1 still passes.
Cutover hazard (double-fire): the stub keeps the same filename (.github/workflows/claude.yml), so
the old full workflow is replaced, not accompanied. That makes double-firing structurally impossible rather
than merely avoided — but assert it anyway (assertion 3).
Rollback: all four claude.yml triggers are default-branch-only, so a revert has no effect until it
merges — and the kit mandates a human approver on every consuming default branch. So rollback is a review
round-trip on a client repo, not a push. Pre-stage it: keep the pre-cutover content on a branch named
rollback/claude-yml-pre-reusable in each target so the revert is a one-click PR. Name the out-of-hours
approver per repo. The strongest safety property of the same-filename design: the old full workflow is
entirely self-contained and depends on nothing in this repo, so revert is complete and instant once merged.
- Convert
shopify-tool-smoke.ymlin the same wave — or make thelint.ymlassertion mandatory. This is no longer the optional add-on the first draft described. The twoDRIVER_AGENTS_REFpins are currently in lockstep (templates/github/claude.yml:191andtemplates/github/shopify-tool-smoke.yml:46, both4d633714ce0a3c9bf7ec87cfcfb8b13ceaf8240cas of the 2026-08-02 bump), and the invariant is written into the file as "keep in lockstep withclaude.yml'sDRIVER_AGENTS_REFin this same repo." Convertingclaude.ymlalone breaks that by construction — the reusable would pin centrally while the smoke test pins whatever the last fleet wave copied. The file also carries its own hand-edited job-levelSHOPIFY_STORE_NAME(:31), so it is a third copied-and-hand-edited file, not merely a third pin site. Converting it costs ~2–3h (workflow_dispatch-only, ~15-line stub). If cut: the one-linelint.ymlassertion that the two values match stops being a nice-to-have and becomes the only thing preventing silent divergence. secrets: inheritvs explicit mapping for theclaudestub — pick which silent failure you would rather not have, and say why in the PR body. Not a stylistic toss-up: with an explicit map the org-levelSHOPIFY_ALERT_WEBHOOKis not automatically visible to the called workflow and must be listed, and forgetting it disables Slack alerting fleet-wide on a green run (see Proposed interfaces).inheritmakes that omission impossible and matchespr-first-review; explicit makes theANTHROPIC_API_KEYprecedence trap impossible.Reconcile the target count.CLOSED 2026-08-02 — verified against the org. All three numbers were right; they count different things. Use these definitions and put them next to the number:- 18 repo@branch pairs, across 11 distinct repos, carry
bonsai-status-sync.ymlon a long-lived branch. This is the Phase 3 wave size. Palmers contributes 8 of the 18. - 21 is the repin-wave target list (what
tools/fleet-pin-audit.shenumerates) — it additionally countsTeam-Laird@develop,The-Gathery@developanddriver-bonsai-mcp@main, which carry only thepr-first-reviewstub and neither of the two full workflows. That is the numberidentity-unification-scope.mdcorrectly uses. - The pilot section's "10 single-branch repos → Avara → Palmers ×8" was off by one: there are 10 single-branch kit repos including Avara, so 10 + 8 = 18.
claude.ymlsits on exactly the same 18 pairs — verified branch-by-branch across all 618 org branches, zero rows where one file is present without the other. A wave touching one can touch both.
- 18 repo@branch pairs, across 11 distinct repos, carry
CLOSED 2026-08-02 —BONSAI_BEARER_TOKEN:required: falsevsrequired: true.required: true. It is an org-level Actions secret available to every consuming repo, so there is no legitimate caller that installs this rail without it.required: truefails at startup validation naming the shortfall;required: falsewould let the run reach thecurland surface as an opaque 401 mid-run. Also matchesticketed-review. One line to flip if that reasoning ever stops holding.- Confirm the Claude GitHub App is installed on all kit repos, not just the 4 with prior
claude[bot]PRs. If it is missing inplugins/client-workspaces/studio-sulzer, they fail on their first real ticket after the wave and it gets blamed on the conversion. Applies to the tabledclaude.ymlhalf only — not a blocker for Phases 1–3. Confirm no repo pinsCLOSED 2026-08-02 — none do, so theclaudeorsyncas a required status check.sync→sync / syncrename breaks nothing. Verified rather than assumed: all 42 protected branches across the 15 kit-touching repos were checked. 36 have norequired_status_checksblock at all; 6 have the block withstrict: truebut bothcontexts: []andchecks: [](the newerchecks[]array was checked too — acontexts-only query would have missed a modern pin). Org rulesets are empty, and the single repo ruleset (vite-plugin-shopify-clean) has only a Copilot-review rule. Two live kit branches are unprotected entirely —studio-sulzer@mainandTeam-Laird@develop— which is worth knowing independently of this question.- Pin policy: immutable SHAs vs a moving
@v1tag for this first-party repo. A moving tag removes waves entirely, at the cost of deleting the only staging gate between a merge tomainand the fleet. Genuine tradeoff — decide deliberately, do not drift into it.
Researched 2026-07-31 by five parallel agents across: the OIDC/App-token path, event and gate semantics, the
workflow_call interface, bonsai-status-sync specifics, and migration risk. Every hard-blocker claim was
then put to an adversarial challenge agent instructed to refute it; all six were refuted. Doc claims are
sourced to official GitHub Actions docs, anthropics/claude-code-action source at the pinned SHA
be7b93b1907a4abad570368f3c74b6fe3807510b, issue #443, and this repo's own files.
Refreshed 2026-08-02 against main @ a54c91e (v1.9.0), after three releases landed underneath the draft,
and re-verified again the same day against the v1.11.0 release branch — the bonsai-status-sync stub
conversion and the DRIVER_AGENTS_REF + tripwire commits both moved claude.yml line numbers after that
first pass. Every in-repo file:line citation was re-read and every arithmetic claim recomputed; citations are
now path-qualified. Five findings from the CodeRabbit review of PR #21 were adopted and one rejected on
evidence — see On declaring secrets above. The external citations into anthropics/claude-code-action
were not re-verified; they remain as originally researched at the pinned SHA.