diff --git a/README.md b/README.md index 959a41f..cb90628 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,53 @@ rail) → `v1.5.1` (drop the `gh`-based author re-check that skipped every real (`allowed_bots: claude[bot]`, so the bot-opened round 1 actually reviews) → `v1.5.3` (always-latest resilient Claude Code self-install in the three agent reusables) → `v1.5.4` (`dependabot-validate`: npm-install fallback for lockfile-less repos + `actions/checkout` v7) → `v1.5.5` (claude-code-action -1.0.161 → 1.0.168 in the agent reusables) → `v1.6.0` → `v1.7.0` → **`v1.8.0`** (all below). -`v1.3.0` was never tagged. +1.0.161 → 1.0.168 in the agent reusables) → `v1.6.0` → `v1.7.0` → `v1.8.0` → `v1.9.0` → `v1.10.0` → +**`v1.11.0`** (all below). `v1.3.0` was never tagged. + +### `v1.11.0` (`90f0d06`, 2026-08-02) + +Waved to all 21 repin targets on 2026-08-02; fleet uniform, 108 pins, zero stale. + +- **`bonsai-status-sync.yml` conversion completed.** The 190-line per-repo copy became a 67-line + caller stub — the status machine, actor gate, linkage logic and cascade caveat now live in one + central file. The reusable itself landed one tag earlier (see `v1.10.0`), so the conversion spans + the two tags: a new reusable's stub cannot be pinned until the tag containing it exists. +- **kit `claude.yml` + `shopify-tool-smoke.yml`:** `DRIVER_AGENTS_REF` → `4d63371`. The previous pin + `0bbb125` predated `graphql_guard.py`, so every fleet runner executed `admin-graphql.sh` with no + fail-closed allowlist and the Driver Engineering scope grant was the only control on destructive + mutations. +- **Shopify operator tripwire** appended to `claude.yml`'s static `--append-system-prompt`, pairing + with that wrapper. The blockquote is copied verbatim from driver-agents + `docs/agent-instructions-shopify.md` (canonical — edit there first); a non-canonical kit-side + lead-in precedes it, un-scoping the block from the conduct rules above and telling the model how to + report a trip on a rail that cannot set a job exit code. +- **this repo's own CI:** `lint.yml` gained a tokenization guard asserting `claude_args` holds + exactly four single quotes and the system prompt contains no apostrophe or `$`. One apostrophe + typed into canonical upstream silently *truncates* the prompt — `shell-quote` does not throw, every + flag still parses, and the wave would copy the truncated prompt fleet-wide green. +- **Piloted before the wave:** `vars.BONSAI_URL` proven to resolve against the caller, so the + per-repo tunnel override survives the conversion. See + [`docs/fleet-operations.md`](docs/fleet-operations.md). + +### `v1.10.0` (`b394c6d`, 2026-08-02) + +- **New sixth reusable: `.github/workflows/bonsai-status-sync.yml`.** Its `jobs:` body is + byte-identical to the old per-repo copy except one added comment. Deliberately shipped without its + caller stub — see `v1.11.0`. +- **`lint.yml` placeholder-pin guard:** fails the build on any kit stub still carrying an all-zero + pin, so a stub that cannot resolve can never reach `main`. +- Never waved on its own; superseded by `v1.11.0` two commits later. + +### `v1.9.0` (`a54c91e`, 2026-08-01, kit-only) + +- **Store app secrets renamed** `DRIVER_AGENTS_SCOPES_CLIENT_ID/_SECRET` → + `DRIVER_ENGINEERING_APP_CLIENT_ID/_SECRET`, tied to the per-org "Driver Engineering" app that + replaced "Driver Agents Scopes" (retired 2026-08-01). Waved to all 21 targets; Avara's smoke test + green on the new names, old-name secrets deleted. +- **Reusables unchanged.** Note this release **never got its kit repin commit** — `templates/` sat at + `v1.8.0`'s SHA while the deployed fleet was waved to `a54c91e`, leaving the fleet a release *ahead* + of the kit templates until `v1.11.0` closed it. That gap is invisible to + `tools/fleet-pin-audit.sh`; see [`docs/fleet-operations.md`](docs/fleet-operations.md). ### `v1.8.0` (2026-07-31, kit-only) @@ -140,13 +185,13 @@ npm-install fallback for lockfile-less repos + `actions/checkout` v7) → `v1.5. wave script rewrites `templates/github/` → `.github/workflows/`, so assert the rewritten diff touches no destination path twice before applying — otherwise the reusable can land in a client repo *as* the workflow, where it is `workflow_call`-only, fires on nothing, and looks green. - - **This wave only — the five pre-existing stubs' pin hunks will not apply.** They patch from - `80c35fe` (`v1.8.0`), but every deployed stub holds `a54c91e` (`v1.9.0`): `v1.9.0` shipped without - a kit repin commit while the 2026-08-01 wave repinned the fleet anyway, so **no kit revision has - ever carried `a54c91e` in a pin line** and no diff base produces a matching `-` line. `git apply` - rejects all five on target #1. Apply the kit diff restricted to - `templates/github/bonsai-status-sync.yml` (a whole-file replacement) and let the wave script's - existing `sed` repin handle the other five pin lines. + - **Sed pin lines; never `git apply` them.** A pin hunk patches from whatever SHA the kit held, + which is not necessarily what the fleet holds — at v1.11.0 the kit diff patched from `80c35fe` + (v1.8.0) while every deployed stub held `a54c91e` (v1.9.0), a SHA no kit revision had ever + carried in a pin line, so no diff base produced a matching `-` line and `git apply` would have + rejected all five files on target #1. + - Wave mechanics, the guards worth keeping, and what the pin audit cannot see: + [`docs/fleet-operations.md`](docs/fleet-operations.md). **Template pins are manual.** `.github/dependabot.yml` uses `directory: "/"`, which only scans `.github/workflows/` — nothing will ever bump an action pin inside `templates/`. Check diff --git a/docs/fleet-operations.md b/docs/fleet-operations.md new file mode 100644 index 0000000..3387fa1 --- /dev/null +++ b/docs/fleet-operations.md @@ -0,0 +1,153 @@ +# Fleet operations — waves, pilots, and what the audit cannot see + +How kit changes actually reach the fleet, and the traps that have bitten. The **authoritative +release sequence is [`README.md`](../README.md) → *Release + repin order*** — this document does not +restate it. What lives here is the operational knowledge around it: how a wave is executed, what a +pilot can and cannot prove, and where the drift detector is blind. + +Written 2026-08-02 from the v1.7.0 → v1.11.0 waves. + +--- + +## The fleet + +**21 repo@branch pairs**, and the split matters because two different numbers are correct depending +on the question: + +| Set | Size | What it is | +|---|---|---| +| **Repin-wave targets** | **21** | Every pair carrying any kit caller stub. What `tools/fleet-pin-audit.sh` enumerates, and what a pin-only wave must cover — miss one and `--stale` never reads clean. | +| **Full-kit targets** | **18** | Pairs carrying `claude.yml` *and* `bonsai-status-sync.yml`. Verified branch-by-branch across all 618 org branches: zero rows where one is present without the other, so a wave touching one can touch both. | +| **Difference** | **3** | `Team-Laird@develop`, `The-Gathery@develop`, `driver-bonsai-mcp@main` — stub rails only, neither full workflow. They still need the pin repin. | + +Palmers contributes **8** of the 18 (one per country branch: `main`, `-au`, `-ca`, `-in`, `-ma`, +`-me`, `-sa`, `-uk`); the other 10 are single-branch repos including Avara. + +**Avara is the only provisioned store repo** — the only pair carrying `shopify-tool-smoke.yml`, and +the only one whose `claude.yml` has a non-empty `SHOPIFY_STORE_NAME`. + +--- + +## Waves are direct pushes, not PRs + +Decided at the v1.7.0 wave (2026-07-31) and used for every wave since. A mechanical, +centrally-reviewed kit change is pushed **straight to each branch with `[skip ci]` in the commit +message**, rather than opening 21 PRs. + +Why: + +- **Zero review runs.** 21 PRs would each fire `pr-first-review` and burn quota on a change that was + already reviewed centrally. +- **Zero theme deploys.** Recon found `develop`/`staging` deploy workflows on ~10 fleet branches that + a bare push *would* have fired. `[skip ci]` suppresses them. +- Branch protection does not enforce for admins (`enforce_admins: false` fleet-wide), so the push + lands as Maria without a review round-trip. + +**Reserve PR waves for changes that genuinely want per-repo review.** A kit change that is +byte-identical everywhere does not. + +`[skip ci]` suppresses workflow triggers but **not** GitHub's own "Dependabot Updates" scheduler — +seeing one of those fire after a wave is expected and benign. + +### Execution shape + +One atomic commit per branch via the Git Data API (blobs → tree → commit → ref patch), not one +commit per file. Per target: + +1. `claude.yml` ← kit version, with the repo's own `SHOPIFY_STORE_NAME` restored. +2. `bonsai-status-sync.yml` ← kit stub, **whole-file replacement**. +3. The other five stubs ← **sed the pin line only**, so any per-repo edit survives. +4. `shopify-tool-smoke.yml` (Avara only) ← kit version, store handle restored. +5. `actionlint` every file about to be written, then commit `[skip ci]` and patch the ref. + +Guards worth keeping in any wave script: assert no destination path is written twice, assert the +store handle survived, assert no stale pin remains, and dry-run the whole fleet before writing +anything. + +--- + +## Three traps + +**1. Same basename in both halves of the diff.** When a full workflow becomes a stub, the kit diff +carries `templates/github/.yml` *and* `.github/workflows/.yml`. The wave rewrites +`templates/github/` → `.github/workflows/`, so both collapse onto one destination. Apply them +blindly and the *reusable* can land in a client repo **as** the workflow — where it is +`workflow_call`-only, fires on nothing, and looks green. Assert no destination is touched twice. + +**2. Pin hunks patch from a base the fleet was never on.** At v1.11.0 the kit diff patched from +`80c35fe` (v1.8.0) while every deployed stub held `a54c91e` (v1.9.0) — because v1.9.0 shipped +without a kit repin commit even though the wave repinned the fleet. No kit revision had *ever* +carried `a54c91e` in a pin line, so no diff base produced a matching `-` line and `git apply` would +have rejected all five files on target #1. **Sed the pin; don't patch it.** + +**3. Per-repo state that must survive.** `SHOPIFY_STORE_NAME` in `claude.yml` and +`shopify-tool-smoke.yml`, and any Dependabot-bumped action pins. Surveyed at v1.11.0: the fleet's +`claude.yml` copies were byte-identical to the kit except Avara's store handle, and there was no +Dependabot drift — but survey, don't assume. + +--- + +## What the pin audit cannot see + +`tools/fleet-pin-audit.sh` greps only +`DriverDigital/workflows/.github/workflows/@` and compares the SHA to the latest tag. +Three consequences: + +- **A file with no `uses:` line is invisible.** An unconverted 190-line copy has none, so the audit + cannot tell a repo that was skipped by a stub conversion from one that never carried the file. +- **Content is never compared.** `DRIVER_AGENTS_REF` is a raw SHA in an `env:` block, and the + system-prompt text is just text. A fleet running kit content from no tag reports clean. +- **The reference itself can drift.** The audit compares against the latest *tag*, never against + `templates/`. When those disagree the audit reports uniform while real drift sits in the source of + truth — which is exactly how the v1.9.0 gap went unnoticed for a day. + +This is why the release order requires the tag to contain what gets waved, and why a +`DRIVER_AGENTS_REF` bump must re-run the canonical parity check by hand. + +--- + +## Piloting a cross-repo reusable + +The v1.11.0 pilot proved `vars.BONSAI_URL` resolves against the **caller**, so a per-repo tunnel +override still works after conversion. Two things made it harder than expected, both worth knowing +before designing the next one. + +**The `issues` leg is not pilotable.** `bonsai-status-sync`'s issues gate greps the issue body for +`@claude`, and `claude.yml`'s issues gate does the same — deliberately mirrored. Any issue that +trips the status flip also wakes a real implementer run on a client repo. Use the PR leg. + +**`closingIssuesReferences` only populates for PRs targeting the default branch.** A PR into a +scratch base dodges the theme-deploy workflows (they filter on `branches: [staging, dev-staging]`) +but resolves `uuid=`, so the run never reaches the `curl` and passes green having tested +nothing. If the assertion needs the network call, the PR must target the default branch. + +**Split the legs by what each can actually prove.** Leg 1 on a private consumer +(`foundrae-blackridge@staging`) proves a private repo resolves the public cross-repo reusable and +reads the caller's event payload — that is the visibility question. Variable resolution is +repo-agnostic, so leg 2 belongs wherever it is cheapest: `vite-plugin-shopify-clean` is public, +single-branch, and has no Shopify store attached, so nothing but node tests fire. + +**Assert on the log line, not the colour.** Setting `BONSAI_URL` to a bogus host and checking for a +red run is not sufficient — a wrong-way resolution falls back to the hardcoded default and *also* +fails. The discriminator is which host the log names: + +``` +BONSAI_URL: https://pilot-bogus-host.invalid +curl: (6) Could not resolve host: pilot-bogus-host.invalid +``` + +Clean up afterwards: delete the variable, close the issue and PR, delete the scratch branches. Leave +the installed stub — the wave covers it anyway. + +--- + +## Branch protection + +`enforce_admins` is `false` fleet-wide, which is what makes direct-push waves work. Two live kit +branches have **no protection at all** — `studio-sulzer@main` and `Team-Laird@develop` (404 on the +protection endpoint). Every other kit branch is protected. The kit's onboarding steps assume a +human-approver rule exists, so on those two a bot signal alone could satisfy a merge. + +On this repo, `main` has `required_status_checks` with `strict: true` but empty `contexts` — so +`lint.yml` reports red without being able to block. The context string to add is **`actionlint`** +(the job id at `.github/workflows/lint.yml:28`; the workflow-level `name:` is not part of it). diff --git a/docs/reusable-conversion-scope.md b/docs/reusable-conversion-scope.md index 02315db..f806731 100644 --- a/docs/reusable-conversion-scope.md +++ b/docs/reusable-conversion-scope.md @@ -1,6 +1,6 @@ # Scope: convert `claude.yml` + `bonsai-status-sync.yml` into reusable workflows -**Status:** `bonsai-status-sync` half IN PROGRESS (Phase 1 done); `claude.yml` half TABLED. **Written:** 2026-07-31 against `main` @ `9b70acf` (tag `v1.6.0` = `0a3934f`). +**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 @@ -345,10 +345,10 @@ 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 | **next** | -| 3 | Fleet wave for `bonsai-status-sync` — 18 repo@branch pairs across 11 repos | 3–4h | after Phase 2 | +| 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** | Phases 1 + 5 done → **pilot + wave left** | +| | **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** | @@ -461,6 +461,21 @@ 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.invalid` then `curl: (6) Could not resolve host`, exit 6. +> The variable resolves against the **caller**, so the per-repo tunnel override survives the +> conversion. Leg 1 on `foundrae-blackridge@staging` separately 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.yml` pilot, both recorded in +> [`fleet-operations.md`](fleet-operations.md): the `issues` leg is **not pilotable** (its `@claude` +> grep is mirrored by `claude.yml`'s gate, so tripping it wakes a real implementer on a client repo), +> and `closingIssuesReferences` only populates for PRs targeting the **default** branch — so a +> scratch-base PR dodges the theme deploy but resolves `uuid=` 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.