Recipe: release a small library
Build, test, bump the version, tag, push, cut the GitHub release, write the notes, publish. For a small library this is where the real pain lives: a routine you run once a month, from a script that grew ugly, with a step you always forget. This recipe is that routine as one chain of targets — every step a typed wrapper, and a chain you can stop anywhere.
The chain
import { Build, FileTasks, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
import { GhTasks } from "jsr:@zuke/gh";
import { GitTasks } from "jsr:@zuke/git";
class Release extends Build {
// `--version 1.1.0` on the command line, or VERSION in the environment.
// Required: a missing value is an error before anything runs.
version = parameter("The version to release, e.g. 1.1.0").required();
check = target()
.description("Type-check the public entrypoint")
.executes(() => DenoTasks.check((s) => s.paths("mod.ts")));
test = target()
.description("Run the tests")
.dependsOn(this.check)
.executes(() => DenoTasks.test((s) => s.allowAll()));
bump = target()
.description("Write the version into jsr.json and commit it")
.dependsOn(this.test)
.executes(async () => {
const manifest: Record<string, unknown> = JSON.parse(
await FileTasks.readText("jsr.json"),
);
const bumped = { ...manifest, version: this.version.value };
await FileTasks.writeText("jsr.json", `${JSON.stringify(bumped, null, 2)}\n`);
await GitTasks.add((s) => s.paths("jsr.json"));
await GitTasks.commit((s) => s.message(`chore: release ${this.version.value}`));
});
tag = target()
.description("Create the annotated release tag")
.dependsOn(this.bump)
.executes(() =>
GitTasks.tag((s) =>
s.name(`v${this.version.value}`).message(`Release ${this.version.value}`)
)
);
push = target()
.description("Push the commit and the tag")
.dependsOn(this.tag)
.executes(() => GitTasks.push((s) => s.remote("origin").followTags()));
release = target()
.description("Create the GitHub release, with notes generated from the PRs")
.dependsOn(this.push)
.executes(() =>
GhTasks.releaseCreate((s) =>
s.tag(`v${this.version.value}`).generateNotes().latest()
)
);
publish = target()
.description("Publish the package to JSR")
.dependsOn(this.release)
.executes(() => DenoTasks.publish());
default = target()
.description("Default: the whole release")
.dependsOn(this.publish)
.executes(() => {});
}
await run(Release); # the whole release
./zuke --version 1.1.0
# stop after the tag: a safe local rehearsal
./zuke tag --version 1.1.0
# print the plan, run nothing
./zuke release --version 1.1.0 --dry-run
# the parameter from the environment
VERSION=1.1.0 ./zuke Why a chain beats a script
- You can stop anywhere.
./zuke tagruns check → test → bump → tag and nothing after it;--dry-runprints the plan for any target. A script has one entry point and a comment that says "don't run past here". - Every step is the real CLI, typed.
GitTasks.tag((s) => s.name(…).message(…))isgit tag -a … -m …;GhTasks.releaseCreate((s) => s.generateNotes().latest())isgh release create --generate-notes --latest;DenoTasks.publish()isdeno publish, which reads yourjsr.json(ordeno.json). The flags are methods, a typo is a compile error, and the argv is built without a shell. - The version is a typed input.
parameter(…).required()is read from--versionorVERSION, checked before the first target runs, and used in the targets asthis.version.value../zuke --listdocuments it. - The release notes write themselves.
--generate-notesturns the merged pull requests since the previous tag into the release body.
Variations
- npm instead of JSR: replace the
publishbody withNpmTasks.publish((s) => s.access("public"))from@zuke/npm, and bumppackage.jsoninstead ofjsr.json. - A changelog file:
GhTasks.releaseCreatealso takes.notesFile(path), so aCHANGELOG.mdsection you maintain by hand becomes the release body. - Fully automated:
@zuke/release-pleasewraps release-please, which opens a release pull request from your conventional commits, keeps the changelog, and cuts the GitHub release when the PR merges — the routine Zuke uses for its own 58 packages. - Secrets: a token for
ghornpmis aparameter("…").secret(), which Zuke masks in every log — see Secrets.
The complete project, with a one-function library beside it, is
examples/release-library.