Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ jobs:
with:
node-version: 24
cache: 'pnpm'
# The top CHANGELOG.md heading must be exactly `## <package.json version>` (publish.yml takes
# the GitHub Release notes from that line). Into release/** a `## <x.y.z> (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.
Expand Down
39 changes: 33 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<major>.<minor>` 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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand All @@ -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-<major>.<minor>` 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<version> tag
Expand Down Expand Up @@ -147,13 +168,16 @@ jobs:
exit 1
fi
# The release notes are the package.json version's section of CHANGELOG.md (from its
# `## <version>` heading to the next `## ` heading).
# `## <version>` 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"
Expand All @@ -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"
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,29 @@ 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-<major>.<minor>` 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.
`release-state.sh` fails unless the tag is `latest`, `next` or `backport-<n>.<n>`.
`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
`main` it must be exactly `## <package.json version>`; into `release/**`,
`## <x.y.z> (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` whose arguments are asserted.

## 0.30.1

Expand Down
53 changes: 41 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `## <package.json version>`; into `release/**`, `## <x.y.z> (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**.
Expand All @@ -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 `## <version>` (e.g.
`## 1.0.0`): `publish.yml` takes the GitHub Release notes from the line that equals
`## <version>`, so any suffix left on the heading leaves the Release without notes. Its merge
`## <version>`, 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 `## <package.json version>` 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
Expand All @@ -265,15 +275,34 @@ 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<version>` 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 <dist-tag>`, pushes the `v<version>` 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-<major>.<minor>` for a version lower than the current `latest` (such a re-run), or
`next` for a prerelease, so `latest` never moves backwards. Not `v<major>.<minor>`: 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.
`release-state.sh` fails unless the tag matches `latest|next|backport-<n>.<n>`. `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`.
Expand Down
34 changes: 29 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 `## <x.y.z> (unreleased)`, into `main` it must be exactly `## <package.json version>`
(`node scripts/check-changelog-heading.mjs <target branch>` 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

Expand Down Expand Up @@ -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<version>` 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-<major>.<minor>` 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@<newest version> latest`.

## Contract fixtures

`test/fixtures/contract/` holds real API responses recorded by `scripts/record-fixtures.mjs`.
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
"tag": "latest",
"provenance": true
},
"dependencies": {
Expand Down
Loading
Loading