From 079773ac9d3325f65824781f4084d421bd68c16c Mon Sep 17 00:00:00 2001 From: Matthew Date: Sat, 8 Aug 2026 12:45:00 -0700 Subject: [PATCH 1/7] ci: make consumer verification the release's last step, and make a failure 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. --- .github/workflows/release.yml | 55 +++ .github/workflows/verify-deploy.yml | 684 +++++++++++++++++++++++++++- 2 files changed, 730 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbd3bf2f4..b92534448 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -519,3 +519,58 @@ jobs: fi done < .github/release-notify-targets.txt [ "$fail" = 0 ] || { echo "::error::one or more downstream dispatches failed (see warnings)"; exit 1; } + + # -- THE LAST STEP: DOES THE THING WE JUST PUBLISHED ACTUALLY WORK FOR A USER? ------------------- + # + # Everything above this line verifies what THIS WORKFLOW produced, from inside this workflow. The + # gate proves the tests passed. `verify-assets` proves the assets it uploaded are present and + # plausibly sized. `notify-downstream` proves the fan-out fired. Not one of them proves that + # `docker pull getbusbar/busbar`, `curl -fsSL https://getbusbar.com/install.sh | sh`, or + # `brew install getbusbar/busbar/busbar` gives a user this release. Those are different systems, in + # different repos, on different clocks. + # + # `verify-deploy.yml` already checked all of that, and thoroughly - but it fired on its OWN + # `release: published` trigger, BESIDE this workflow rather than as part of it. Nothing here waited + # on it and nothing reported its verdict as part of the release's status. So the release could be + # green while being unusable, with the only evidence a separate red run in a repo full of runs. + # That is not a hypothetical: 1.5.3 published five of its seven assets, `install.sh` returned 404 + # on Apple Silicon, `docker pull getbusbar/busbar` served the previous release, and every one of + # those was found by a human checking by hand. + # + # Calling it as a job makes its failure THIS RUN's failure, on the release's own status page, with + # the failing check named in this run's own job list. That is what "last step" has to mean. + # + # 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 only 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 here is the verdict being impossible to miss, not a block: + # 1. this job turns the RELEASE RUN red, and + # 2. verify-deploy.yml's `alert` job opens/updates a labelled GitHub issue naming the failing + # check, its expected and observed values, and the run URL. + # + # `!cancelled()` FOR THE SAME REASON `verify-assets` HAS IT. A `needs:` on a failed job skips the + # dependent by default, so without this a failed `notify-downstream` (which fails outright while + # RELEASE_DISPATCH_TOKEN is unprovisioned - see its TODO) would SKIP consumer verification on every + # release. The one check that matters most must not be switched off by an unrelated red job + # upstream of it. + consumer-verification: + name: consumer verification (LAST STEP) + needs: [verify-assets, notify-downstream] + if: ${{ !cancelled() }} + # `./` resolves the reusable workflow AT THIS RUN'S REF, i.e. at the tag being released, so a + # release is verified by the verifier that shipped with it rather than by whatever is on the + # default branch today. + uses: ./.github/workflows/verify-deploy.yml + with: + version: ${{ github.ref_name }} + # Job-level permissions REPLACE the workflow-level block for a called workflow, and a called + # workflow can never exceed what it is granted here. `issues: write` is what lets the alert job + # file the issue; `actions: read` is what lets it read the failed job's log to quote the exact + # expected/observed values. + permissions: + contents: read + issues: write + actions: read + secrets: inherit diff --git a/.github/workflows/verify-deploy.yml b/.github/workflows/verify-deploy.yml index 052ce8662..4b0f88add 100644 --- a/.github/workflows/verify-deploy.yml +++ b/.github/workflows/verify-deploy.yml @@ -123,12 +123,45 @@ name: Verify deploy # object BEFORE the build matrix runs, and that event fires regardless of what the rest of the run # does, which is exactly the property needed: if a release object exists in public, it gets verified, # whether or not the workflow that made it finished happy. +# +# -- THIS IS THE LAST STEP OF THE RELEASE, NOT A BYSTANDER --------------------------------------- +# Until now every trigger above fired this workflow BESIDE the release. release.yml's own graph +# ended at `notify-downstream`, nothing in it waited on this, and nothing reported this verdict as +# part of the release's status. So a release could be green, fanned out to the Homebrew tap and the +# Helm chart, and simultaneously unusable, with the only evidence a separate red run in a repo full +# of runs. That is what happened to 1.5.3: it published five of seven assets, `install.sh` 404'd on +# Apple Silicon, and a human found it by hand. +# +# `workflow_call` fixes that. release.yml's FINAL job now calls this file directly, so a failing +# consumer check turns the RELEASE RUN red, on the release's own status, with the failure named in +# the release run's own job list. Two properties make this the right mechanism rather than "have +# release.yml poll for the separate run's conclusion": +# * `uses: ./.github/workflows/verify-deploy.yml` from a tag-triggered caller loads this file AT +# THE TAG, so the release is verified by the verifier that shipped with it. (The other triggers +# load it from the default branch - see the workflow_run note below for why that is harmless +# here: this file reads no repository state.) +# * 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. +# +# CONSUMER VERIFICATION IS POST-PUBLICATION BY NATURE, and this does not pretend otherwise. You +# cannot `docker pull` an image that was never pushed, or `brew install` a formula the tap has not +# bumped. So this CANNOT gate publication and is not designed to. What it does instead: +# 1. makes the verdict part of the release's own status (RED on the release run), and +# 2. opens/updates a GitHub issue naming the failing check (see the `alert` job below), +# because a release that is published and unusable is a fact somebody has to be TOLD, and red alone +# is a signal only for whoever happens to be looking at that run. on: release: types: [published] workflow_run: workflows: ["Release", "Docker"] types: [completed] + workflow_call: + inputs: + version: + description: "Version to verify (e.g. 1.5.2, no leading v)" + required: true + type: string workflow_dispatch: inputs: version: @@ -138,14 +171,395 @@ on: # 13:17 UTC daily. Off the top of the hour on purpose: :00 cron slots are the most contended on # GitHub's shared scheduler and get delayed the most, and mid-day UTC lands in working hours for # both EU and US-East so a red rot alert is seen the day it fires, not the next morning. + # THE FULL SWEEP runs on this one only. - cron: "17 13 * * *" + # 3-HOURLY POINTER SWEEP (the `pointers` job only; `verify` is gated off it below). + # + # WHY A SECOND, FASTER CRON. "if docker or anything isn't latest we need to know right away." + # Daily means up to 24h of `docker pull getbusbar/busbar` handing users the previous release, + # which is exactly what happened across at least two releases when 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 above the block said "X.Y.Z + latest" the whole time. + # + # WHY 3 HOURS AND NOT HOURLY. The cost side is real but small: the pointer sweep is HEAD requests + # and small JSON reads (no image pull, no build, no brew, no boot), ~1-2 minutes of runner time. + # 3-hourly is 8 runs/day, under 20 minutes of runner time a day, and caps the window in which a + # stale default pointer can go unnoticed at 3h instead of 24h. Hourly would be 3x the runs for a + # 2h improvement on a number already inside the "someone notices this shift" range, and GitHub + # deprioritises high-frequency crons on shared runners, so the nominal interval would not be the + # real one anyway. The release-publication trigger is what makes the common case immediate; this + # cron exists for the case where a pointer rots WITHOUT a release, which is how the Docker `latest` + # freeze survived: nothing about our artifacts changed on the day it broke. + - cron: "23 */3 * * *" +# `issues: write` is for the `alert` job, which is the second half of "make a failure impossible to +# miss": red on the release run, AND an issue that comes and finds a human. `actions: read` lets +# that job read the FAILED job's own log through the API so the issue can quote the exact FAIL / +# ::error:: lines (the failing check, expected, observed) rather than saying "something went wrong, +# go read a log". permissions: contents: read + issues: write + actions: read jobs: + # -- MOVING POINTERS ----------------------------------------------------------------------------- + # A separate job from `verify`, on purpose, and it is the cheap one. + # + # THE DEFECT CLASS. Every channel below has a DEFAULT pointer: the thing a user gets when they do + # not name a version. `docker pull getbusbar/busbar`. `/releases/latest/download/...`. `brew + # install`. `pip install busbar-admin`. `uses: GetBusbar/validate-action@v1`. Each of those + # pointers is written by a DIFFERENT publish step in a DIFFERENT repo, and each one can silently + # fail to move while the version-pinned artifact beside it publishes perfectly. When that happens + # nothing is red anywhere: the pinned thing exists, the release is green, and users quietly get + # old code. `docker pull getbusbar/busbar` served the previous release to tens of thousands of + # pulls that way, across at least two releases. + # + # THE RULE THIS JOB ENFORCES: for every channel, the pointer a user gets by DEFAULT must resolve to + # the newest thing that channel's repo actually published. Two families, because busbar's + # distribution is deliberately mixed-model: + # * TRACKS BUSBAR'S VERSION - docker.io/ghcr.io `:latest`, github `/releases/latest`, the Homebrew + # tap, the helm chart's `appVersion`, the download page. These must equal the release under test. + # * INDEPENDENT SEMVER - the three SDKs (PyPI/npm/Go), the Terraform provider, validate-action's + # `@v1`. These do NOT mirror busbar's version and asserting they do is simply wrong (it was, and + # it red-failed check (e)). For these the invariant is registry-latest == that repo's own newest + # published tag, which catches the real defect ("we tagged it and the publish job never ran") + # without false-failing on the legitimate no-op-release case. + # + # DIGESTS, NOT TAG NAMES, AND NEVER A LOCAL IMAGE. The registry assertions compare MANIFEST DIGESTS + # over the Distribution API. They deliberately do not `docker run ... --version`: a local image + # cache will happily answer with the OLD image for the SAME tag and report a stale `latest` as + # fresh (or a fresh one as stale). This job pulls nothing at all, so it cannot be fooled that way; + # check (d) in the `verify` job, which does need a real image, deletes its local copy first. + pointers: + name: moving pointers (every default a user gets is the newest release) + runs-on: ubuntu-latest + # HEAD requests and small JSON reads only. If this has not finished in 20 minutes something is + # hanging, and on a 3-hourly cron a hung run must die well before the next one fires. + timeout-minutes: 20 + outputs: + version: ${{ steps.sweep.outputs.version }} + env: + DOCKERHUB_IMAGE: getbusbar/busbar + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Every default pointer must resolve to the newest published release + id: sweep + run: | + set -uo pipefail # deliberately NOT -e: every channel is checked and ALL failures are + # reported. A pointer sweep that stops at the first stale channel tells + # you about one of them and hides the rest, and "docker is stale" and + # "docker AND homebrew AND helm are stale" are different incidents. + fail=0 + : > /tmp/pointer-failures.md + + record() { # record + echo "FAIL: $1 | expected: $2 | observed: $3" + { + echo "- **$1**" + echo " - expected: \`$2\`" + echo " - observed: \`$3\`" + echo " - $4" + } >> /tmp/pointer-failures.md + echo "::error::STALE MOVING POINTER: $1 - expected '$2', observed '$3'. $4" + fail=1 + } + declared() { # declared -- a channel with NO meaningful "latest" + echo "NOT APPLICABLE: $1 -- $2" + } + + # Same anonymous pull-token -> HEAD manifest -> Docker-Content-Digest flow the `verify` job + # uses. It is duplicated rather than shared because jobs cannot share a file without an + # artifact round-trip, and this is a pure function of its arguments with no repository + # state in it - the duplication that is dangerous is a duplicated FACT (a platform list), + # not a duplicated pure function. + reg_digest() { # reg_digest + local auth_host="$1" reg_host="$2" repo="$3" tag="$4" token_url token + if [ "$auth_host" = "auth.docker.io" ]; then + token_url="https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" + else + token_url="https://${auth_host}/token?service=${auth_host}&scope=repository:${repo}:pull" + fi + token="$(curl -fsS --max-time 30 "$token_url" | jq -r '.token // .access_token' 2>/dev/null)" + [ -n "${token:-}" ] && [ "$token" != "null" ] || return 1 + curl -fsS --max-time 30 -I \ + -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \ + "https://${reg_host}/v2/${repo}/manifests/${tag}" \ + | tr -d '\r' | grep -i '^docker-content-digest:' | awk '{print $2}' + } + + # Newest published tag of a repo, from the RELEASES list - deliberately NOT from + # /releases/latest, which is itself one of the pointers under test. Asking the pointer what + # the newest release is and then checking the pointer against that answer is a check that + # can never fail. + newest_tag() { # newest_tag + local out + out="$(gh api --paginate "repos/$1/releases" \ + --jq '.[] | select(.draft==false and .prerelease==false) | .tag_name' 2>/dev/null \ + | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)" + [ -n "$out" ] && { printf '%s\n' "$out"; return 0; } + # FALL BACK TO GIT TAGS, and this is not a nicety. The three SDK repos (busbar-python, + # busbar-js, busbar-go) publish to PyPI/npm/the Go proxy off a pushed TAG and create no + # GitHub Release at all. Reading releases only, this function returned empty for all + # three, and the caller then declared them "no releases yet, nothing to assert" -- three + # live, shipping, user-facing channels silently exempted from the sweep while PyPI, npm + # and proxy.golang.org were all serving 0.4.0. A pointer check that quietly excuses the + # channels it cannot read is worse than one that is absent, because it looks covered. + gh api --paginate "repos/$1/tags" --jq '.[].name' 2>/dev/null \ + | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 + } + + # -- Which busbar release is under test ------------------------------------------------ + SUPPLIED="${{ inputs.version || github.event.release.tag_name || '' }}" + SUPPLIED="${SUPPLIED#v}" + NEWEST="$(newest_tag GetBusbar/busbar)" + if [ -z "${NEWEST:-}" ]; then + echo "::error::could not enumerate GetBusbar/busbar's published releases; every pointer assertion below would be vacuous, so this fails rather than passes." + exit 1 + fi + if [ -n "$SUPPLIED" ] && [ "$SUPPLIED" != "$NEWEST" ]; then + # Not a failure by itself (a re-verify of an older version is legitimate), but the + # pointers are only ever asserted against the newest release, so say which one won. + echo "note: supplied version ${SUPPLIED} is not the newest published release (${NEWEST}); pointers are asserted against ${NEWEST}." + fi + V="$NEWEST" + TAG="v$V" + # Written BEFORE any assertion runs, on purpose: the `alert` job needs the version to + # title the issue, and it only ever reads this output when this step has FAILED. + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "Newest published busbar release: ${TAG}. Every default pointer below must resolve to it." + + # -- P1/P2: container registries. THE 1.5.3 BUG, EXACTLY. ------------------------------ + # Retried because on release day docker.yml and this can race; a stale pointer is + # permanent and survives the retries, a race resolves inside them. + dh_ver="" dh_latest="" ghcr_ver="" ghcr_latest="" + for i in $(seq 1 10); do + dh_ver="$(reg_digest auth.docker.io registry-1.docker.io "$DOCKERHUB_IMAGE" "$V" || true)" + dh_latest="$(reg_digest auth.docker.io registry-1.docker.io "$DOCKERHUB_IMAGE" latest || true)" + [ -n "$dh_ver" ] && [ "$dh_ver" = "$dh_latest" ] && break + echo " ...docker.io :latest not yet == :${V} (attempt $i/10), retrying in 15s" + sleep 15 + done + if [ -z "$dh_ver" ]; then + record "docker.io ${DOCKERHUB_IMAGE}:${V}" "a resolvable manifest digest" "" \ + "The version-pinned image was never pushed. Fix: re-run docker.yml for ${TAG}." + elif [ "$dh_latest" = "$dh_ver" ]; then + echo "PASS: docker.io ${DOCKERHUB_IMAGE}:latest == :${V} (${dh_latest})" + else + record "docker.io ${DOCKERHUB_IMAGE}:latest" "$dh_ver (the :${V} digest)" "${dh_latest:-}" \ + "\`docker pull ${DOCKERHUB_IMAGE}\` -- the command in the README, the docs and on the site - is serving a DIFFERENT image than ${TAG}. This is the exact 1.5.3 defect: docker/metadata-action does not imply \`latest\` from \`type=semver,pattern={{version}}\`, so \`latest\` froze wherever a human last set it. Fix: confirm docker.yml's \`tags:\` block still emits an explicit \`type=raw,value=latest\` (gated on a real release), then re-run it for ${TAG}." + fi + for i in $(seq 1 10); do + ghcr_ver="$(reg_digest ghcr.io ghcr.io getbusbar/busbar "$V" || true)" + ghcr_latest="$(reg_digest ghcr.io ghcr.io getbusbar/busbar latest || true)" + [ -n "$ghcr_ver" ] && [ "$ghcr_ver" = "$ghcr_latest" ] && break + echo " ...ghcr.io :latest not yet == :${V} (attempt $i/10), retrying in 15s" + sleep 15 + done + if [ -z "$ghcr_ver" ]; then + record "ghcr.io/getbusbar/busbar:${V}" "a resolvable manifest digest" "" \ + "Fix: re-run docker.yml's ghcr push for ${TAG}." + elif [ "$ghcr_latest" != "$ghcr_ver" ]; then + record "ghcr.io/getbusbar/busbar:latest" "$ghcr_ver (the :${V} digest)" "${ghcr_latest:-}" \ + "Users pulling from GHCR without a tag get a different image than ${TAG}. Same fix as the Docker Hub case." + else + echo "PASS: ghcr.io/getbusbar/busbar:latest == :${V} (${ghcr_latest})" + fi + # And the two registries must agree, or "latest" means two different things depending on + # which registry you happened to pull from. + if [ -n "$dh_ver" ] && [ -n "$ghcr_ver" ] && [ "$dh_ver" != "$ghcr_ver" ]; then + record "ghcr.io vs docker.io for :${V}" "$dh_ver" "$ghcr_ver" \ + "The same tag resolves to DIFFERENT images on the two registries, so which bytes a user runs depends on which registry they pulled from. Fix: docker.yml copies the manifest cross-registry; re-run it for ${TAG}." + fi + + # -- P3: the GitHub /releases/latest redirect, and its assets -------------------------- + # Every download button on getbusbar.com, and install.sh, follow this. It 404s outright if + # the newest Release is not flagged 'latest', and silently serves the PREVIOUS release's + # bytes if the newest one never published. + loc="$(curl -fsS --max-time 30 -o /dev/null -w '%{redirect_url}' \ + "https://github.com/GetBusbar/busbar/releases/latest" || true)" + if [ "${loc##*/releases/tag/}" = "$TAG" ]; then + echo "PASS: github.com/GetBusbar/busbar/releases/latest -> ${TAG}" + else + record "github.com/GetBusbar/busbar/releases/latest" ".../releases/tag/${TAG}" "${loc:-}" \ + "install.sh and every download button on getbusbar.com resolve through this redirect, so all of them are handing users the wrong release. Fix: mark Release ${TAG} as 'latest' (it is probably still a draft or flagged prerelease)." + fi + # A redirect that lands in the right place still proves nothing if the assets behind it are + # not there. THIS IS THE 1.5.3 FIVE-OF-SEVEN CASE: the Release existed, was flagged latest, + # and `curl install.sh | sh` still 404'd on Apple Silicon because that platform's tarball + # was never uploaded. The expected names come from the release's OWN manifest at the tag, + # never from a list typed here. + # The manifest is fetched at the TAG first so the expectation tracks the release that + # produced the assets. Tags cut before the manifest existed do not carry it, and it has not + # reached `main` yet either, so the chain degrades tag -> main -> dev. Reading it from a + # branch is weaker (the platform set could have moved since the tag) but it is the same + # trade check (c) already makes, and the platform set changes rarely - whereas HARDCODING + # a list here would be the exact defect the manifest was introduced to remove. + got_manifest=0 + for ref in "${TAG}" main dev; do + if curl -fsS --max-time 30 \ + "https://raw.githubusercontent.com/GetBusbar/busbar/${ref}/.github/release-targets.json" \ + -o /tmp/pt-targets.json 2>/dev/null; then + [ "$ref" = "$TAG" ] || echo "note: ${TAG} does not carry .github/release-targets.json; using ${ref}'s copy." + got_manifest=1 + break + fi + done + if [ "$got_manifest" = 1 ]; then + mapfile -t want < <(python3 - "$TAG" <<'PY' + import json, sys + spec = json.load(open("/tmp/pt-targets.json")) + for t in spec["targets"]: + print("busbar-%s.%s" % (t["target"], t["archive"])) + PY + ) + if [ "${#want[@]}" -lt 5 ]; then + record "release-targets manifest at ${TAG}" ">= 5 platform archives" "${#want[@]}" \ + "Refusing to 'verify' the latest-download path against an empty expectation list: that passes for a release that published nothing." + fi + for a in "${want[@]}"; do + u="https://github.com/GetBusbar/busbar/releases/latest/download/${a}" + code="$(curl -sSL --max-time 60 --range 0-0 -o /dev/null -w '%{http_code}' "$u" || echo 000)" + case "$code" in + 200|206) echo "PASS: /releases/latest/download/${a} -> ${code}" ;; + *) record "/releases/latest/download/${a}" "HTTP 200/206" "HTTP ${code}" \ + "This is the platform-specific 404 that broke \`curl -fsSL https://getbusbar.com/install.sh | sh\` on Apple Silicon for the whole 1.5.3 release. A user on that platform gets nothing. Fix: find that target's leg in release.yml's \`upload-assets\` matrix, fix it, and re-upload the asset to ${TAG}." ;; + esac + done + else + record ".github/release-targets.json (tried ${TAG}, main, dev)" "fetchable over raw.githubusercontent" "" \ + "Without it this check cannot know which platforms ${TAG} owed, and guessing is how a missing platform got waved through in the first place. Fix: restore .github/release-targets.json - release.yml's own \`targets\` job reads the same file, so if it is really gone the release matrix is broken too." + fi + + # -- P4: Homebrew tap ------------------------------------------------------------------ + fver="$(curl -fsSL --max-time 30 \ + "https://raw.githubusercontent.com/GetBusbar/homebrew-busbar/main/Formula/busbar.rb" 2>/dev/null \ + | grep -m1 -E '^ *version "' | sed -E 's/.*version "([^"]+)".*/\1/' || true)" + if [ "$fver" = "$V" ]; then + echo "PASS: Homebrew tap formula version == ${V}" + else + record "Homebrew tap Formula/busbar.rb version" "$V" "${fver:-}" \ + "\`brew install getbusbar/busbar/busbar\` -- the documented command - installs an old binary. Fix: run/repair the tap's bump.yml workflow." + fi + + # -- P5: published Helm chart appVersion ----------------------------------------------- + appver="$(curl -fsS --max-time 60 "https://getbusbar.github.io/helm-charts/index.yaml" 2>/dev/null \ + | awk '/^ busbar:/{f=1} f && /appVersion:/{print $2; exit}' | tr -d '"' || true)" + if [ "$appver" = "$V" ]; then + echo "PASS: helm-charts busbar appVersion == ${V}" + else + record "GetBusbar/helm-charts busbar chart appVersion" "$V" "${appver:-}" \ + "\`helm install busbar getbusbar/busbar\` deploys an old gateway. Fix: run/repair helm-charts' release workflow." + fi + + # -- P6: what the site presents as current --------------------------------------------- + # Anchored: an unanchored substring match once passed v1.5.2 against a page advertising + # v1.5.20. + # NEVER `curl ... | grep -q` UNDER pipefail. `grep -q` exits the instant it matches, which + # closes the pipe, which kills curl with SIGPIPE (exit 23), which `pipefail` then reports + # as a failed pipeline - so the check goes RED EXACTLY WHEN THE ASSERTION HOLDS and green + # only when the page is missing the version. That inversion is live in check (g) below and + # is fixed there too. Fetch to a file, then grep the file. + curl -fsS --max-time 30 "https://getbusbar.com/download/" -o /tmp/pt-download.html 2>/dev/null || : > /tmp/pt-download.html + if grep -qE "v${V}([^0-9]|\$)" /tmp/pt-download.html; then + echo "PASS: getbusbar.com/download/ advertises v${V}" + else + record "getbusbar.com/download/ advertised version" "v${V}" "" \ + "The site tells visitors the current release is something other than ${TAG}. Fix: redeploy the marketing site." + fi + + # -- P7-P10: INDEPENDENT-SEMVER channels. The invariant is registry-latest == that repo's + # own newest published tag, NOT == busbar's version. ---------------------------------- + check_independent() { # check_independent