Skip to content

Commit db82f2e

Browse files
os-zhuangclaude
andauthored
ci(release): make the release-completeness guard reachable, and let it guard the image too (#4900) (#4901)
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. Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89d2a4e commit db82f2e

1 file changed

Lines changed: 65 additions & 32 deletions

File tree

.github/workflows/release.yml

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -162,51 +162,76 @@ jobs:
162162
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
163163
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
164164

165-
- name: Publish a release the Changesets action left behind
165+
- name: Ensure this version actually shipped (npm + runtime image)
166166
id: recover-publish
167-
if: steps.changesets.outputs.published != 'true'
168-
# changesets/action reaches its publish branch ONLY when there are zero
169-
# pending changesets. An EMPTY changeset still counts as pending, so a
170-
# `main` carrying only empty ones lands in
171-
# All changesets are empty; not creating PR
172-
# and returns: no version PR, no publish, `published=false`, and a GREEN
173-
# run. The version bump is already committed by then, so the release
174-
# simply evaporates — 17.0.0-rc.2 sat versioned-in-repo and absent from
175-
# npm with Release reporting success, and `docker` skipped with it
176-
# (#4898). This is not an exotic state: `Check Changeset` sanctions the
177-
# empty changeset as the "this PR releases nothing" declaration, so any
178-
# ordinary docs/ci PR landing during the version-PR window produces it.
167+
# `!cancelled()` is load-bearing, not decoration. GitHub wraps an `if:`
168+
# naming no status function in an IMPLICIT success(), so the first
169+
# version of this step — `if: steps.changesets.outputs.published !=
170+
# 'true'` — could not fire in one of the two cases it exists for: the
171+
# changesets step itself failing. On 89d2a4e it did exactly that. npm
172+
# publish and the atomic tag push both succeeded, then creating the
173+
# @objectstack/spec GitHub Release failed on the API's 125k body limit
174+
# (#4900); the step went red, `published` stayed false, this step was
175+
# skipped, and the 17.0.0-rc.2 runtime image was silently lost.
176+
if: ${{ !cancelled() && steps.changesets.outputs.published != 'true' }}
177+
# It also guards an INVARIANT rather than performing an action: the
178+
# version in this repo must be on npm AND must have a matching runtime
179+
# image. "Publish whatever is missing" — the first contract — was not
180+
# enough: 89d2a4e's version was already on npm, so a publish-only step
181+
# would have no-opped and lost the image just the same.
179182
#
180-
# Detect it by the only fact that matters — the version in the repo is
181-
# not on the registry — and repair it. `changeset publish` skips every
182-
# version already published, so this is a no-op on the normal path
183-
# (where main's version IS the last released one) and a repair on this
184-
# one. Never a silent success: if the version is still missing after
185-
# publishing, the job fails.
183+
# The two ways the invariant breaks, both observed within one day:
184+
# - nothing published at all: changesets/action reaches its publish
185+
# branch only with ZERO pending changesets, and an EMPTY changeset
186+
# still counts, so a main carrying only empty ones prints "All
187+
# changesets are empty; not creating PR" and returns — no version
188+
# PR, no publish, and a GREEN run (#4898);
189+
# - published, then died before reporting it (#4900, above).
190+
# `changeset publish` skips versions already on the registry, so the
191+
# repair is idempotent and the whole step is a no-op on the normal path,
192+
# where main's version IS the last released one and its image exists.
186193
env:
187194
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
188195
run: |
189196
version=$(node -p "require('./packages/cli/package.json').version")
197+
198+
# ── npm ─────────────────────────────────────────────────────────────
190199
if npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
191-
echo "@objectstack/cli@$version is already on npm — nothing left to publish."
192-
exit 0
200+
echo "npm: @objectstack/cli@$version is present."
201+
else
202+
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."
203+
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc"
204+
git config user.name 'github-actions[bot]'
205+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
206+
pnpm run release
207+
if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
208+
echo "::error::publish ran but @objectstack/cli@$version is still not on npm"
209+
exit 1
210+
fi
211+
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."
193212
fi
194213
195-
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."
196-
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc"
197-
git config user.name 'github-actions[bot]'
198-
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
199-
pnpm run release
200-
201-
if ! npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then
202-
echo "::error::publish ran but @objectstack/cli@$version is still not on npm"
203-
exit 1
214+
# ── runtime image ───────────────────────────────────────────────────
215+
# Reported through the job outputs so the `docker` job builds it. A
216+
# failed probe counts as MISSING on purpose: a redundant rebuild costs
217+
# a few minutes, a wrongly-skipped one leaves a published npm version
218+
# with no image and nothing to say so.
219+
if token=$(curl -fsS "https://ghcr.io/token?scope=repository:${GITHUB_REPOSITORY}:pull&service=ghcr.io" 2>/dev/null) \
220+
&& token=$(node -p 'JSON.parse(process.argv[1]).token' "$token" 2>/dev/null) \
221+
&& curl -fsS -o /dev/null -H "Authorization: Bearer $token" \
222+
-H 'Accept: application/vnd.oci.image.index.v1+json' \
223+
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
224+
"https://ghcr.io/v2/${GITHUB_REPOSITORY}/manifests/$version" 2>/dev/null
225+
then
226+
echo "ghcr: image for $version is present — release is complete."
227+
exit 0
204228
fi
229+
230+
echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job."
205231
{
206232
echo "published=true"
207233
echo "version=$version"
208234
} >> "$GITHUB_OUTPUT"
209-
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."
210235
211236
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
212237
# Rebuilds the change manifest with the api-surface diff against the
@@ -242,7 +267,15 @@ jobs:
242267
# for every npm release. Called as a reusable workflow so the same build
243268
# can be re-run manually via workflow_dispatch (e.g. base-image CVE
244269
# rebuilds) — see docker-publish.yml.
245-
if: needs.release.outputs.published == 'true'
270+
#
271+
# `!cancelled()` rather than the default implicit success(): the release job
272+
# can publish to npm and THEN fail (89d2a4e died creating the spec GitHub
273+
# Release, #4900). A dependent job guarded by success() is skipped for any
274+
# upstream failure, so the image was lost to a fault that happened after the
275+
# packages were already public. The `published` output — which the recovery
276+
# step above sets when an image is missing — is the real gate; the job's
277+
# exit status is not.
278+
if: ${{ !cancelled() && needs.release.outputs.published == 'true' }}
246279
permissions:
247280
contents: read
248281
packages: write

0 commit comments

Comments
 (0)