Skip to content

feat: kit upgrade, update notices, and an attested release pipeline - #23

Merged
imjohnbo merged 3 commits into
mainfrom
catchup/09-release
Aug 21, 2026
Merged

feat: kit upgrade, update notices, and an attested release pipeline#23
imjohnbo merged 3 commits into
mainfrom
catchup/09-release

Conversation

@imjohnbo

Copy link
Copy Markdown
Owner

Follows the spec catch-up stack (#14 through #21), which merged while this was in
progress. Rebased onto main, so this targets main directly.

Release plumbing, not spec catch-up. Three commits: the pipeline, the semver
gate, and the package rename.

What makes a release publish to npm

A pushed version tag, and a manual approval. Nothing else.

npm version patch          # bumps package.json, commits, creates the tag
git push --follow-tags     # pushing the tag starts the workflow

.github/workflows/release.yml runs three jobs.

Job Runs on Stops the release when
verify Node 18, 20, 22 A test fails
verify-package Once The version breaks a semver rule, or the tarball does not match the source
publish After approval The repacked tarball differs from the verified one

The publish job targets an npm-publish environment. Add a required reviewer to
it, and a pushed tag can no longer ship anything on its own.

A prerelease tag such as v0.1.0-rc.1 also triggers the workflow. It publishes
under the next dist-tag, so a prerelease cannot push kit upgrade users onto an
unstable version.

Attestation

Three checks, each answering a different question.

  1. npm provenance. Links the package to this workflow run and commit. Verify
    with npm audit signatures.
  2. GitHub artifact attestation. Covers the release asset. Verify with
    gh attestation verify.
  3. Source equals tarball. The one that answers the original question.

Layers 1 and 2 prove where a tarball was built. Neither proves the tarball
matches the source. A step between checkout and publish could edit a file and
still produce valid provenance.

kit-cli has no build step and no devDependencies, so the published tarball is the
source. CI proves it: pack the tree, unpack the tarball, diff the two, and fail
the release on any difference.

I verified both assumptions locally before relying on them.

  • npm does not normalize package.json when it packs, so the diff is exact.
  • npm pack is reproducible here, so the publish job can assert the repacked
    tarball matches the one CI verified.

That assertion is in the workflow. The npm package, the attested artifact, and the
release asset are the same bytes.

publishConfig.provenance is true, so a local npm publish fails rather than
shipping an unattested build. That is deliberate.

Enforcing semantic versioning

npm run check:semver runs four checks. CI runs the same script on every tagged
release.

  1. The version is valid semver.
  2. The git tag matches package.json.
  3. The version is newer than what npm already serves.
  4. A breaking change carries a big enough bump.

Check 4 is the only one that enforces the meaning of a version, and it needs a
machine-readable public surface. For a library that would be the exported
symbols. For a CLI it is the command tree: the commands, their arguments, and
their flags. Removing or renaming any of those breaks a caller's script. Adding
one does not.

spec/cli-surface.json holds that surface, 103 commands. A test asserts the
committed snapshot matches the current tree, so a surface change has to be
committed on purpose and shows up in review. On release, the gate reads the
snapshot from the previous tag and compares.

Below 1.0.0 a breaking change needs a minor bump, not a major one. Semver
leaves 0.x unstable, but npm's caret range treats minor as the breaking axis
there, and the ecosystem reads it that way.

I exercised all four paths against a real temporary tag rather than trusting the
unit tests alone:

Change Bump Result
Removed a command patch Refused, named removed command: kit tags remove-by-email
Removed a command minor Allowed
Added a command patch Refused
No surface change patch Allowed

What this cannot catch. The gate sees shape, not behavior. A flag that keeps
its name and changes its meaning, a changed output format, or a changed exit code
will all pass. Those still need a human. docs/RELEASING.md says so plainly, so
nobody mistakes the gate for a guarantee it does not give.

Action versions

Every pin in my first draft was stale. I had assumed rather than checked. Queried
against the API:

Action Was Now
checkout v4 v7.0.1
setup-node v4 v7.0.0
upload-artifact v4 v7.0.1
download-artifact v4 v8.0.1
attest-build-provenance v2 v4.2.2
github-script v7 v9.0.0

All six are pinned to a commit SHA with the version in a trailing comment. A tag
is mutable, so @v7 is a promise from the action's owner rather than a guarantee,
and these jobs hold id-token: write. Dependabot reads the version comment and
keeps it in step with the SHA.

This also fixes test.yml and check-api-spec.yml, which carried the same stale
pins.

Version 0.0.1, and the blocker it found

The project is pre-release, so it starts at 0.0.1 rather than 1.0.0.

Setting that immediately tripped the new gate. The unscoped kit-cli on npm
belongs to another author
, since 2015, currently at 0.0.4. Check 3 refused
0.0.1 as not newer than the published 0.0.4. The gate earned its keep before
the first release.

The package therefore publishes as @imjohnbo/kit-cli. Only package.json
changed, because src/package-info.js reads the name. The installed command is
still kit, because bin names it separately. I verified the registry serves a
scoped packument at an unencoded path, so the update check still resolves.

Moving to @kit/cli later is one edit plus a deprecation on the old name. The
runbook documents that path.

kit upgrade

kit upgrade              # detect the installer, delegate to it
kit upgrade --check      # report the newest version, install nothing
kit upgrade --dry-run    # print the command that would run

upgrade detects how the CLI was installed, then runs that package manager. It
never downloads or unpacks a release itself.

That is the security decision worth reviewing. Delegating keeps npm's integrity
check and provenance verification in the trust path. A bespoke updater that
fetched a tarball and unpacked it over itself would replace all of that with code
nobody audits. Running from a git checkout is detected and reported, since
npm link is what the README describes for development.

The command table holds argv arrays, never shell strings, and a test asserts no
entry carries a shell metacharacter.

Update notices

A one-line notice on stderr when a newer version exists.

It reads a cached version number from config, so it never delays a command and
never fails one. A background request refreshes the cache at most once a day. It
goes to stderr, which keeps --format json parseable, matching the
printWarnings decision from part 5.

It makes an outbound request, so it is documented and easy to turn off:

kit config set-update-check false
export KIT_NO_UPDATE_CHECK=1

It also stays off whenever CI is set. Flagging the default: the check is on
out of the box. Update notices are themselves a security mechanism, so that felt
right, but it is a one-line change if you would rather ship it off.

Version and name drift

package.json is the only place either value lives. src/package-info.js reads
both at run time.

src/package-info.js exists as its own module rather than living in
program.js, because commands need these values and program.js imports the
commands. Putting it there created an import cycle.

isNewer moved to src/semver.js so the release script can import version logic
without starting the config store. One implementation, three callers.

Guards against a second copy appearing:

  • a test asserting no file in src/ hardcodes the version string
  • a test asserting kit --version equals package.json
  • tests asserting the registry URL and the upgrade target derive from the name
  • a CI step repeating the version check, so a mismatched tag cannot publish

src/config.js keeps its own fixed projectName. That names the directory
holding a user's credentials, so it must not follow the package name.

Two bugs fixed on the way

package.json pointed at github.com/yourusername/kit-cli. npm provenance
rejects a repository URL that does not match the publishing repo. Releases would
have failed at the last step.

npm test wrote to the real config. The suite mutated
~/Library/Preferences/kit-cli-nodejs/config.json, so running it clobbered the
developer's stored API key and OAuth tokens. conf also rewrites the whole file
on every set, so parallel test files lost each other's writes. That is the flake
I saw earlier in this work and wrongly dismissed as truncated output. The new
update-check tests made it reproduce every time.

config.js now honors KIT_CONFIG_DIR, which also gives users one profile per
Kit account. scripts/run-tests.js gives each test file its own config
directory. npm test runs through that script and prints the same # tests and
# pass summary lines as before, so CI needs no change.

Review notes

Tests: 378 before, 497 after.

docs/RELEASING.md is the runbook. It covers the one-time setup, the checklist,
how a user verifies a release, how the semver gate reads a change, what the gate
cannot catch, action pinning, and what to do when a bad version ships. It also
records why the no-build-step property matters, so nobody adds a build step
without seeing what it costs.

Nothing here is spec catch-up. It was written on top of the stack because it needs
src/program.js from #21.

Before merging

Two things need doing outside this PR.

  1. Create the npm-publish GitHub environment and add a required reviewer.
  2. Set up npm trusted publishing for @imjohnbo/kit-cli, or store an automation
    token in that environment as NPM_TOKEN.

## What makes a release publish

A pushed version tag starts .github/workflows/release.yml. A manual approval on
the npm-publish environment finishes it. Nothing else publishes.

  npm version patch
  git push --follow-tags

## Attestation

Three independent checks, each answering a different question.

npm provenance links the package to the workflow run and commit. GitHub artifact
attestation covers the release asset. Neither proves the tarball matches the
source, so CI proves that directly: pack the tree, unpack the tarball, diff the
two. kit-cli has no build step, so the published package is the source, and the
diff is exact. Verified locally, including that npm does not normalize
package.json and that npm pack is reproducible.

The publish job also asserts the repacked tarball matches the one CI verified.
The npm package, the attested artifact, and the release asset are the same bytes.

publishConfig.provenance is true, so a local npm publish fails rather than
shipping an unattested build.

## kit upgrade

  kit upgrade [--check] [--dry-run]

It detects the package manager that installed the CLI and delegates to it. It
never downloads or unpacks a release itself, so npm keeps verifying integrity and
provenance. A hand-rolled updater would replace that with unaudited code. Running
from a git checkout reports that and points at git pull.

A passive notice prints on stderr when a newer version exists. It reads a cached
version number, so it never delays or fails a command. A background request
refreshes the cache at most once a day. Off via kit config set-update-check
false, KIT_NO_UPDATE_CHECK, or any CI environment.

## Version drift

package.json is the only place the version lives. src/version.js reads it at run
time. Two tests and one CI step stop a second copy appearing.

## Two bugs fixed on the way

package.json pointed at github.com/yourusername/kit-cli. npm provenance rejects a
repository URL that does not match the publishing repo, so releases would have
failed.

npm test wrote to the real config at ~/Library/Preferences/kit-cli-nodejs, so
running the suite clobbered the developer's stored API key and OAuth tokens. conf
rewrites the whole file per set, so parallel test files also lost each other's
writes. config.js now honors KIT_CONFIG_DIR, which doubles as a way to keep one
profile per Kit account, and scripts/run-tests.js gives each test file its own
config directory.

Tests: 378 before, 440 after.
## Enforcing semantic versioning

npm run check:semver runs four checks. CI runs the same script on every tagged
release.

1. The version is valid semver.
2. The git tag matches package.json.
3. The version is newer than what npm already serves. This blocks a republish
   and blocks going backwards.
4. A breaking change carries a big enough bump.

Check 4 is the only one that enforces the meaning of a version, and it needs a
machine-readable public surface. For a CLI that is the command tree: commands,
arguments, and flags. Removing or renaming any of those breaks a caller's
script. Adding one does not.

spec/cli-surface.json holds that surface, 103 commands. A test asserts the
committed snapshot matches the current tree, so a surface change has to be
committed on purpose and shows up in review. The release gate reads the snapshot
from the previous tag and compares.

Below 1.0.0 a breaking change needs a minor bump, not a major one. Semver leaves
0.x unstable, but npm's caret range treats minor as the breaking axis there.

The gate sees shape, not behavior. A flag that keeps its name and changes its
meaning still needs a human. docs/RELEASING.md says so plainly.

## Action versions

Every pin was stale. Checked against the API rather than assumed:

  checkout                v4 -> v7.0.1
  setup-node              v4 -> v7.0.0
  upload-artifact         v4 -> v7.0.1
  download-artifact       v4 -> v8.0.1
  attest-build-provenance v2 -> v4.2.2
  github-script           v7 -> v9.0.0

All six are now pinned to a commit SHA with the version in a comment. A tag is
mutable, so a retagged release would otherwise flow into a job holding
id-token: write. This covers test.yml and check-api-spec.yml too.

## Version 0.0.1

The project is pre-release, so it starts at 0.0.1 rather than 1.0.0.

Prerelease tags such as v0.1.0-rc.1 now trigger the workflow and publish under
the next dist-tag. Publishing a prerelease as latest would push every
kit upgrade onto an unstable version.

## One source of truth for the package name

src/version.js becomes src/package-info.js and exports PACKAGE_NAME alongside
VERSION. The update check and kit upgrade read it instead of a hardcoded string,
so renaming the package is one edit in package.json. src/config.js keeps its own
fixed name, because that names the directory holding user credentials and must
not move.

isNewer moves to src/semver.js so the release script can import version logic
without starting the config store. One implementation, three callers.

Tests: 440 before, 495 after.
The unscoped kit-cli on npm belongs to another author. It has existed since 2015
and sits at 0.0.4, so that name is not available. The release gate caught this:
check 3 refused 0.0.1 as not newer than the published 0.0.4.

A scoped name sidesteps the collision. publishConfig.access is already public.

Only package.json changes. src/package-info.js reads the name, so the update
check and kit upgrade follow. Verified that the registry serves a scoped
packument at an unencoded path, so the update check still resolves.

The installed command stays kit, because bin names it separately.

Three tests hardcoded the old name. They now derive it from package.json, which
is the same drift the version tests guard against.
@imjohnbo
imjohnbo merged commit 1c09efc into main Aug 21, 2026
3 checks passed
@imjohnbo
imjohnbo deleted the catchup/09-release branch August 21, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant