How the @dwk/* packages get published to npm. Read this before cutting a
release — the repo is in a Changesets pre-release ("beta") mode that changes
the normal flow, and there are a couple of non-obvious npm behaviours documented
at the bottom.
- Record changes as you go:
pnpm changeset(commit the generated.changeset/*.md). - Version locally:
pnpm changeset version→ commit the bumps + changelogs. - Push to
main. - Dry run the publish: Actions → Release → Run workflow →
dry_run: true. - Publish: same workflow with
dry_run: false. - Verify on npm and confirm the git tags landed on origin.
You never run pnpm release / changeset publish from a laptop — publishing
happens only through the gated Release GitHub Actions workflow.
Before cutting a release that touches an endpoint package, re-run the hosted
conformance suite(s) for any package that changed — not just once ever, but
every time, since these suites exercise the real deployed target and a
regression there won't show up in unit/integration tests. The mechanical
release gate (pnpm release:gate) only enforces this for stable
(>=1.0.0) versions today — everything is still prerelease, so nothing is
gated yet — but treat it as required practice regardless of what the gate
currently allows. See conformance/README.md for
the full suite list and:
conformance/micropub-qa.md— micropub.rocksconformance/webmention-qa.md— webmention.rocksconformance/webdav-qa.md— litmusconformance/pixelfed-qa.md/conformance/lemmy-qa.md— ActivityPub fediverse interopconformance/mastodon-client-qa.md— Mastodon-compatible client APIconformance/scale-out-qa.md— central mode (@dwk/server) live verification, if a release touches central mode's mechanism; not gated bystatus.jsonsince@dwk/serveris private/unpublished, but central mode stays experimental until this one passes
Record each result in conformance/status.json per that runbook's last
section before proceeding to the version bump below.
- Changesets, independent semver per package. Config in
.changeset/config.json(access: public,commit: false, changelog via@changesets/cli/changelog). - Pre mode is active.
.changeset/pre.jsonexists withtag: beta, so every package currently sits at0.1.0-beta.N. Nothing has hit a stable1.0.0yet. Dist-tag caveat: despite thebetapre-mode tag,changeset publishpublishes packages that have never had a stable release to thelatestdist-tag, notbeta(see the "Dist-tags" gotcha below). So until a1.0.0ships,latesttracks the newest beta and plainnpm i @dwk/<pkg>installs it. - Release gate.
pnpm release:gate(scripts/release-gate.mjs) blocks any package at a stable version (major >= 1, no prerelease tag) whose conformance/integration status inconformance/status.jsonisn'tpassingornot-applicable. Prerelease (-beta.N) versions are exempt, so the gate passes today. It also skips"private": truepackages. pnpm release=release:gate→pnpm build→changeset publish.- CI publish workflow
.github/workflows/release.yml:- Manual
workflow_dispatchonly, with adry_runboolean input. - Runs the full CI gate (lint → format:check → typecheck → build → test)
before publishing — build precedes test because package tests import sibling
@dwk/*deps through theirexportsmap (dist/). - Runs in the
npm-publishGitHub Environment, which holds any protection rules (required reviewers, allowed branches). The environment is gated to themainbranch. - Authenticates to npm via Trusted Publishing (OIDC) — no stored npm
token.
permissions: id-token: writelets the job mint a short-lived OIDC token that npm exchanges for a publish credential, scoped to this exact repo + workflow file + environment. This also satisfies provenance (NPM_CONFIG_PROVENANCE) automatically for a public repo. After publishing, the workflow tags origin by re-derivingname@versiontags from each non-privatepackage.jsonand pushing (changeset's own tags don't survive the step — see the gotcha).
- Manual
@dwk/serveris"private": true— the Node/Express self-hosting host ships only as a Docker image and is never published to npm.
- npm Trusted Publisher, configured individually for every
publishable
@dwk/*package (npm has no bulk/org-wide setting — one config per package, and a package can only have one Trusted Publisher at a time). On each package's npmjs.com page → Settings → Trusted Publisher → GitHub Actions:- Org/user:
davidwkeith, repo:workers - Workflow filename:
release.yml(filename only, not the full path) - Environment:
npm-publish - Allowed actions:
npm publish - Requires npm CLI
>= 11.5.1and Node>= 22.14.0in the publishing environment;release.ymlpins both. - Once verified working, retire the fallback path per package: Settings → Publishing access → "Require two-factor authentication and disallow tokens". This is also the mechanism npm is phasing out — see the GitHub changelog post on the GAT bypass-2FA deprecation: 2FA-bypass tokens lose account/access-management ability in early August 2026, and lose the ability to publish directly at all in January 2027.
- Org/user:
- The
npm-publishenvironment must allow themainbranch (so aworkflow_dispatchfrommainisn't rejected).
-
Record changesets for the work (if not already done):
pnpm changeset # pick affected packages + bump type; commit the .md -
Apply versions locally. In pre mode this bumps
0.1.0-beta.N→0.1.0-beta.(N+1), consumes the pending changesets intopre.json, and writes CHANGELOGs:pnpm changeset version git add -A && git commit -m "chore(release): version packages" git push origin main # or via PR
-
Dry run the workflow (build + full gate, no publish):
gh workflow run release.yml -f dry_run=true --ref main gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-statusExpect green, with Publish to npm and Push release tags skipped.
-
Publish for real:
gh workflow run release.yml -f dry_run=false --ref main
Watch it; the same two steps now run.
changeset publishpublishes each package whose local version isn't yet on npm (to thelatestdist-tag while no stable release exists — see the dist-tag caveat above). -
Verify (see below).
When the packages are ready for a real >=1.0.0 on the latest tag:
-
Make sure each package you're stabilising is green in
conformance/status.json(the gate enforces this for stable versions). -
Exit pre mode and re-version:
pnpm changeset pre exit # removes pre.json pnpm changeset version # collapses the betas into the next stable git add -A && git commit -m "chore(release): 1.0.0" git push origin main
-
Dry run, then publish, exactly as above. Now
changeset publishtargets thelatestdist-tag, andrelease:gatewill fail the run if any stable package is non-conformant.
To start a new prerelease line later: pnpm changeset pre enter beta (or
next, rc, …).
# Each package on the expected dist-tag (registry is authoritative):
for p in dpop rdf log store wac solid-pod solid-oidc activitypub micropub \
microsub indieauth webmention websub webfinger host-meta webauthn \
vc ldn oauth http-signatures remotestorage atproto-pds calendar \
webdav esi safe-fetch mcp mastodon-api mf2 cf-shims deno-host; do
printf '%-22s beta=%s latest=%s\n' "@dwk/$p" \
"$(npm view @dwk/$p dist-tags.beta 2>/dev/null)" \
"$(npm view @dwk/$p dist-tags.latest 2>/dev/null)"
done
# @dwk/server must NOT exist (private):
npm view @dwk/server version # expect E404
# Git tags reached origin:
git ls-remote --tags origin | grep -c '@dwk' # >= 31, one per publishable package per releaseThe meaningful tag pre-1.0 is latest — it should equal the version you just
released (beta stays pinned at 0.1.0-beta.0 and is not advanced; see the
dist-tag gotcha). If npm view 404s or shows the old version for a package you
just published, give it a minute — registry GET-propagation lags. Confirm the
run's Publish to npm step listed it as published successfully, and check the
published version list (npm view @dwk/<pkg> versions) which updates before
the dist-tag pointer does.
-
Registry GET-propagation can lag several minutes. Immediately after a successful publish,
npm view/ a directcurl https://registry.npmjs.org/...may 404 for minutes even though the publish succeeded. Sanity-check your read path against a known-public package (e.g.@types/node→ 200) before concluding a publish failed. Trust the workflow'spublished successfullyline first. -
Dist-tags: betas land on
latest, notbeta. In pre mode,changeset publishsends packages that have never had a stable release to thelatestdist-tag (it only uses the pre-modebetatag for packages that already have a normal release). Since none of ours do, every beta publishes tolatest, and thebetatag stays frozen at0.1.0-beta.0(a leftover from the first publish). Net effect pre-1.0: the install channel is plainnpm i @dwk/<pkg>(→ newest beta);@dwk/<pkg>@betais stale — don't advertise it. This self-corrects onpre exit+1.0.0, whenlatestmoves to the stable. We deliberately do not maintain thebetatag in the meantime. -
@dwk/mcp's stalelatest(resolved, 2026-07-28). The partial publish repaired in PR #321 (see "Recovering from a bad publish" above) left0.0.0on npm; a later good publish landed onbetainstead oflatest, because by then the package did have a prior release forchangeset publishto treat as stable, sonpm i @dwk/mcpkept installing the broken0.0.0. Fixed with a one-off dist-tag move (a registry operation, not a repo change — needs an interactive, 2FA-backednpm login, not a bypass-2FA token, see the Trusted Publishing prerequisite above):npm dist-tag add @dwk/mcp@<latest-published-version> latest npm view @dwk/mcp dist-tags # expect latest === <latest-published-version>
Verify this whenever a package's first real publish followed a bad one; every other
@dwkpackage'slatestmatches its local version today. -
Changeset's git tags don't survive to the push step.
changeset publishlogsNew tag: ...but those lightweight tags are not present when the next workflow step runs (git push origin --tags→ "Everything up-to-date"), so the workflow instead re-derivesname@versiontags from each non-privatepackage.jsonand pushes those. If a release's tags ever go missing, recreate them at the publish commit and push:git tag "@dwk/<pkg>@<version>" <sha> && git push origin "refs/tags/@dwk/*". -
changeset publishis a no-op for already-published versions. Re-running a publish without bumping versions publishes nothing and still exits 0. Bump versions (changeset version) first. -
Build before test. Any CI step that runs the test suite must build first; vitest resolves sibling
@dwk/*deps through theirdist/exports.
If a package's package.json version and .changeset/pre.json disagree — e.g. a
package sits at 0.0.0 (a bad/partial publish) while one of its changesets is
already listed in pre.json's applied changesets array but the version was
never bumped — do not just run changeset version to fix it: in pre mode
that consumes every pending changeset across the whole workspace (bumping many
unrelated packages) and still skips the already-applied changeset, silently
losing its changelog entry. Instead reconstruct the intended end-state by hand
for that one package (this is what PR #321 did for @dwk/mcp):
- Identify only the changesets that bump the affected package — grep the
frontmatter, since an
mcp-*-named changeset may actually bump a different package (e.g. the endpoint packages that contribute tools). - Set
package.jsonversionto what those changesets imply from the package'sinitialVersions[...]anchor inpre.json(e.g.0.0.0+ aminorchangeset →0.1.0-beta.0; the first prerelease is-beta.0). - In
pre.json, add any of that package's not-yet-applied changesets to the appliedchangesetsarray so all of them are marked consumed into that version. LeaveinitialVersions[...]alone — it's the pre-enter anchor, not the published version. - Write/extend the package's
CHANGELOG.mdwith the new version's entry, copying the changeset bodies verbatim and prefixing each bullet with the short hash of the commit that added the changeset (git log --diff-filter=A --format=%h -- .changeset/<name>.md | tail -1), so it matches the auto-generated sibling format. - Verify with
pnpm exec changeset status(the repaired package must show no pending bump), thenpnpm release:gateandpnpm catalog:check.
Note: an already-published bad version (e.g. 0.0.0 on npm) can't be
unpublished; this repairs the repo state so the next publish is correct and
dependents re-pin to the good version.
CLAUDE.md→ "Conformance & release gate" and the commands table.spec/conformance-and-testing.md— the conformance bar for stable releases..github/workflows/release.yml— the publish workflow..github/workflows/conformance.yml— release-gate + integration on every PR; hosted suites on schedule/dispatch.