From 1afbc9059383a71442b6d6bd41e5305fcc7b36a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:58:02 +0000 Subject: [PATCH] ci(release): make the release-completeness guard reachable, and let it guard the image too (#4900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the guard #4899 added, both exposed by the very next release (89d2a4e). npm published all of 17.0.0-rc.2 and pushed its tags, then creating the @objectstack/spec GitHub Release failed on the API's 125k body limit — the spec changelog section for that version is 342,911 characters (#4900). The step went red, `published` stayed false, and the runtime image was silently lost. 1. The guard could not fire. GitHub wraps an `if:` naming no status function in an implicit success(), so `if: steps.changesets.outputs.published != 'true'` was really `success() && …` — skipped for any changesets-step failure, which is one of the two cases it exists for. Now `!cancelled() && …`. 2. Its contract was wrong. "Publish whatever is missing" does not cover a release that published and then died before reporting it: rc.2 was already on npm, so a publish-only step would have no-opped and lost the image anyway. It now guards the invariant the release actually owes — this repo's version must be on npm AND must have a matching runtime image — and reports the second half through the job outputs so `docker` builds it. `docker` gains the same `!cancelled()` treatment. A dependent job under the default implicit success() is skipped for any upstream failure, so the image was lost to a fault that happened after the packages were already public. The `published` output is the real gate; the release job's exit status is not, and the run stays red either way because the GitHub Release genuinely failed. A failed ghcr probe counts as MISSING on purpose: a redundant rebuild costs a few minutes, a wrongly-skipped one leaves a published npm version with no image and nothing to say so. Verified against stubbed npm/pnpm/git/curl — npm present + image present (full no-op, no outputs), npm present + image missing (requests docker, does NOT republish), npm missing (publishes, then requests docker), and ghcr unreachable (treated as missing). The ghcr probe itself was checked against the live registry: it resolves an anonymous pull token and correctly reports 17.0.0-rc.1 present and 17.0.0-rc.2 absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf --- .github/workflows/release.yml | 97 +++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38409556e3..97eaff8d15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,51 +162,76 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish a release the Changesets action left behind + - name: Ensure this version actually shipped (npm + runtime image) id: recover-publish - if: steps.changesets.outputs.published != 'true' - # changesets/action reaches its publish branch ONLY when there are zero - # pending changesets. An EMPTY changeset still counts as pending, so a - # `main` carrying only empty ones lands in - # All changesets are empty; not creating PR - # and returns: no version PR, no publish, `published=false`, and a GREEN - # run. The version bump is already committed by then, so the release - # simply evaporates — 17.0.0-rc.2 sat versioned-in-repo and absent from - # npm with Release reporting success, and `docker` skipped with it - # (#4898). This is not an exotic state: `Check Changeset` sanctions the - # empty changeset as the "this PR releases nothing" declaration, so any - # ordinary docs/ci PR landing during the version-PR window produces it. + # `!cancelled()` is load-bearing, not decoration. GitHub wraps an `if:` + # naming no status function in an IMPLICIT success(), so the first + # version of this step — `if: steps.changesets.outputs.published != + # 'true'` — could not fire in one of the two cases it exists for: the + # changesets step itself failing. On 89d2a4e it did exactly that. npm + # publish and the atomic tag push both succeeded, then creating the + # @objectstack/spec GitHub Release failed on the API's 125k body limit + # (#4900); the step went red, `published` stayed false, this step was + # skipped, and the 17.0.0-rc.2 runtime image was silently lost. + if: ${{ !cancelled() && steps.changesets.outputs.published != 'true' }} + # It also guards an INVARIANT rather than performing an action: the + # version in this repo must be on npm AND must have a matching runtime + # image. "Publish whatever is missing" — the first contract — was not + # enough: 89d2a4e's version was already on npm, so a publish-only step + # would have no-opped and lost the image just the same. # - # Detect it by the only fact that matters — the version in the repo is - # not on the registry — and repair it. `changeset publish` skips every - # version already published, so this is a no-op on the normal path - # (where main's version IS the last released one) and a repair on this - # one. Never a silent success: if the version is still missing after - # publishing, the job fails. + # The two ways the invariant breaks, both observed within one day: + # - nothing published at all: changesets/action reaches its publish + # branch only with ZERO pending changesets, and an EMPTY changeset + # still counts, so a main carrying only empty ones prints "All + # changesets are empty; not creating PR" and returns — no version + # PR, no publish, and a GREEN run (#4898); + # - published, then died before reporting it (#4900, above). + # `changeset publish` skips versions already on the registry, so the + # repair is idempotent and the whole step is a no-op on the normal path, + # where main's version IS the last released one and its image exists. env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | version=$(node -p "require('./packages/cli/package.json').version") + + # ── npm ───────────────────────────────────────────────────────────── if npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then - echo "@objectstack/cli@$version is already on npm — nothing left to publish." - exit 0 + echo "npm: @objectstack/cli@$version is present." + else + echo "::warning::@objectstack/cli@$version is versioned in this repo but absent from npm, and the Changesets action did not publish it (#4898) — publishing it now." + printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + pnpm run release + if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then + echo "::error::publish ran but @objectstack/cli@$version is still not on npm" + exit 1 + fi + echo "::warning::Recovered npm packages and git tags. The GitHub Releases and the ADR-0087 D4 spec-changes attachment were NOT created — those only exist on the Changesets action's own publish path. Create them by hand if this release needs them." fi - echo "::warning::@objectstack/cli@$version is versioned in main but absent from npm and the Changesets action did not publish it (#4898) — publishing it now." - printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - pnpm run release - - if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then - echo "::error::publish ran but @objectstack/cli@$version is still not on npm" - exit 1 + # ── runtime image ─────────────────────────────────────────────────── + # Reported through the job outputs so the `docker` job builds it. A + # failed probe counts as MISSING on purpose: a redundant rebuild costs + # a few minutes, a wrongly-skipped one leaves a published npm version + # with no image and nothing to say so. + if token=$(curl -fsS "https://ghcr.io/token?scope=repository:${GITHUB_REPOSITORY}:pull&service=ghcr.io" 2>/dev/null) \ + && token=$(node -p 'JSON.parse(process.argv[1]).token' "$token" 2>/dev/null) \ + && curl -fsS -o /dev/null -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/${GITHUB_REPOSITORY}/manifests/$version" 2>/dev/null + then + echo "ghcr: image for $version is present — release is complete." + exit 0 fi + + echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job." { echo "published=true" echo "version=$version" } >> "$GITHUB_OUTPUT" - echo "::warning::Recovered by the fallback path — npm packages and git tags are published, but the GitHub Releases and the ADR-0087 D4 spec-changes attachment were NOT created (those only exist on the Changesets action's own publish path). Create them by hand if this release needs them." - name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4) # Rebuilds the change manifest with the api-surface diff against the @@ -242,7 +267,15 @@ jobs: # for every npm release. Called as a reusable workflow so the same build # can be re-run manually via workflow_dispatch (e.g. base-image CVE # rebuilds) — see docker-publish.yml. - if: needs.release.outputs.published == 'true' + # + # `!cancelled()` rather than the default implicit success(): the release job + # can publish to npm and THEN fail (89d2a4e died creating the spec GitHub + # Release, #4900). A dependent job guarded by success() is skipped for any + # upstream failure, so the image was lost to a fault that happened after the + # packages were already public. The `published` output — which the recovery + # step above sets when an image is missing — is the real gate; the job's + # exit status is not. + if: ${{ !cancelled() && needs.release.outputs.published == 'true' }} permissions: contents: read packages: write