feat: kit upgrade, update notices, and an attested release pipeline - #23
Merged
Conversation
## 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows the spec catch-up stack (#14 through #21), which merged while this was in
progress. Rebased onto
main, so this targetsmaindirectly.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.
.github/workflows/release.ymlruns three jobs.verifyverify-packagepublishThe
publishjob targets annpm-publishenvironment. Add a required reviewer toit, and a pushed tag can no longer ship anything on its own.
A prerelease tag such as
v0.1.0-rc.1also triggers the workflow. It publishesunder the
nextdist-tag, so a prerelease cannot pushkit upgradeusers onto anunstable version.
Attestation
Three checks, each answering a different question.
with
npm audit signatures.gh attestation verify.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.
package.jsonwhen it packs, so the diff is exact.npm packis reproducible here, so the publish job can assert the repackedtarball 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.provenanceistrue, so a localnpm publishfails rather thanshipping an unattested build. That is deliberate.
Enforcing semantic versioning
npm run check:semverruns four checks. CI runs the same script on every taggedrelease.
package.json.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.jsonholds that surface, 103 commands. A test asserts thecommitted 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.0a breaking change needs a minor bump, not a major one. Semverleaves 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:
removed command: kit tags remove-by-emailWhat 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.mdsays so plainly, sonobody 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:
checkoutsetup-nodeupload-artifactdownload-artifactattest-build-provenancegithub-scriptAll six are pinned to a commit SHA with the version in a trailing comment. A tag
is mutable, so
@v7is a promise from the action's owner rather than a guarantee,and these jobs hold
id-token: write. Dependabot reads the version comment andkeeps it in step with the SHA.
This also fixes
test.ymlandcheck-api-spec.yml, which carried the same stalepins.
Version 0.0.1, and the blocker it found
The project is pre-release, so it starts at
0.0.1rather than1.0.0.Setting that immediately tripped the new gate. The unscoped
kit-clion npmbelongs to another author, since 2015, currently at 0.0.4. Check 3 refused
0.0.1as not newer than the published0.0.4. The gate earned its keep beforethe first release.
The package therefore publishes as
@imjohnbo/kit-cli. Onlypackage.jsonchanged, because
src/package-info.jsreads the name. The installed command isstill
kit, becausebinnames it separately. I verified the registry serves ascoped packument at an unencoded path, so the update check still resolves.
Moving to
@kit/clilater is one edit plus a deprecation on the old name. Therunbook documents that path.
kit upgrade
upgradedetects how the CLI was installed, then runs that package manager. Itnever 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 linkis 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 jsonparseable, matching theprintWarningsdecision from part 5.It makes an outbound request, so it is documented and easy to turn off:
It also stays off whenever
CIis set. Flagging the default: the check is onout 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.jsonis the only place either value lives.src/package-info.jsreadsboth at run time.
src/package-info.jsexists as its own module rather than living inprogram.js, because commands need these values andprogram.jsimports thecommands. Putting it there created an import cycle.
isNewermoved tosrc/semver.jsso the release script can import version logicwithout starting the config store. One implementation, three callers.
Guards against a second copy appearing:
src/hardcodes the version stringkit --versionequalspackage.jsonsrc/config.jskeeps its own fixedprojectName. That names the directoryholding a user's credentials, so it must not follow the package name.
Two bugs fixed on the way
package.jsonpointed atgithub.com/yourusername/kit-cli. npm provenancerejects a repository URL that does not match the publishing repo. Releases would
have failed at the last step.
npm testwrote to the real config. The suite mutated~/Library/Preferences/kit-cli-nodejs/config.json, so running it clobbered thedeveloper's stored API key and OAuth tokens.
confalso rewrites the whole fileon every
set, so parallel test files lost each other's writes. That is the flakeI saw earlier in this work and wrongly dismissed as truncated output. The new
update-check tests made it reproduce every time.
config.jsnow honorsKIT_CONFIG_DIR, which also gives users one profile perKit account.
scripts/run-tests.jsgives each test file its own configdirectory.
npm testruns through that script and prints the same# testsand# passsummary lines as before, so CI needs no change.Review notes
Tests: 378 before, 497 after.
docs/RELEASING.mdis 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.jsfrom #21.Before merging
Two things need doing outside this PR.
npm-publishGitHub environment and add a required reviewer.@imjohnbo/kit-cli, or store an automationtoken in that environment as
NPM_TOKEN.