A release starts from a tested main commit and ends with a maintainer approving a
staged package on npm with 2FA. Do not create or move a version tag by hand, and do
not run npm publish from a workstation.
Two workflows do the work:
| Workflow | Trigger | Authority |
|---|---|---|
Build & Release (build.yml) |
dispatch on main |
pushes the signed release commit + tag |
Stage npm Release (publish.yaml) |
dispatched at that tag | stages one tarball on npm |
Only publish.yaml's stage job can publish. It checks out nothing, installs no
project dependencies, runs no third-party action, and holds no repository secret.
- The intended commit is on
mainand its CI run is green. CHANGELOG.mdhas the release notes under a heading that names the version bump:## [Unreleased (patch)],## [Unreleased (minor)], or## [Unreleased (major)]. Run/preflightfirst if upstream Node.js or SQLite sources need syncing.package.json'sversionis untouched —build.ymlbumps it.- npm's Trusted Publisher for
@photostructure/sqlitenamesphotostructure/node-sqliteandpublish.yaml. - Actions can read
SSH_SIGNING_KEY,GIT_USER_NAME, andGIT_USER_EMAIL.
- Open Build & Release in GitHub Actions and Run workflow on
main. It takes no input: the bump comes from theCHANGELOG.mdheading. - Wait for lint, the eight prebuilds, the test matrix, and the packed-package checks.
The release job then creates one signed commit and one signed
vX.Y.Zannotated tag, pushes them atomically, and starts Stage npm Release at that tag. - In the tag-bound Stage npm Release workflow, wait for the Stage package on npm job to finish. Leave the workflow running: its Create immutable GitHub release job waits for your npm approval.
build.yml(Build & Release) reads the version bump from the## [Unreleased (patch|minor|major)]heading inCHANGELOG.mdwhile lint, the prebuilds, the test matrix, and the packed-package checks run, and fails with instructions if that heading is missing, names no bump, or has no notes under it. Once all of them pass, it bumps the version, renames the heading to## [X.Y.Z] - YYYY-MM-DDwith a new empty## [Unreleased]above it, adds the version's[X.Y.Z]: <url>link definition, pushes a signed release commit and annotated tag, and startspublish.yamlat that tag.photostructure/changelog-release-actiondoes theCHANGELOG.mdwork and the npm wait below.publish.yaml(Stage npm Release) validates the tag, rebuilds all eight prebuilds from it, packs one tarball, installs and loads it on Linux, macOS, Windows, and Alpine across Node.js 22, 24, and 26, and stages that exact tarball withnpm stage publish. npm trusts only this workflow file, by name, to stage the package, so renaming it means changing the package's trusted-publisher settings on npmjs.com.- npm validates a staged package, which takes 5 to 20 minutes, then holds it until a maintainer approves it with 2FA.
- The Create immutable GitHub release job checks npm every 5 minutes, for up to
5 hours 45 minutes. Any npm error other than "not found" (E404) ends the job
early. Once npm serves the version, it
re-verifies the signed tag and creates the immutable GitHub release. Its notes
are the version's
CHANGELOG.mdsection, followed by GitHub's generated notes. GitHub emails the release to the repository's watchers at that moment, so the email always announces a version they can install.
- Download the
npm-package-vX.Y.Zartifact from the run. Check thatCONTENTS.txtlists all eight prebuilds and thatPACK.jsonnames the tag's version. - Open Staged Packages from the npm user menu.
- Confirm the package name, version, file list, and that provenance identifies this
repository,
publish.yaml, the release tag, and the tag's target commit. - Approve with 2FA, then confirm npm lists the version publicly.
- Wait for the Create immutable GitHub release job to finish, then confirm the
immutable GitHub release exists for the same tag, with the version's
CHANGELOG.mdsection at the top of its notes.
The CLI works too, and still requires 2FA: npm stage list, npm stage view <id>,
then npm stage approve <id> or npm stage reject <id>.
build.ymlpacks, installs, and loads a tarball on every push tomain.publish.yamlis frozen at the tag it runs from, so that procedure must never run for the first time during a release.- The release job changes only
package.json,package-lock.json, andCHANGELOG.md, signs the commit and tag, and pushes them atomically. It installs no dependencies. publish.yamlaccepts only a signedvMAJOR.MINOR.PATCHannotated tag whose package name, repository URL, version, and target commit match the workflow ref. It rechecks that identity in the staging job and again before creating the release.- All eight native binaries are rebuilt from the tag, and
packrefuses to package unless exactly those eight are present -- a missing one would silently push consumers on that platform into compiling from source.assertPackedContentsthen confirms each one made it into the tarball. - Artifact integrity between jobs is
actions/download-artifact's job: it hashes every artifact and fails ondigest-mismatchby default. - The packed tarball is installed with
--ignore-scripts, so the load check proves the packed prebuild resolves rather than a local source build.
| Failure | Response |
|---|---|
| A pre-tag job fails | Fix main and dispatch again. No tag exists yet. |
main moved during the run |
Dispatch again from the new head. |
| Tag or signature validation fails | Correct the release identity. Do not bypass validation. |
| The tag exists but dispatch failed | Rerun the dispatch job, or run publish.yaml at that exact tag. Do not bump again. |
| A tagged build or package check fails | Fix main and release a new version. Never move the tag. |
| The staged package is wrong | Reject the stage, cancel that Stage npm Release run, and release a new version. |
| An approved release is bad | Deprecate it or publish a corrected version. Never overwrite it. |
When a failure above calls for a new version after the tag exists, the release
commit has already moved the notes under ## [X.Y.Z] - YYYY-MM-DD in
CHANGELOG.md, and the next release run fails until ## [Unreleased] names a
bump and has notes. Rename that empty heading to
## [Unreleased (patch|minor|major)], move the unpublished version's notes
under it, leave a line under ## [X.Y.Z] saying that version was never
published, and then start the new release.
If the Create immutable GitHub release job fails before it creates the release
(its checks ran out before you approved the stage, or npm view failed with an error
other than E404), approve the stage if you have not, then use Re-run failed jobs
on that Stage npm Release run. GitHub allows re-runs for 30 days. Do not start a
new release. This does not apply to a rejected stage: its tag gets no GitHub release,
so cancel the run or let the job fail, and never re-run it.
For the first release following any change to build.yml, publish.yaml, or the
scripts/release-* tooling, record:
- both workflow run URLs;
- the signed tag and its target commit SHA;
- the staged-package approval time;
- the npm package and GitHub release URLs; and
npm view @photostructure/sqlite@X.Y.Z version gitHead dist.integrity --json.
gitHead must equal the release tag's target commit.