From 9e4583c40477d2ae0212494087676d84cb304771 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:09:34 +0200 Subject: [PATCH 1/2] Guard npm latest and detect skipped releases in publish.yml (#33) On 2026-09-29 the 0.29.5 and 0.30.0 publish runs were cancelled: GitHub keeps one pending run per concurrency group, so a later merge's publish replaced them while they waited for approval. - publish.yml passes an explicit dist-tag to npm publish (scripts/release-plan.mjs, SemVer 2.0.0 comparison incl. prereleases, no dependency): latest, or backport-. for a version lower than the current latest, or next for a prerelease, so latest never moves backwards. Such a GitHub Release is not marked Latest. - The release job summary warns about CHANGELOG versions between the last published version and this one that are missing on npm. - ci.yml checks the top CHANGELOG heading (scripts/check-changelog-heading.mjs): exactly "## " into main; "## (unreleased)" is also accepted into release/**. - CONTRIBUTING.md, CLAUDE.md and the publish.yml header document the concurrency behaviour, the recovery and "merge release PRs one at a time". - test/release-scripts.spec.ts covers the scripts, release-state.sh end to end with a fake npm. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 + .github/workflows/publish.yml | 39 ++- CHANGELOG.md | 18 ++ CLAUDE.md | 49 +++- CONTRIBUTING.md | 34 ++- scripts/check-changelog-heading.mjs | 42 ++++ scripts/release-plan.d.mts | 16 ++ scripts/release-plan.mjs | 157 ++++++++++++ scripts/release-state.sh | 41 ++++ test/release-scripts.spec.ts | 361 ++++++++++++++++++++++++++++ 10 files changed, 742 insertions(+), 23 deletions(-) create mode 100644 scripts/check-changelog-heading.mjs create mode 100644 scripts/release-plan.d.mts create mode 100644 scripts/release-plan.mjs create mode 100644 test/release-scripts.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e655bbb..f57d5f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,14 @@ jobs: with: node-version: 24 cache: 'pnpm' + # The top CHANGELOG.md heading must be exactly `## ` (publish.yml takes + # the GitHub Release notes from that line). Into release/** a `## (unreleased)` + # heading is accepted too, so a heading still marked "(unreleased)" can never reach main. + # The target is the PR's base branch, or the pushed branch (main) when publish.yml calls this. + - name: Check the CHANGELOG.md heading + env: + TARGET_BRANCH: ${{ github.base_ref || github.ref_name }} + run: node scripts/check-changelog-heading.mjs - run: pnpm install --frozen-lockfile - run: pnpm run lint # Enforce the coverage thresholds (vitest.config.ts), not just that tests pass. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6b71d1c..77c2f6d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,20 @@ name: Publish Package # GitHub Release from the CHANGELOG section # A merge that does not bump `version`, or a re-run after a successful release, skips the # publish (and the approval) with a notice in the job summary. +# +# Concurrency (the `npm-publish` group on the publish job): an in-progress publish is never +# cancelled, but GitHub keeps at most ONE pending run per group. A newer merge's publish job +# replaces (cancels) an older one still waiting in the queue or for its approval, so that older +# version is never published. This happened on 2026-09-29 to 0.29.5 and 0.30.0 (#33). Hence: +# - Merge release PRs one at a time: wait for each publish to finish before the next merge. +# - The `release` job warns in the summary about any CHANGELOG version between the last +# published one and this one that is missing on npm (scripts/release-plan.mjs). +# - To recover, re-run the cancelled version's workflow run (its commit still carries that +# version). If a higher version is already `latest`, it is published under the +# `backport-.` dist-tag instead, so `latest` never moves backwards; a +# prerelease goes out under `next`. Its GitHub Release is then not marked "Latest". +# A re-run uses the workflow of its own commit: one older than this guard publishes with npm's +# default tag, so check `npm dist-tag ls` afterwards (CONTRIBUTING.md "Releases"). on: push: branches: @@ -36,7 +50,8 @@ jobs: with: node-version: 24 # Fails the job (rather than publishing) if npm cannot be queried; only a 404 means - # "not published". + # "not published". Also picks the dist-tag and warns about skipped (never published) + # CHANGELOG versions in the job summary. - id: state run: scripts/release-state.sh @@ -89,7 +104,7 @@ jobs: - run: npm install -g npm@latest - run: npm --version # Re-check right before publishing: the approval may have waited while another run - # published this version. + # published this version, or moved `latest` (which decides the dist-tag). - id: state run: scripts/release-state.sh - if: steps.state.outputs.action == 'publish' @@ -105,12 +120,18 @@ jobs: # provenance (publishConfig.provenance). Requires a Trusted Publisher configured for this # package on npmjs.com: repo glassnode/glassnode-api-ts-client, workflow publish.yml, # environment npm. + # The dist-tag is always explicit: `latest`, or `backport-.` for a version + # lower than the current `latest` (a re-run of a cancelled older release) and `next` for a + # prerelease, so `latest` never moves backwards (scripts/release-plan.mjs). - if: steps.state.outputs.action == 'publish' - run: npm publish + env: + DIST_TAG: ${{ steps.state.outputs.dist_tag }} + run: npm publish --tag "$DIST_TAG" - if: steps.state.outputs.action == 'publish' env: VERSION: ${{ steps.state.outputs.version }} - run: echo "Published \`glassnode-api@$VERSION\` to npm." >> "$GITHUB_STEP_SUMMARY" + DIST_TAG: ${{ steps.state.outputs.dist_tag }} + run: echo "Published \`glassnode-api@$VERSION\` to npm under the \`$DIST_TAG\` dist-tag." >> "$GITHUB_STEP_SUMMARY" # Tag the published commit. An existing tag on this commit is fine (re-run); one on another # commit is left alone with a warning, and the GitHub Release is then skipped. - name: Push the v tag @@ -147,13 +168,16 @@ jobs: exit 1 fi # The release notes are the package.json version's section of CHANGELOG.md (from its - # `## ` heading to the next `## ` heading). + # `## ` heading to the next `## ` heading). Only a version published as npm + # `latest` becomes the repository's "Latest" GitHub Release; a backport does not, and a + # prerelease is marked as one. - name: Create the GitHub Release if: steps.tag.outputs.release == 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.state.outputs.tag }} VERSION: ${{ steps.state.outputs.version }} + DIST_TAG: ${{ steps.state.outputs.dist_tag }} run: | if gh release view "$TAG" > /dev/null 2> view.err; then echo "GitHub Release \`$TAG\` already exists." >> "$GITHUB_STEP_SUMMARY" @@ -172,5 +196,8 @@ jobs: echo "::warning::CHANGELOG.md has no '## $VERSION' section; the release links to it instead." echo "See [CHANGELOG.md](https://github.com/$GITHUB_REPOSITORY/blob/$TAG/CHANGELOG.md)." > notes.md fi - gh release create "$TAG" --verify-tag --title "$TAG" --notes-file notes.md + flags=() + if [ "$DIST_TAG" != latest ]; then flags+=(--latest=false); fi + case "${VERSION%%+*}" in *-*) flags+=(--prerelease) ;; esac + gh release create "$TAG" --verify-tag --title "$TAG" --notes-file notes.md "${flags[@]}" echo "Created GitHub Release \`$TAG\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f51319..75d96e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,24 @@ No change to the published package (#50). - CI hardening: `publish.yml`'s `publish` job no longer restores the pnpm cache (`cache: 'pnpm'` dropped). It holds `id-token: write` and `contents: write`, and setup-node's docs advise against caching in privileged release jobs (cache poisoning). The other jobs keep their cache. +- Release guards (#33). On 2026-09-29 the 0.29.5 and 0.30.0 publish runs were cancelled: GitHub + keeps one pending run per concurrency group, so a later merge's publish replaced them while they + waited for approval. No change to the published package. + - `publish.yml` always passes an explicit dist-tag to `npm publish`: `latest`, or + `backport-.` for a version lower than the current `latest` (e.g. a re-run of a + cancelled release), or `next` for a prerelease, so `latest` never moves backwards. Such a + GitHub Release is not marked "Latest" (a prerelease is marked as one). The decision is in + `scripts/release-plan.mjs`: SemVer 2.0.0 comparison with prereleases, no new dependency. + - The release job summary warns about every `CHANGELOG.md` version between the last published + version and the one being released that is missing on npm. + - `ci.yml` checks the top `CHANGELOG.md` heading (`scripts/check-changelog-heading.mjs`): into + `main` it must be exactly `## `; into `release/**`, + `## (unreleased)` is accepted too. A heading still marked "(unreleased)" cannot reach + `main`. + - CONTRIBUTING.md "Releases" and the `publish.yml` header explain the concurrency behaviour, the + recovery (re-run; an older version gets a non-`latest` tag) and "merge release PRs one at a + time". The scripts are tested in `test/release-scripts.spec.ts`, `release-state.sh` with a + fake `npm`. ## 0.30.1 diff --git a/CLAUDE.md b/CLAUDE.md index 4089aee..768f238 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,9 @@ This document provides context for Claude when working with this project. real API drift: fix the schema, never the fixture or the test - `/examples` - Example usage patterns (own `package.json`; type-checked via `tsconfig.examples.json`) - `/scripts` - `smoke-timeout.mjs`, the plain-Node runtime smoke run on the Node floor (22) in CI; - `record-fixtures.mjs`, which records the contract fixtures (needs an API key; run only to refresh) + `record-fixtures.mjs`, which records the contract fixtures (needs an API key; run only to refresh); + the release tooling `release-state.sh` + `release-plan.mjs` (publish.yml) and + `check-changelog-heading.mjs` (ci.yml), tested by `test/release-scripts.spec.ts` - `/typecheck/x402-node16` - consumer type-check fixture (node16 resolution, `skipLibCheck: false`) - `/dist` - Compiled output (not checked into git) - `/api-docs` - Generated TypeDoc API reference (`pnpm run docs`; not checked into git, never @@ -212,14 +214,20 @@ Relative imports in `src/` are written `./foo.js` even though the file is `foo.t `.github/workflows/ci.yml` runs on pull requests to `main` and to `release/**` branches, and `publish.yml` calls it (`workflow_call`) as its `verify` job, so a release runs the same checks: -- `test` (Node 24): lint, `test:coverage` (thresholds in `vitest.config.ts`), `tsc` on - `tsconfig.test.json` and `tsconfig.examples.json`, an offline ts-node import of `../src` from - `examples/` (module-resolution guard, no example runs), `build`, `build:browser`, the - `typecheck/x402-node16` consumer check, `docs` (TypeDoc), `publint` and +- `test` (Node 24): the CHANGELOG heading check (below), lint, `test:coverage` (thresholds in + `vitest.config.ts`), `tsc` on `tsconfig.test.json` and `tsconfig.examples.json`, an offline + ts-node import of `../src` from `examples/` (module-resolution guard, no example runs), `build`, + `build:browser`, the `typecheck/x402-node16` consumer check, `docs` (TypeDoc), `publint` and `@arethetypeswrong/cli --pack .`. - `compat-node-floor` (Node 22, the `engines` floor): build, CJS `require` smoke, `scripts/smoke-timeout.mjs`. +The CHANGELOG heading check (`scripts/check-changelog-heading.mjs`, target = the PR's base branch, +or `main` when `publish.yml` calls `ci.yml`): the top `## ` heading of `CHANGELOG.md` must be +exactly `## `; into `release/**`, `## (unreleased)` is also accepted +(not compared with package.json, which a release branch does not bump). So a heading still marked +"(unreleased)" fails the final release-branch PR into `main` until it is renamed. + `.github/workflows/docs.yml` builds the API reference on every push to `main` and deploys it to GitHub Pages (https://glassnode.github.io/glassnode-api-ts-client/). It requires the repo setting Settings → Pages → Source: **GitHub Actions**. @@ -243,7 +251,9 @@ A major release is prepared on a long-lived `release/**` branch (e.g. `release/1 `## 1.0.0 (unreleased)`-style heading at the top. The final PR from the release branch to `main` bumps `version` (e.g. to `1.0.0`) and renames that heading to exactly `## ` (e.g. `## 1.0.0`): `publish.yml` takes the GitHub Release notes from the line that equals - `## `, so any suffix left on the heading leaves the Release without notes. Its merge + `## `, so any suffix left on the heading leaves the Release without notes. `ci.yml` + enforces this (the CHANGELOG heading check, see [CI](#ci)): into `release/**` the + `(unreleased)` heading passes, into `main` only `## ` does. Its merge publishes as usual. - **CI:** `ci.yml` runs on PRs into `release/**`, but that is not a merge gate by itself: the checks block a merge only if a ruleset for `release/**` requires them. Only PRs are CI-checked, not @@ -265,15 +275,30 @@ A major release is prepared on a long-lived `release/**` branch (e.g. `release/1 Only an E404 means "not published"; any other `npm view` failure (network, registry error) fails the job, never publishes. If the version is already on npm (a merge without a bump, a re-run), the run ends there with a notice in the job summary, and no approval is requested. + It also runs `npm view glassnode-api versions dist-tags` (same failure policy) and + `scripts/release-plan.mjs`, which picks the dist-tag and warns in the job summary about + skipped releases: `CHANGELOG.md` versions between the last published version (the highest + on npm below this one) and this one that are missing on npm. 3. `publish` (`needs: [verify, release]`) runs in the `npm` **environment**, so it waits for a - required reviewer to approve. It re-checks npm, builds, runs `npm publish`, pushes the - `v` tag on the published commit and creates a GitHub Release from the version's - `CHANGELOG.md` section. An existing tag on the same commit or an existing Release is fine; a - tag on another commit is left alone with a warning. If the version is on npm from this very - commit (npm records its `gitHead`), a re-run only (re)creates the missing tag and Release. + required reviewer to approve. It re-checks npm (and the dist-tag), builds, runs + `npm publish --tag `, pushes the `v` tag on the published commit and creates + a GitHub Release from the version's `CHANGELOG.md` section. An existing tag on the same commit + or an existing Release is fine; a tag on another commit is left alone with a warning. If the + version is on npm from this very commit (npm records its `gitHead`), a re-run only (re)creates + the missing tag and Release. - Only `publish` has write permissions: `id-token: write` (OIDC) and `contents: write` (the tag and Release, via `GITHUB_TOKEN`). A `npm-publish` concurrency group serializes publishes and never - cancels one in progress. + cancels one in progress, but GitHub keeps only **one pending** run per group: a newer merge's + publish replaces an older one still queued or awaiting approval, and that version is never + published (0.29.5 and 0.30.0, 2026-09-29, #33). Merge release PRs one at a time. To recover, + re-run the skipped version's run (CONTRIBUTING.md "Releases"). +- **Dist-tag guard** (`scripts/release-plan.mjs`, SemVer 2.0.0 precedence incl. prereleases, no + dependency): `npm publish` always gets an explicit `--tag`: `latest`, or + `backport-.` for a version lower than the current `latest` (such a re-run), or + `next` for a prerelease, so `latest` never moves backwards. Not `v.`: npm rejects a + tag name that is a valid semver range. That GitHub Release gets `--latest=false` (and + `--prerelease` for a prerelease version). A re-run uses its own commit's workflow, so a run from + before this guard (pre-1.0) publishes with npm's default tag: check `npm dist-tag ls` afterwards. - It uses **npm Trusted Publishing (OIDC)** with provenance — there is **no `NPM_TOKEN` secret**. The Trusted Publisher for `glassnode-api` on npmjs.com must name repo `glassnode/glassnode-api-ts-client`, workflow `publish.yml` and environment `npm`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ac286d..8cd8569 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ Project layout: - `src/types/` - Zod schemas and the types inferred from them - `test/` - Vitest tests, including the contract tests in `test/contract.spec.ts` - `examples/` - runnable usage examples (their own `package.json`) -- `scripts/` - the Node-floor smoke test and the contract fixture recorder +- `scripts/` - the Node-floor smoke test, the contract fixture recorder and the release tooling - `typecheck/x402-node16/` - a consumer type-check fixture Add or update tests for what you change. The public API is what `src/index.ts` and `src/x402.ts` @@ -66,10 +66,12 @@ it. A PR merged without a bump publishes nothing. `CHANGELOG.md` entry, with migration notes for breaking changes, under a single `## 1.0.0 (unreleased)`-style heading. The final PR from the release branch to `main` bumps the version (e.g. to `1.0.0`) and renames that heading to exactly `## 1.0.0`, the form the release -workflow extracts the GitHub Release notes from. The release branch takes merges from `main` -during its lifetime, and right after any CI change lands there: a PR's CI runs the `ci.yml` of its -merge commit, so the release branch needs the current one. Only PRs into it are CI-checked, not -direct pushes; the final PR to `main` checks the combined result. +workflow extracts the GitHub Release notes from. CI enforces it: into a release branch the top +heading may be `## (unreleased)`, into `main` it must be exactly `## ` +(`node scripts/check-changelog-heading.mjs ` runs the check locally). The release +branch takes merges from `main` during its lifetime, and right after any CI change lands there: a +PR's CI runs the `ci.yml` of its merge commit, so the release branch needs the current one. Only +PRs into it are CI-checked, not direct pushes; the final PR to `main` checks the combined result. ### 4. Run the full local check list @@ -113,6 +115,28 @@ waits for a maintainer to approve it (the `npm` environment). After the approval new version to npm with provenance, tags the commit `v` and creates a GitHub Release from your `CHANGELOG.md` entry. If that version is already on npm, the workflow skips the release. +## Releases (for maintainers) + +**Merge release PRs one at a time.** Publishes run one after another (the `npm-publish` +concurrency group), and one in progress is never cancelled. But GitHub keeps only **one pending** +run per group: when a newer merge's publish job queues up, it replaces an older one that is still +waiting, including one waiting for its approval. That older version is then never published. This +happened to 0.29.5 and 0.30.0 on 2026-09-29. Wait for each release to finish (published, tagged, +GitHub Release created) before merging the next PR that bumps the version. + +**Detecting a skipped version.** Every release run's summary lists, as a warning, each +`CHANGELOG.md` version between the last one published to npm and the one being released that is +missing on npm. + +**Recovering.** Open the skipped version's workflow run (Actions → Publish Package, the merge +commit that carries that version) and choose **Re-run all jobs**, then approve the publish. If a +higher version is already npm's `latest`, the older one is published under the +`backport-.` dist-tag (e.g. `backport-0.29`) instead, so `latest` never moves +backwards, and its GitHub Release is not marked "Latest". A prerelease version is published under +`next`. A run from a commit older than this guard (1.0.0) publishes with npm's default tag, so after +re-running one, check `npm dist-tag ls glassnode-api`; if `latest` moved backwards, a maintainer +restores it with `npm dist-tag add glassnode-api@ latest`. + ## Contract fixtures `test/fixtures/contract/` holds real API responses recorded by `scripts/record-fixtures.mjs`. diff --git a/scripts/check-changelog-heading.mjs b/scripts/check-changelog-heading.mjs new file mode 100644 index 0000000..4c72eee --- /dev/null +++ b/scripts/check-changelog-heading.mjs @@ -0,0 +1,42 @@ +// CI check (.github/workflows/ci.yml): the top `## ` heading of CHANGELOG.md must name the release. +// publish.yml takes the GitHub Release notes from the line that equals `## `, +// so a heading with a suffix (e.g. still "(unreleased)") would release without notes. +// +// - Target `main` (and anything that is not `release/**`): exactly `## `. +// - Target `release/**`: that, or `## (unreleased)`. A release branch carries no version +// bump (package.json still has the last published version), so the unreleased heading is not +// compared with package.json. The final PR from the release branch to `main` must rename it. +// +// Usage: node scripts/check-changelog-heading.mjs [target-branch] +// The target defaults to $TARGET_BRANCH, then `main`. Env CHANGELOG and PACKAGE_JSON override +// the file paths (for tests). Exits 1 with a GitHub `::error::` annotation on failure. +/* global process, console */ +import { readFileSync } from 'node:fs'; +import { parseSemver } from './release-plan.mjs'; + +/** Returns null when `heading` is acceptable for `target`, or the reason it is not. */ +function checkHeading(heading, version, target) { + const release = /^release\//.test(target); + const expected = `## ${version}`; + if (heading === null) return `CHANGELOG.md has no "## " heading; expected "${expected}".`; + if (heading === expected) return null; + const unreleased = /^## (\S+) \(unreleased\)$/.exec(heading); + if (release && unreleased && parseSemver(unreleased[1])) return null; + const allowed = release ? `"${expected}" or "## (unreleased)"` : `"${expected}"`; + const hint = + unreleased && !release + ? ' A heading still marked "(unreleased)" cannot reach main: bump package.json and rename it.' + : ''; + return `The top CHANGELOG.md heading is "${heading}"; into ${target} it must be ${allowed} (package.json version ${version}).${hint}`; +} + +const target = process.argv[2] || process.env.TARGET_BRANCH || 'main'; +const changelog = readFileSync(process.env.CHANGELOG || 'CHANGELOG.md', 'utf8'); +const { version } = JSON.parse(readFileSync(process.env.PACKAGE_JSON || 'package.json', 'utf8')); +const heading = changelog.split(/\r?\n/).find((line) => line.startsWith('## ')) ?? null; +const error = checkHeading(heading, version, target); +if (error) { + console.error(`::error file=CHANGELOG.md::${error}`); + process.exit(1); +} +console.log(`CHANGELOG.md heading "${heading}" is valid for a PR into ${target}.`); diff --git a/scripts/release-plan.d.mts b/scripts/release-plan.d.mts new file mode 100644 index 0000000..bc3a7df --- /dev/null +++ b/scripts/release-plan.d.mts @@ -0,0 +1,16 @@ +// Types for scripts/release-plan.mjs, so test/release-scripts.spec.ts type-checks its import. +export interface SemVer { + major: bigint; + minor: bigint; + patch: bigint; + prerelease: string[]; +} +export function parseSemver(version: string): SemVer | null; +export function compareSemver(a: string, b: string): -1 | 0 | 1; +export function distTagFor(version: string, latest: string | null): string; +export function changelogVersions(changelog: string): string[]; +export function skippedVersions(version: string, published: string[], changelog: string): string[]; +export function parseNpmView( + raw: string, + rc: string | number +): { latest: string | null; versions: string[] }; diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000..a3e3448 --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,157 @@ +// Release planning for .github/workflows/publish.yml, called by scripts/release-state.sh. It only +// computes; it never publishes, tags or talks to npm itself (the caller passes the `npm view` +// output in). Plain Node, no dependencies. Tested by test/release-scripts.spec.ts. +// +// CLI: node scripts/release-plan.mjs +// Env: +// VERSION the version being released (package.json `version`) +// NPM_VIEW stdout of `npm view versions dist-tags --json` +// NPM_RC that command's exit code +// CHANGELOG path of the changelog (default CHANGELOG.md) +// Prints, one per line: +// latest= +// dist_tag= +// skipped= +// Exits 1 (message on stderr) when VERSION is not semver or npm could not be queried: only an +// E404 means "package not on npm yet"; any other failure must fail the job, never publish. +/* global process, console */ +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +// The official SemVer 2.0.0 regex (semver.org), with named groups. +const SEMVER = + /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** Parses a SemVer 2.0.0 version, or returns null. Numbers are BigInt: no precision limit. */ +export function parseSemver(version) { + const m = SEMVER.exec(String(version)); + if (!m) return null; + const { major, minor, patch, prerelease } = m.groups; + return { + major: BigInt(major), + minor: BigInt(minor), + patch: BigInt(patch), + prerelease: prerelease ? prerelease.split('.') : [], + }; +} + +function cmp(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * SemVer 2.0.0 precedence: -1, 0 or 1. Build metadata is ignored; a prerelease sorts before its + * release; prerelease identifiers compare numerically when both are numeric, numeric before + * alphanumeric, otherwise ASCII order, and a shorter prefix sorts first. + */ +export function compareSemver(a, b) { + const x = parseSemver(a); + const y = parseSemver(b); + if (!x) throw new Error(`not a semver version: ${a}`); + if (!y) throw new Error(`not a semver version: ${b}`); + const core = cmp(x.major, y.major) || cmp(x.minor, y.minor) || cmp(x.patch, y.patch); + if (core) return core; + if (!x.prerelease.length && !y.prerelease.length) return 0; + if (!x.prerelease.length) return 1; // 1.0.0 > 1.0.0-rc.1 + if (!y.prerelease.length) return -1; + for (let i = 0; i < Math.max(x.prerelease.length, y.prerelease.length); i++) { + const p = x.prerelease[i]; + const q = y.prerelease[i]; + if (p === undefined) return -1; + if (q === undefined) return 1; + const pn = /^\d+$/.test(p); + const qn = /^\d+$/.test(q); + const c = pn && qn ? cmp(BigInt(p), BigInt(q)) : pn ? -1 : qn ? 1 : cmp(p, q); + if (c) return c; + } + return 0; +} + +/** + * The dist-tag to publish `version` under, given the current `latest` (null when the package or + * the tag does not exist), so `latest` never moves backwards or onto a prerelease: + * - lower than `latest`: `backport-.` (e.g. a cancelled 0.29.5 re-run after 0.30.1 + * is out). It names the release line, and it cannot be read as a semver range, which npm + * rejects as a tag name (a `v0.29`-style tag would be one). + * - a prerelease, not lower: `next`, npm's conventional prerelease tag. + * - otherwise (higher, equal, no `latest` yet): `latest`. + */ +export function distTagFor(version, latest) { + const v = parseSemver(version); + if (!v) throw new Error(`not a semver version: ${version}`); + if (latest && compareSemver(version, latest) < 0) return `backport-${v.major}.${v.minor}`; + if (v.prerelease.length) return 'next'; + return 'latest'; +} + +/** The released versions in a changelog: every `## ` heading, exactly (no suffix). */ +export function changelogVersions(changelog) { + const out = []; + for (const line of changelog.split(/\r?\n/)) { + const m = /^## (\S+)$/.exec(line); + if (m && parseSemver(m[1])) out.push(m[1]); + } + return out; +} + +/** + * CHANGELOG versions that were never published: above the highest npm version below `version` + * (the last one published before it) and below `version`, and not on npm. These are releases + * whose publish run was cancelled or never approved. Sorted ascending. + */ +export function skippedVersions(version, published, changelog) { + const onNpm = new Set(published); + const before = published.filter((p) => parseSemver(p) && compareSemver(p, version) < 0); + const last = before.sort(compareSemver).at(-1) ?? null; + return changelogVersions(changelog) + .filter((v) => !onNpm.has(v)) + .filter((v) => compareSemver(v, version) < 0 && (!last || compareSemver(v, last) > 0)) + .sort(compareSemver); +} + +/** + * Interprets `npm view versions dist-tags --json`: `{ latest, versions }`, with + * `{ latest: null, versions: [] }` for an E404. Throws on any other failure. + */ +export function parseNpmView(raw, rc) { + let data; + try { + data = JSON.parse(raw); + } catch { + throw new Error(`npm view exited ${rc} with unparseable output`); + } + if (String(rc) !== '0') { + const code = data && data.error && data.error.code; + if (code === 'E404') return { latest: null, versions: [] }; + throw new Error(`npm view exited ${rc} with ${code || 'no error code'}`); + } + if (!data || typeof data !== 'object') { + throw new Error(`npm view exited 0 but returned ${JSON.stringify(data)}`); + } + // One published version prints a bare string instead of an array. + const versions = [].concat(data.versions ?? []); + const latest = (data['dist-tags'] && data['dist-tags'].latest) || null; + if (!versions.every((v) => typeof v === 'string') || (latest && !parseSemver(latest))) { + throw new Error(`npm view exited 0 but returned ${JSON.stringify(data)}`); + } + return { latest, versions }; +} + +function main() { + const { VERSION: version = '', NPM_VIEW: raw = '', NPM_RC: rc = '' } = process.env; + if (!parseSemver(version)) throw new Error(`package.json version is not semver: ${version}`); + const { latest, versions } = parseNpmView(raw, rc); + const changelog = readFileSync(process.env.CHANGELOG || 'CHANGELOG.md', 'utf8'); + console.log(`latest=${latest ?? ''}`); + console.log(`dist_tag=${distTagFor(version, latest)}`); + console.log(`skipped=${skippedVersions(version, versions, changelog).join(' ')}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} diff --git a/scripts/release-state.sh b/scripts/release-state.sh index c4b5aff..8bc0879 100755 --- a/scripts/release-state.sh +++ b/scripts/release-state.sh @@ -11,6 +11,15 @@ # so only (re)create the tag and the GitHub Release # action=skip the version is on npm from another commit (a merge that did not bump # `version`, or a later re-run): nothing to do +# dist_tag= the npm dist-tag to publish under (scripts/release-plan.mjs): `latest`, or +# `backport-.` when the version is lower than the current +# `latest` (a re-run of a cancelled older release), or `next` for a prerelease, +# so `latest` never moves backwards +# latest= the current `latest` dist-tag (empty when the package is not on npm) +# +# It also warns (annotation and job summary) about skipped releases: CHANGELOG versions between +# the last published version and this one that are not on npm (their publish run was cancelled +# by a later merge or never approved). # # `npm view` failure modes: only an E404 means "not published". Any other failure (network, # registry error, unparseable output) exits non-zero, so the job fails instead of publishing. @@ -59,6 +68,20 @@ state=$( ' ) +# The package's versions and dist-tags, for the dist-tag and the skipped-release check. Same +# failure policy: an E404 (package never published) is fine, any other failure fails the job. +set +e +pkg_view=$(npm view "$name" versions dist-tags --json --prefer-online 2>/dev/null) +pkg_rc=$? +set -e +plan=$(NPM_VIEW="$pkg_view" NPM_RC="$pkg_rc" VERSION="$version" node "$(dirname "$0")/release-plan.mjs") || { + echo "::error::Could not plan the release of $name@$version (see above)" >&2 + exit 1 +} +dist_tag=$(sed -n 's/^dist_tag=//p' <<<"$plan") +latest=$(sed -n 's/^latest=//p' <<<"$plan") +skipped=$(sed -n 's/^skipped=//p' <<<"$plan") + case "$state" in missing) action='publish' @@ -82,10 +105,28 @@ esac echo "version=$version" echo "tag=$tag" echo "action=$action" + echo "dist_tag=$dist_tag" + echo "latest=$latest" } >>"$out" echo "### Release: $action" >>"$summary" echo "$msg" >>"$summary" +if [ "$dist_tag" != latest ]; then + echo "" >>"$summary" + if [ "$dist_tag" = next ]; then + echo "\`$version\` is a prerelease: it goes out under the \`next\` dist-tag, \`latest\` stays at \`${latest:-none}\`." >>"$summary" + else + echo "\`$version\` is lower than \`latest\` (\`$latest\`): it goes out under the \`$dist_tag\` dist-tag and \`latest\` does not move." >>"$summary" + fi +fi +if [ -n "$skipped" ]; then + echo "::warning::CHANGELOG versions never published to npm (publish run cancelled or not approved): $skipped" + { + echo "" + echo "> [!WARNING]" + echo "> These CHANGELOG versions, between the last published version and \`$version\`, are not on npm (their publish run was cancelled by a later merge or never approved): \`${skipped// /\`, \`}\`. See CONTRIBUTING.md \"Releases\" to recover." + } >>"$summary" +fi if [ "$action" = publish ]; then echo "::notice::$name@$version is not on npm: will publish" else diff --git a/test/release-scripts.spec.ts b/test/release-scripts.spec.ts new file mode 100644 index 0000000..0f9191a --- /dev/null +++ b/test/release-scripts.spec.ts @@ -0,0 +1,361 @@ +// Tests for the release tooling in scripts/ (publish.yml and ci.yml run it; it is not shipped): +// release-plan.mjs (semver, dist-tag, skipped versions), release-state.sh end to end with a fake +// `npm` on PATH, and check-changelog-heading.mjs. +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +type Plan = typeof import('../scripts/release-plan.mjs'); +let plan: Plan; +beforeAll(async () => { + plan = await import('../scripts/release-plan.mjs'); +}); + +const scripts = resolve(__dirname, '../scripts'); +const dirs: string[] = []; +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'release-scripts-')); + dirs.push(dir); + return dir; +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const CHANGELOG = [ + '# Changelog', + '', + '## 0.30.1', + '', + '- fix', + '', + '## 0.30.0', + '', + '## 0.29.5', + '', + '## 0.29.4', + '', + '## 0.29.3', + '', +].join('\n'); + +describe('compareSemver', () => { + it('orders versions by SemVer 2.0.0 precedence', () => { + // The precedence example from semver.org, plus numeric (not string) core comparison. + const ordered = [ + '0.9.0', + '0.10.0', + '1.0.0-alpha', + '1.0.0-alpha.1', + '1.0.0-alpha.beta', + '1.0.0-beta', + '1.0.0-beta.2', + '1.0.0-beta.11', + '1.0.0-rc.1', + '1.0.0', + '1.0.1', + '1.1.0', + '2.0.0', + ]; + for (let i = 0; i < ordered.length; i++) { + for (let j = 0; j < ordered.length; j++) { + expect(plan.compareSemver(ordered[i], ordered[j])).toBe(Math.sign(i - j)); + } + } + }); + + it('ignores build metadata and rejects non-semver input', () => { + expect(plan.compareSemver('1.0.0+build.1', '1.0.0+build.2')).toBe(0); + expect(plan.compareSemver('1.0.0-rc.1+b', '1.0.0')).toBe(-1); + expect(() => plan.compareSemver('v1.0.0', '1.0.0')).toThrow(/not a semver/); + expect(() => plan.compareSemver('1.0', '1.0.0')).toThrow(/not a semver/); + expect(plan.parseSemver('01.0.0')).toBeNull(); + }); +}); + +describe('distTagFor', () => { + it.each([ + ['older than latest', '0.29.5', '0.30.1', 'backport-0.29'], + ['older patch in the same line', '0.30.0', '0.30.1', 'backport-0.30'], + ['older major', '0.30.2', '1.0.0', 'backport-0.30'], + ['newer than latest', '1.0.0', '0.30.1', 'latest'], + ['equal to latest (re-run)', '0.30.1', '0.30.1', 'latest'], + ['no latest (first publish)', '0.1.0', null, 'latest'], + ['prerelease above latest', '1.0.0-rc.1', '0.30.1', 'next'], + ['prerelease with no latest', '1.0.0-rc.1', null, 'next'], + ['prerelease below latest', '1.0.0-rc.1', '1.0.0', 'backport-1.0'], + ['release above a prerelease latest', '1.0.0', '1.0.0-rc.1', 'latest'], + ])('%s', (_, version, latest, expected) => { + expect(plan.distTagFor(version, latest)).toBe(expected); + }); + + it('never picks a tag npm would read as a semver range', () => { + // npm rejects dist-tags that parse as a range; `v0.29`-style names would. + expect(plan.distTagFor('0.29.5', '0.30.1')).not.toMatch(/^v?\d/); + }); +}); + +describe('skippedVersions', () => { + it('lists CHANGELOG versions between the last published one and this one missing on npm', () => { + // 2026-09-29: the 0.29.5 and 0.30.0 publish runs were cancelled by the 0.30.1 merge. + const published = ['0.29.2', '0.29.3', '0.29.4']; + expect(plan.skippedVersions('0.30.1', published, CHANGELOG)).toEqual(['0.29.5', '0.30.0']); + // Still reported on a re-run after 0.30.1 is out: "last" is the last one below this version. + expect(plan.skippedVersions('0.30.1', [...published, '0.30.1'], CHANGELOG)).toEqual([ + '0.29.5', + '0.30.0', + ]); + }); + + it('is empty when nothing was skipped, and ignores unreleased headings', () => { + const changelog = `## 1.0.0 (unreleased)\n\n${CHANGELOG}`; + const all = ['0.29.3', '0.29.4', '0.29.5', '0.30.0']; + expect(plan.skippedVersions('0.30.1', all, changelog)).toEqual([]); + expect(plan.changelogVersions(changelog)).toEqual([ + '0.30.1', + '0.30.0', + '0.29.5', + '0.29.4', + '0.29.3', + ]); + }); + + it('lists every earlier CHANGELOG version when nothing is published yet', () => { + expect(plan.skippedVersions('0.29.5', [], CHANGELOG)).toEqual(['0.29.3', '0.29.4']); + }); +}); + +describe('parseNpmView', () => { + it('reads versions and the latest dist-tag', () => { + const raw = JSON.stringify({ versions: ['0.1.0', '0.2.0'], 'dist-tags': { latest: '0.2.0' } }); + expect(plan.parseNpmView(raw, 0)).toEqual({ latest: '0.2.0', versions: ['0.1.0', '0.2.0'] }); + }); + + it('accepts a single version printed as a string and a missing latest tag', () => { + const raw = JSON.stringify({ versions: '0.1.0', 'dist-tags': { next: '0.1.0' } }); + expect(plan.parseNpmView(raw, '0')).toEqual({ latest: null, versions: ['0.1.0'] }); + }); + + it('treats E404 as "never published" and fails on anything else', () => { + const e404 = JSON.stringify({ error: { code: 'E404' } }); + expect(plan.parseNpmView(e404, 1)).toEqual({ latest: null, versions: [] }); + const timeout = JSON.stringify({ error: { code: 'ETIMEDOUT' } }); + expect(() => plan.parseNpmView(timeout, 1)).toThrow(/ETIMEDOUT/); + expect(() => plan.parseNpmView('', 1)).toThrow(/unparseable/); + expect(() => plan.parseNpmView('"0.1.0"', 0)).toThrow(/returned/); + const badLatest = JSON.stringify({ versions: [], 'dist-tags': { latest: 'nope' } }); + expect(() => plan.parseNpmView(badLatest, 0)).toThrow(/returned/); + }); +}); + +describe('release-state.sh (fake npm)', () => { + const SHA = 'a'.repeat(40); + + interface Npm { + /** `npm view glassnode-api@ version gitHead --json`: stdout and exit code. */ + version: [string, number]; + /** `npm view glassnode-api versions dist-tags --json`: stdout and exit code. */ + pkg: [string, number]; + } + const e404: [string, number] = [JSON.stringify({ error: { code: 'E404' } }), 1]; + const pkg = (versions: string[], latest?: string): [string, number] => [ + JSON.stringify({ versions, 'dist-tags': latest ? { latest } : {} }), + 0, + ]; + + function run(version: string, npm: Npm, changelog = CHANGELOG) { + const dir = tempDir(); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'glassnode-api', version })); + writeFileSync(join(dir, 'CHANGELOG.md'), changelog); + const fakeNpm = join(dir, 'bin', 'npm'); + mkdirSync(join(dir, 'bin')); + // $2 is the package spec: `@` for the version query, `` otherwise. + writeFileSync( + fakeNpm, + [ + '#!/bin/sh', + 'case "$2" in', + ' *@*) printf "%s" "$FAKE_VERSION_OUT"; exit "$FAKE_VERSION_RC" ;;', + ' *) printf "%s" "$FAKE_PKG_OUT"; exit "$FAKE_PKG_RC" ;;', + 'esac', + '', + ].join('\n') + ); + chmodSync(fakeNpm, 0o755); + const output = join(dir, 'output'); + const summary = join(dir, 'summary'); + writeFileSync(output, ''); + writeFileSync(summary, ''); + const res = spawnSync('bash', [join(scripts, 'release-state.sh')], { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${join(dir, 'bin')}:${process.env.PATH}`, + GITHUB_SHA: SHA, + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: summary, + FAKE_VERSION_OUT: npm.version[0], + FAKE_VERSION_RC: String(npm.version[1]), + FAKE_PKG_OUT: npm.pkg[0], + FAKE_PKG_RC: String(npm.pkg[1]), + }, + }); + const outputs = Object.fromEntries( + readFileSync(output, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => line.split(/=(.*)/s).slice(0, 2)) + ); + return { ...res, outputs, summary: readFileSync(summary, 'utf8') }; + } + + it('publishes a new version above latest under `latest`', () => { + const r = run('0.30.1', { + version: e404, + pkg: pkg(['0.29.4', '0.29.5', '0.30.0'], '0.30.0'), + }); + expect(r.status).toBe(0); + expect(r.outputs).toEqual({ + version: '0.30.1', + tag: 'v0.30.1', + action: 'publish', + dist_tag: 'latest', + latest: '0.30.0', + }); + expect(r.summary).not.toContain('WARNING'); + }); + + it('publishes a version below latest under a backport tag and warns about skipped ones', () => { + // Re-run of the cancelled 0.30.0 after 0.30.1 is out; 0.29.5 is still missing too. + const r = run('0.30.0', { version: e404, pkg: pkg(['0.29.4', '0.30.1'], '0.30.1') }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ action: 'publish', dist_tag: 'backport-0.30' }); + expect(r.summary).toContain('`latest` does not move'); + expect(r.summary).toContain('`0.29.5`'); + expect(r.stdout).toContain('::warning::'); + }); + + it('warns about the versions skipped before a new release', () => { + const r = run('0.30.1', { version: e404, pkg: pkg(['0.29.3', '0.29.4'], '0.29.4') }); + expect(r.outputs).toMatchObject({ action: 'publish', dist_tag: 'latest' }); + expect(r.summary).toContain('`0.29.5`, `0.30.0`'); + }); + + it('only (re)tags a version published from this commit, keeping `latest`', () => { + const view = JSON.stringify({ version: '0.30.1', gitHead: SHA }); + const r = run('0.30.1', { + version: [view, 0], + pkg: pkg(['0.29.5', '0.30.0', '0.30.1'], '0.30.1'), + }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ action: 'tag-only', dist_tag: 'latest', latest: '0.30.1' }); + }); + + it('skips a version published from another commit', () => { + const view = JSON.stringify({ version: '0.30.1', gitHead: 'b'.repeat(40) }); + const r = run('0.30.1', { version: [view, 0], pkg: pkg(['0.30.1'], '0.30.1') }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ action: 'skip' }); + }); + + it('publishes the first version of a package that is not on npm under `latest`', () => { + const r = run('0.29.3', { version: e404, pkg: e404 }, '## 0.29.3\n'); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ action: 'publish', dist_tag: 'latest', latest: '' }); + }); + + it('publishes a prerelease under `next`', () => { + const r = run('1.0.0-rc.1', { version: e404, pkg: pkg(['0.30.1'], '0.30.1') }); + expect(r.status).toBe(0); + expect(r.outputs).toMatchObject({ action: 'publish', dist_tag: 'next' }); + expect(r.summary).toContain('prerelease'); + }); + + it('fails, never publishes, when npm cannot be queried', () => { + const timeout: [string, number] = [JSON.stringify({ error: { code: 'ETIMEDOUT' } }), 1]; + const pkgFails = run('0.30.1', { version: e404, pkg: timeout }); + expect(pkgFails.status).not.toBe(0); + expect(pkgFails.stderr).toContain('ETIMEDOUT'); + expect(pkgFails.outputs).toEqual({}); + const versionFails = run('0.30.1', { version: timeout, pkg: pkg(['0.30.0'], '0.30.0') }); + expect(versionFails.status).not.toBe(0); + expect(versionFails.outputs).toEqual({}); + }); +}); + +describe('check-changelog-heading.mjs', () => { + function check(changelog: string, version: string, target: string) { + const dir = tempDir(); + writeFileSync(join(dir, 'CHANGELOG.md'), changelog); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ version })); + return spawnSync('node', [join(scripts, 'check-changelog-heading.mjs'), target], { + encoding: 'utf8', + env: { + ...process.env, + CHANGELOG: join(dir, 'CHANGELOG.md'), + PACKAGE_JSON: join(dir, 'package.json'), + }, + }); + } + const log = (heading: string) => `# Changelog\n\n${heading}\n\n- change\n\n## 0.30.0\n`; + + it.each([ + ['main, exact version', log('## 0.30.1'), '0.30.1', 'main'], + ['release branch, exact version', log('## 1.0.0'), '1.0.0', 'release/1.0'], + [ + 'release branch, unreleased (package.json not compared)', + log('## 1.0.0 (unreleased)'), + '0.30.1', + 'release/1.0', + ], + ])('passes: %s', (_, changelog, version, target) => { + const r = check(changelog, version, target); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + }); + + it.each([ + [ + 'main, unreleased heading', + log('## 1.0.0 (unreleased)'), + '1.0.0', + 'main', + /cannot reach main/, + ], + ['main, version mismatch', log('## 0.30.2'), '0.30.1', 'main', /must be "## 0.30.1"/], + [ + 'release branch, version mismatch', + log('## 0.30.2'), + '0.30.1', + 'release/1.0', + /"## 0.30.1" or/, + ], + [ + 'release branch, unreleased non-semver', + log('## 1.0 (unreleased)'), + '0.30.1', + 'release/1.0', + /must be/, + ], + ['release branch, other suffix', log('## 1.0.0 (draft)'), '0.30.1', 'release/1.0', /must be/], + ['trailing whitespace', log('## 0.30.1 '), '0.30.1', 'main', /must be/], + ['no heading', '# Changelog\n', '0.30.1', 'main', /no "## " heading/], + ])('fails: %s', (_, changelog, version, target, message) => { + const r = check(changelog, version, target); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/^::error file=CHANGELOG.md::/); + expect(r.stderr).toMatch(message); + }); + + it('accepts the repository CHANGELOG.md for its own branch rules', () => { + // release/1.0 carries "## 1.0.0 (unreleased)" with package.json still at 0.30.x. + const r = spawnSync('node', [join(scripts, 'check-changelog-heading.mjs'), 'release/1.0'], { + cwd: resolve(__dirname, '..'), + encoding: 'utf8', + }); + expect(r.status).toBe(0); + }); +}); From ed2552a992f99ede7deda11bed14adac06b914ac Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:18:07 +0200 Subject: [PATCH 2/2] Harden the publish guards: validate the dist-tag, assert npm args, drop publishConfig.tag (#33) - release-state.sh fails, writing no outputs, unless the dist-tag from release-plan.mjs is latest, next or backport-.. - The fake-npm end-to-end tests record and assert the exact npm arguments, so dropping `versions dist-tags` or `--prefer-online` fails a test. - package.json publishConfig no longer sets tag: "latest". The guard relied on --tag overriding it, and a manual or older-npm publish would still force latest. npm publish --dry-run still defaults to latest and honours --tag backport-0.29. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++- CLAUDE.md | 4 +++ package.json | 1 - scripts/release-state.sh | 6 ++++ test/release-scripts.spec.ts | 55 ++++++++++++++++++++++++++++++++++-- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d96e8..0e8bb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,11 @@ No change to the published package (#50). cancelled release), or `next` for a prerelease, so `latest` never moves backwards. Such a GitHub Release is not marked "Latest" (a prerelease is marked as one). The decision is in `scripts/release-plan.mjs`: SemVer 2.0.0 comparison with prereleases, no new dependency. + `release-state.sh` fails unless the tag is `latest`, `next` or `backport-.`. + `package.json` `publishConfig` no longer sets `tag: "latest"`: the guard relied on `--tag` + overriding it, and a manual publish or an older npm would still have forced `latest`. npm's + default tag is `latest` anyway (`npm publish --dry-run` still reports `latest`, and honours + `--tag backport-0.29`). - The release job summary warns about every `CHANGELOG.md` version between the last published version and the one being released that is missing on npm. - `ci.yml` checks the top `CHANGELOG.md` heading (`scripts/check-changelog-heading.mjs`): into @@ -151,7 +156,7 @@ No change to the published package (#50). - CONTRIBUTING.md "Releases" and the `publish.yml` header explain the concurrency behaviour, the recovery (re-run; an older version gets a non-`latest` tag) and "merge release PRs one at a time". The scripts are tested in `test/release-scripts.spec.ts`, `release-state.sh` with a - fake `npm`. + fake `npm` whose arguments are asserted. ## 0.30.1 diff --git a/CLAUDE.md b/CLAUDE.md index 768f238..8864a0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,6 +299,10 @@ A major release is prepared on a long-lived `release/**` branch (e.g. `release/1 tag name that is a valid semver range. That GitHub Release gets `--latest=false` (and `--prerelease` for a prerelease version). A re-run uses its own commit's workflow, so a run from before this guard (pre-1.0) publishes with npm's default tag: check `npm dist-tag ls` afterwards. + `release-state.sh` fails unless the tag matches `latest|next|backport-.`. `package.json` + `publishConfig` deliberately sets **no** `tag`: a `tag: "latest"` there would force `latest` on + a manual publish or an npm that does not let `--tag` override it (npm's default is `latest` + anyway). Keep it out. - It uses **npm Trusted Publishing (OIDC)** with provenance — there is **no `NPM_TOKEN` secret**. The Trusted Publisher for `glassnode-api` on npmjs.com must name repo `glassnode/glassnode-api-ts-client`, workflow `publish.yml` and environment `npm`. diff --git a/package.json b/package.json index 8fae44c..8e9874b 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public", - "tag": "latest", "provenance": true }, "dependencies": { diff --git a/scripts/release-state.sh b/scripts/release-state.sh index 8bc0879..77a0f0c 100755 --- a/scripts/release-state.sh +++ b/scripts/release-state.sh @@ -81,6 +81,12 @@ plan=$(NPM_VIEW="$pkg_view" NPM_RC="$pkg_rc" VERSION="$version" node "$(dirname dist_tag=$(sed -n 's/^dist_tag=//p' <<<"$plan") latest=$(sed -n 's/^latest=//p' <<<"$plan") skipped=$(sed -n 's/^skipped=//p' <<<"$plan") +# Belt and braces: never hand `npm publish --tag` an empty or unexpected tag (an empty one would +# fail the publish, a wrong one could move `latest`). +if ! [[ "$dist_tag" =~ ^(latest|next|backport-[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Unexpected dist-tag '$dist_tag' for $name@$version from release-plan.mjs" >&2 + exit 1 +fi case "$state" in missing) diff --git a/test/release-scripts.spec.ts b/test/release-scripts.spec.ts index 0f9191a..31ca0fd 100644 --- a/test/release-scripts.spec.ts +++ b/test/release-scripts.spec.ts @@ -165,8 +165,19 @@ describe('release-state.sh (fake npm)', () => { 0, ]; - function run(version: string, npm: Npm, changelog = CHANGELOG) { + /** + * Runs release-state.sh in a temp repo with a fake `npm`. `plan` replaces release-plan.mjs (the + * script is copied next to a fake one, since it runs `$(dirname "$0")/release-plan.mjs`). + */ + function run(version: string, npm: Npm, changelog = CHANGELOG, plan?: string) { const dir = tempDir(); + let script = join(scripts, 'release-state.sh'); + if (plan !== undefined) { + mkdirSync(join(dir, 'scripts')); + script = join(dir, 'scripts', 'release-state.sh'); + writeFileSync(script, readFileSync(join(scripts, 'release-state.sh'))); + writeFileSync(join(dir, 'scripts', 'release-plan.mjs'), plan); + } writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'glassnode-api', version })); writeFileSync(join(dir, 'CHANGELOG.md'), changelog); const fakeNpm = join(dir, 'bin', 'npm'); @@ -176,6 +187,7 @@ describe('release-state.sh (fake npm)', () => { fakeNpm, [ '#!/bin/sh', + 'echo "$*" >> "$FAKE_NPM_LOG"', 'case "$2" in', ' *@*) printf "%s" "$FAKE_VERSION_OUT"; exit "$FAKE_VERSION_RC" ;;', ' *) printf "%s" "$FAKE_PKG_OUT"; exit "$FAKE_PKG_RC" ;;', @@ -186,9 +198,11 @@ describe('release-state.sh (fake npm)', () => { chmodSync(fakeNpm, 0o755); const output = join(dir, 'output'); const summary = join(dir, 'summary'); + const npmLog = join(dir, 'npm.log'); + writeFileSync(npmLog, ''); writeFileSync(output, ''); writeFileSync(summary, ''); - const res = spawnSync('bash', [join(scripts, 'release-state.sh')], { + const res = spawnSync('bash', [script], { cwd: dir, encoding: 'utf8', env: { @@ -201,6 +215,7 @@ describe('release-state.sh (fake npm)', () => { FAKE_VERSION_RC: String(npm.version[1]), FAKE_PKG_OUT: npm.pkg[0], FAKE_PKG_RC: String(npm.pkg[1]), + FAKE_NPM_LOG: npmLog, }, }); const outputs = Object.fromEntries( @@ -209,9 +224,43 @@ describe('release-state.sh (fake npm)', () => { .filter(Boolean) .map((line) => line.split(/=(.*)/s).slice(0, 2)) ); - return { ...res, outputs, summary: readFileSync(summary, 'utf8') }; + const npmCalls = readFileSync(npmLog, 'utf8').split('\n').filter(Boolean); + return { ...res, outputs, npmCalls, summary: readFileSync(summary, 'utf8') }; } + it('queries npm with exactly the expected arguments', () => { + const r = run('0.30.1', { version: e404, pkg: pkg(['0.30.0'], '0.30.0') }); + expect(r.status).toBe(0); + // --prefer-online: npm's cache must not hide a just-published version or a moved `latest`. + expect(r.npmCalls).toEqual([ + 'view glassnode-api@0.30.1 version gitHead --json --prefer-online', + 'view glassnode-api versions dist-tags --json --prefer-online', + ]); + }); + + it.each([ + ['empty', 'dist_tag='], + ['missing', ''], + ['unexpected', 'dist_tag=beta'], + ['a semver range', 'dist_tag=v0.29'], + ['with trailing text', 'dist_tag=latest x'], + ])('fails, writing no outputs, on a %s dist-tag from release-plan.mjs', (_, line) => { + const plan = `console.log('latest=0.30.0');\nconsole.log(${JSON.stringify(line)});\nconsole.log('skipped=');\n`; + const r = run('0.30.1', { version: e404, pkg: pkg(['0.30.0'], '0.30.0') }, CHANGELOG, plan); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('Unexpected dist-tag'); + expect(r.outputs).toEqual({}); + }); + + it('accepts every dist-tag shape release-plan.mjs produces', () => { + for (const tag of ['latest', 'next', 'backport-0.29', 'backport-12.345']) { + const plan = `console.log('latest=0.30.0');\nconsole.log('dist_tag=${tag}');\nconsole.log('skipped=');\n`; + const r = run('0.30.1', { version: e404, pkg: pkg(['0.30.0'], '0.30.0') }, CHANGELOG, plan); + expect(r.status, r.stderr).toBe(0); + expect(r.outputs.dist_tag).toBe(tag); + } + }); + it('publishes a new version above latest under `latest`', () => { const r = run('0.30.1', { version: e404,