Skip to content

ci: make consumer verification the release's last step, and make a failure loud - #54

Merged
MattJackson merged 7 commits into
devfrom
ci/consumer-verification-gate
Aug 8, 2026
Merged

ci: make consumer verification the release's last step, and make a failure loud#54
MattJackson merged 7 commits into
devfrom
ci/consumer-verification-gate

Conversation

@MattJackson

Copy link
Copy Markdown
Collaborator

1. Consumer verification is now the release's LAST STEP

verify-deploy.yml already checked the release the way a user experiences it, and carefully. But it fired on its own release: published trigger, beside release.yml rather than as part of it. release.yml's graph ended at notify-downstream; nothing waited on the verifier and nothing reported its verdict as part of the release's status. A release could be green and unusable at the same time, with the only evidence a separate red run in a repo full of runs.

Mechanism chosen: workflow_call. verify-deploy.yml gains a workflow_call trigger and release.yml's final job calls it. Two properties decided it over the alternative (keep the separate trigger and have release.yml poll for its conclusion):

  • a called workflow's failure is the caller job's failure - no polling, no timeout heuristics, no second source of truth about whether verification passed;
  • uses: ./.github/workflows/verify-deploy.yml resolves at the tag, so a release is verified by the verifier that shipped with it.

The other triggers are kept, deliberately. release: published fires whether or not the release workflow finished happy, which is the property that matters: during 1.5.3 the Release workflow failed, so a workflow_run-gated verifier never ran at all, on precisely the release that needed it.

if: ${{ !cancelled() }}, for the same reason verify-assets has it: a needs: on a failed job skips its dependent by default, and notify-downstream fails outright while RELEASE_DISPATCH_TOKEN is unprovisioned. Without it, the check that matters most would be switched off on every release by an unrelated red job upstream of it.

It cannot gate publication and does not pretend to. Consumer verification is post-publication by nature: you cannot pull an image that was never pushed or brew install a formula the tap has not bumped. Drafting the release and promoting it after verification would gate the asset-completeness class of defect but still not this one, because every downstream channel it checks - the tap, the chart, the site, /releases/latest - only moves once the release is public. So the value is the verdict being impossible to miss, not a block. Not pursued here; noted as out of scope.

2. Moving pointers, on a 3-hourly cron of their own

docker.yml's tags: block emitted type=semver,pattern={{version}} and nothing else. docker/metadata-action does not imply latest from a semver pattern, so latest stayed frozen wherever a human last put it while the comment directly above the block said "X.Y.Z + latest" the whole time. Something declared a behaviour and nothing checked it.

A new, cheap pointers job asserts that every default a user gets resolves to the newest published release:

  • docker.io and ghcr.io :latest by manifest digest, and the two registries agreeing with each other;
  • /releases/latest, and every platform asset behind /releases/latest/download/;
  • the Homebrew tap version, the published helm chart's appVersion, the download page;
  • the independent-semver channels against their own repo's newest tag, not busbar's version: the Terraform registry, PyPI busbar-admin, npm @busbar/busbar-admin, proxy.golang.org for busbar-go, and validate-action's @v1 moving tag.

Channels with no meaningful latest (crates.io, the deliberately version-agnostic docs commands, the independently-published sibling repos) are named and justified in the log every run rather than silently skipped, because silence is how a channel stops being checked without anyone deciding to stop checking it.

Cost of the second cron: the sweep is HEAD requests and small JSON reads, no pull, no build, no boot: 1-2 minutes. 3-hourly is 8 runs/day, under 20 minutes of runner time daily, and caps the window in which a stale default pointer goes unnoticed at 3h instead of 24h. Hourly is 3x the runs for a 2h improvement, and GitHub deprioritises high-frequency crons anyway. The full 45-minute sweep stays daily.

3. Failure now does something a human sees

verify-deploy.yml had no failure path at all. Its entire output was the run's conclusion, and in this repo that is one more red square.

An alert job opens or updates one labelled issue naming the failing check, its expected and observed values, and the run URL - read out of the failed job's own log, so the issue cannot drift from what the check printed. Idempotent by label: the daily and 3-hourly sweeps update one issue instead of filing thirty. A resolved job closes it when a full sweep passes; a pointer-only sweep deliberately does not close, since it proves nothing about install.sh or brew.

Red and the issue serve different moments and both are here: red is what someone sees when they go looking at the release; the issue is what finds someone who is not looking.

4. The fleet gets a consumer check

plugin-consumer-verify.yml is the consumer-side sibling of plugin-ci.yml: one reusable workflow every plugin repo calls. Wired up in ten PRs (headroom-hook, webrequest-hook, store-mysql, store-postgres, store-sqlite, store-valkey, auth-github, auth-ldap, auth-oidc, hashicorp-vault), each also adding it as their release's final job. Those PRs depend on this one merging to dev first.

Three live bugs found and fixed while proving the gates

  • Check (g) could never pass. curl | grep -q under pipefail: grep -q exits at the first match, closing the pipe, killing curl with SIGPIPE (23), which pipefail reports as a failed pipeline. It went red exactly when the assertion held. Both occurrences now fetch to a file first.
  • Check (d) trusted docker pull against a possibly-cached local image. It now docker rmis both tags first, and additionally runs the untagged docker pull getbusbar/busbar that the docs actually tell users to type.
  • Comparing git/ref/tags object.sha across two annotated tags compares tag-object shas, not commits, so every correctly-repointed alias reads as stale. The @v1 check now uses the dereferenced commit.sha.

Also: the pointer sweep's newest-tag helper falls back to git tags. The three SDK repos publish to PyPI/npm/the Go proxy off a pushed tag and create no GitHub Release, so a releases-only read silently excused three live user-facing channels while all three registries were serving 0.4.0.

And the download-page check now sends a real browser User-Agent and reports HTTP code and body size when the page cannot be read, as an incident distinct from the page being stale: its first CI run reported "v1.5.3 not present" for a page that plainly shows it, because a runner's datacenter IP gets a different response. A check that sends someone to redeploy a healthy site is a check nobody believes the second time.

Proof each gate can go red

Every claim below was executed, not reasoned about.

The owner's acceptance test - latest stale while the pin is correct. Held the busbar version at 1.5.2 while production is at 1.5.3, which is exactly that shape. Six channels went red with real digests, exit 1:

FAIL: docker.io getbusbar/busbar:latest | expected: sha256:adc3f6ad...70c6 (the :1.5.2 digest) | observed: sha256:93016fe5...3244
FAIL: ghcr.io/getbusbar/busbar:latest | expected: sha256:adc3f6ad...70c6 (the :1.5.2 digest) | observed: sha256:93016fe5...3244
FAIL: github.com/GetBusbar/busbar/releases/latest | expected: .../releases/tag/v1.5.2 | observed: .../releases/tag/v1.5.3
FAIL: Homebrew tap Formula/busbar.rb version | expected: 1.5.2 | observed: 1.5.3
FAIL: GetBusbar/helm-charts busbar chart appVersion | expected: 1.5.2 | observed: 1.5.3

The 1.5.3 five-of-seven / Apple Silicon 404. Injected a platform into the release-target manifest that the release never uploaded:

FAIL: /releases/latest/download/busbar-aarch64-apple-darwin-REDPROOF.tar.gz | expected: HTTP 200/206 | observed: HTTP 404

A stale moving tag, caught in production before it self-healed. The @v1 check went red against the real repo on its first run:

FAIL: GetBusbar/validate-action@v1 moving tag | expected: the commit behind v1.0.2 (aef6fe42...) | observed: 3f5deaea...

The alert. Live run 31275222606 went red and filed #51 by itself, naming the failing check, expected, observed and the run URL. A second failing run reused it rather than opening a second.

Not done, deliberately

Draft-then-promote for core's create-release. It would close the phantom/incomplete-release window that the plugin repos just fixed, but it does not close the one this PR is about, and it changes release timing for every downstream consumer while two agents are mid-flight on 1.5.4 and 1.5.5. Worth its own change.

Matthew added 3 commits August 8, 2026 12:45
…ilure loud

verify-deploy.yml already checked the release the way a user experiences it, but
it fired on its own release:published trigger, BESIDE release.yml rather than as
part of it. Nothing in the release pipeline waited on it and nothing reported its
verdict as part of the release's status, so a release could be green and unusable
at the same time. It also had no failure path at all: a red run in a repo this
busy is one more red square.

Three changes.

1. LAST STEP. verify-deploy.yml gains a workflow_call trigger and release.yml's
   final job calls it. A failing consumer check is now the RELEASE RUN's failure,
   named in the release's own job list. ./ resolves the file at the tag, so a
   release is verified by the verifier that shipped with it. !cancelled() so an
   unrelated red job upstream (notify-downstream fails outright while
   RELEASE_DISPATCH_TOKEN is unprovisioned) cannot switch it off.

   Consumer verification is post-publication by nature and does not pretend
   otherwise: you cannot pull an image that was never pushed. It cannot block a
   publish, so the value is the verdict being impossible to miss.

2. MOVING POINTERS, on a 3-hourly cron of their own. A new, cheap pointers job
   asserts that every default a user gets resolves to the newest published
   release: docker.io and ghcr.io :latest by MANIFEST DIGEST, /releases/latest and
   each platform asset behind it, the Homebrew tap, the helm chart appVersion, the
   download page, and the independent-semver channels (Terraform registry, PyPI,
   npm, the Go proxy, validate-action's @v1 alias) against their own repo's newest
   tag. Channels with no meaningful latest are named and justified every run
   rather than silently skipped.

   This is the class of defect that let docker.yml emit type=semver only, with no
   type=raw,value=latest, so docker pull getbusbar/busbar served old code across
   at least two releases while the comment above the tags block said otherwise.

3. THE ALERT. On failure an alert job opens or updates ONE labelled issue naming
   the failing check, expected, observed and the run URL, read from the failed
   job's own log so the issue cannot drift from what the check printed. Idempotent
   by label, so the daily and 3-hourly sweeps update one issue instead of filing
   thirty. A resolved job closes it when a full sweep passes; a pointer-only sweep
   deliberately does not close, since it proves nothing about install.sh or brew.

Three live bugs found and fixed while proving the gates:
  * check (g) could never pass. curl | grep -q under pipefail: grep -q exits at
    the first match, closing the pipe, killing curl with SIGPIPE (23), which
    pipefail reports as a failed pipeline. So it went RED exactly when the
    assertion held. Both occurrences now fetch to a file first.
  * check (d) trusted docker pull against a possibly-cached local image. It now
    docker rmi's both tags first, and additionally runs the untagged
    docker pull getbusbar/busbar that the docs actually tell users to type.
  * comparing git/ref/tags object.sha across two ANNOTATED tags compares tag-object
    shas, not commits, so every correctly-repointed alias reads as stale. The v1
    check now uses the dereferenced commit.sha.

Also: the pointer sweep's newest-tag helper falls back to git tags. The three SDK
repos publish to PyPI/npm/the Go proxy off a pushed tag and create no GitHub
Release, so a releases-only read silently excused three live user-facing channels
while all three registries were serving 0.4.0.
… crying wolf

FLEET. plugin-consumer-verify.yml is the consumer-side sibling of plugin-ci.yml:
one reusable workflow every plugin repo calls, answering one question - does the
thing we published actually work when a user gets it? It checks out nothing, so a
fix that is committed but never published still fails it.

  * the Release is real and PUBLISHED, not a draft. headroom-hook and
    webrequest-hook both create theirs with --draft and promote it in
    verify-assets, and a needs: on a failed job skips its dependent by default, so
    the promote step is one red job away from never running.
  * every platform archive the release owes is present, plausibly sized, and
    downloadable THROUGH /releases/latest/download/. Not a count: a count passes a
    release missing four of five platforms. webrequest-hook v1.0.4 shipped as a
    zero-asset phantom and a >=1 assertion would have caught that one and nothing
    worse.
  * one archive is downloaded and proven to be a plugin busbar would accept:
    manifest name/alias/kind/version, the sha256 actually binding the cdylib
    beside it, the packed file really being ELF/Mach-O/PE, and a non-empty
    signature (every release workflow silently falls back to --allow-unsigned when
    BUSBAR_SIGN_KEY is unset, and busbar then refuses the plugin on the user's
    machine).
  * where the repo publishes a runnable bundle, the image is pulled FRESH and the
    container must BOOT AND SERVE. getbusbar/busbar-headroom:2.0.4, the current
    published bundle, exits 1 during config load - its shipped
    docker/bundle/config.yaml still uses auth.admin_auth with inline module
    entries, retired in 1.5.3 - so  gives a
    user a container that dies. It builds, it pushes, everything is green.

Failure opens or updates ONE labelled issue in the CALLING repo, quoting the
failing check's own FAIL/expected/observed lines out of the failed job's log so
the issue cannot drift from what the check printed. It closes itself on green.

The bundle boot check asserts the host port is free BEFORE probing. While writing
it, a probe against a port another process already held returned a cheerful 200
while the container under test was dead, which is the same false-green shape as
trusting a cached local image.

DOWNLOAD PAGE. The first CI run of the pointer sweep reported that
getbusbar.com/download/ does not advertise v1.5.3, for a page that plainly does
from a browser: a runner's datacenter IP gets a different response. Reporting that
as staleness sends someone to redeploy a site that is fine, and a check that does
that twice is a check nobody believes. It now sends a real browser User-Agent and
reports HTTP code and body size when the page cannot be READ, as an incident
distinct from the page being stale.
… parses

A `run:` block containing any ${{ }} is compiled as ONE expression and GitHub caps
an expression at 21000 characters. The pointer sweep is longer than that, so the
whole workflow failed to parse with 'Exceeded max expression length' and could not
even be dispatched - a failure of the file, not of any check in it. The single
interpolation now arrives as an environment variable, which leaves the script a
plain literal with no length limit and is the safer shape anyway: an event-supplied
value reaches the shell as a variable rather than being pasted into script text.
Matthew added 4 commits August 8, 2026 13:02
getbusbar.com returns HTTP 403 to GitHub Actions runners. Its edge challenges
datacenter IPs, so a browser is served the page normally and a runner is not.
Check (g) has therefore been reporting 'getbusbar.com/download/ does not show
v1.5.3' every day on main, for a page that plainly shows v1.5.3, after burning
its full five minutes of retries first.

That is the worst kind of red. It sends whoever reads it to redeploy a site that
is fine, and the second time it does that, everyone learns that red in this
workflow means nothing - which is precisely how install.sh rotted in production
under a green verifier.

Three states now, not two:
  * 200 with a real page -> assert the advertised version, as before.
  * 403/429 -> VISIBLE SKIP. The assertion could not be EVALUATED; that is not the
    same claim as the site being stale, and pretending otherwise is a fabrication.
    Announced three ways (log line, ::warning:: annotation, step summary), with a
    marketing-side fix named: allow GitHub Actions egress through the bot rules,
    or expose a small unchallenged route carrying the current version. Same shape
    and same reasoning as the existing brew half of check (j).
  * anything else non-200 -> hard failure, because unlike a 403 it is broken for
    every visitor and not just for us.

A 403 also breaks out of the retry loop immediately: an edge rule about WHO is
asking will not become a 200 by waiting, and retrying it dresses a block up as a
slow rollout while delaying the rest of the run.

The link-scraping half degrades the same way. Being refused the page is not
evidence that the page carries no download links, so it no longer says so; the
/releases/latest resolution beneath it needs no page at all and still runs, which
is the half that actually decides whether every button on the site serves the
right release.
.github/release-targets.json was added on dev and has not been promoted to main.
Check (c) fetched it at the tag and fell back only to main, so every run today
died on 'no release-target manifest at v1.5.3 and none on main either' and every
check after it - (d) through (n), the image label, install.sh, Homebrew, the
attestation check, the quickstart - was skipped. A whole verifier lost to a file
being one branch away.

Fetch now degrades tag -> main -> dev, the same chain the pointer sweep uses, and
names which ref it fell back to. Reading it from a branch is weaker than reading it
at the tag, but that is the trade the main fallback already made, the platform set
changes rarely, and the alternative - hardcoding a platform list in the verifier -
is the exact defect the manifest was introduced to remove.
getbusbar.com's edge challenges datacenter IPs, so every GitHub Actions runner gets
HTTP 403 while a browser and a laptop are served normally. That takes out checks
(g), (h), (i), (k) and (n) - the whole install-paths half of this workflow, which
is the half it exists for.

What it printed instead was worse than nothing:

  ::error::(h) could not fetch https://getbusbar.com/install.sh at all. The
  documented one-liner in the README and on the site is dead.

The one-liner is not dead. It works. This message has been printed every day on
main, about a healthy site, and it is the most effective possible way to teach
everyone that red in this workflow means nothing - which is exactly how install.sh
rotted under a green verifier the first time.

Three states everywhere the site is read, not two:
  * 200 -> assert as before.
  * 403/429 -> VISIBLE SKIP. The assertion could not be EVALUATED, which is not the
    claim that it failed. Announced in the log, as a ::warning:: annotation, and in
    the step summary, with the real fix named: allow GitHub Actions egress through
    the Cloudflare bot rules for /install.sh, /providers.yaml and /api/*, or serve
    them from a route exempt from the challenge. Same shape as the existing brew
    half of check (j).
  * anything else non-200 -> hard failure, unchanged, because a 404 or a 5xx IS the
    documented path being dead for every user.

(h) leaves a marker so (i) and (l.1) degrade with it rather than each re-deriving
the same block, and (l.1) does NOT substitute a Release binary for the one the live
install.sh should have produced: that would quietly change what the check means.
(l.2), the docker one-liner, needs nothing from the site and still runs.

This is a real, urgent, marketing-side gap and it should be fixed there: while the
block stands, the two most valuable checks in this workflow are blind from CI. A
visible skip that names the blocker is strictly better than a red that lies about
it, and it puts the pressure on the right repair.

Also: every run: block is executed as `bash -e {0}`, so errexit is ALREADY ON
before line 1 and writing `set -uo pipefail` never turned it off. The new pointer
sweep, alert and fleet scripts all claimed to collect every failure and would in
fact have aborted at the first one - the alert job proved it by exiting 1 when its
log grep matched nothing. They now `set +e` explicitly.
…l request

Most plugin repos call plugin-ci with `busbar_ref: ${{ github.ref_name }}`, meaning
'test against the same-named busbar branch'. On a pull_request event
`github.ref_name` is not a branch name: it is '<number>/merge'. So every PR to those
repos asked GetBusbar/busbar for a branch called '5/merge', actions/checkout retried
three times and died with

    The process '/usr/bin/git' failed with exit code 1

naming neither the ref it wanted nor why it could not have it. From the PR page that
is indistinguishable from the plugin's own tests being broken. Several of these repos
have never merged a pull request, and this is sufficient to explain why: a PR there
cannot go green. Feature-branch pushes fall in the same hole, since 'ci/whatever'
exists in the plugin repo and not in busbar.

The ref is now resolved before checkout. A real ref - a sha, main, dev - is used
unchanged. An unresolvable one falls back to dev and says so in a ::warning:: that
names the cause and the caller-side fix (`github.base_ref || github.ref_name`).
Fixed here rather than in ten callers, which is what this file exists for.

Found while opening the fleet's consumer-verification PRs: all ten went red on this
before running a line of the change they were testing.
@MattJackson

Copy link
Copy Markdown
Collaborator Author

Live defects this found, in production, tonight

Each was found by running the gate, not by reading code. None of them was known.

1. The documented Docker quickstart does not work for busbar 1.5.3

docs/getting-started.md Step 1, run verbatim against getbusbar/busbar:1.5.3, never serves /healthz. The container refuses to start:

[error] config is mutable (config.locked: false) but the overlay backend
'/etc/busbar/busbar-overlay.json' is not writable (is the config directory read-only?).
A mutable config MUST be able to persist admin-API changes. Point `config.overlay.file`
at a writable path, or set `config.locked: true` for an immutable/GitOps deployment.

The documented command mounts config.yaml at /etc/busbar/config.yaml:ro, which makes the directory the overlay wants to write to unwritable. A brand new user's first five minutes fail. Not fixed here - it needs a docs or a default-overlay-path decision, which is its own change.

2. getbusbar.com returns HTTP 403 to every GitHub Actions runner

Its edge challenges datacenter IPs. /download/, /install.sh and /api/* are all refused, while a browser and a laptop are served normally. That takes out checks (g), (h), (i), (k) and (n) - the entire install-paths half of this workflow, which is the half it exists for.

Worse than being blind, it was lying about it, every day, on main:

::error::(h) could not fetch https://getbusbar.com/install.sh at all.
The documented one-liner in the README and on the site is dead.
Fix: redeploy the marketing site / restore the /install.sh route.

The one-liner is not dead. It works. This is the single most effective way to teach everyone that red in this workflow means nothing - which is how install.sh rotted under a green verifier the first time. Those checks now report a visible skip naming the real blocker and the real fix, and only hard-fail on a genuine non-200. The marketing-side fix should be treated as urgent: while the block stands, the two most valuable checks here are blind from CI.

3. headroom-hook's published bundle cannot boot

getbusbar/busbar-headroom:2.0.4, the current published image, pulled fresh and run:

[error] config.yaml: this looks like a busbar 1.x config; run `busbar --migrate-config`
  - `auth.admin_auth:` carries INLINE module entries (retired 1.5.3 -> define the
    provider once under `identity-providers:` and reference it by bare name)

It builds, it pushes, every workflow is green, and docker run getbusbar/busbar-headroom gives a user a container that dies. This is what the fleet check exists for and it catches it. Tracked in GetBusbar/headroom-hook#5.

4. Every pull request to a plugin repo has been red for a reason the log never states

Most plugin repos pass busbar_ref: ${{ github.ref_name }} to plugin-ci.yml. On a pull_request event github.ref_name is <number>/merge, not a branch name, so the sibling checkout asked busbar for a branch called 5/merge and died with:

The process '/usr/bin/git' failed with exit code 1

naming neither the ref nor the reason. From the PR page that is indistinguishable from the plugin's tests being broken. Several of these repos have never merged a pull request, and this is sufficient to explain why: a PR there cannot go green. All ten of the fleet PRs hit it before running a line of what they were testing. Fixed in plugin-ci.yml (resolve the ref, fall back with a loud warning) and at each caller (github.base_ref || github.ref_name).

Current state of this branch, honestly

Run 31276236071: pointers green, checks (a) through (k) green, (l) red on finding 1 above, and the alert filed and maintained #51 by itself. That red is the gate working, not the gate being broken.

The one path not proven end to end is resolved auto-closing the issue, because production is genuinely red on (l) and I will not fake a green to demonstrate a cleanup job. Its guard was exercised: it stayed skipped on every failing run, and it refuses to close on a pointer-only sweep.

@MattJackson
MattJackson merged commit ceb7104 into dev Aug 8, 2026
26 checks passed
@MattJackson
MattJackson deleted the ci/consumer-verification-gate branch August 8, 2026 20:32
Sign up for free to 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.

1 participant