Every small project has a scripts/release.sh. It started as three lines, grew
a flag and two guards, and now nobody wants to touch it. This recipe replaces it
with the $ shell from @zuke/core/shell — and nothing else. No build class,
no targets: the smallest possible door into Zuke, and a script you run exactly
as before.
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [ -z "$VERSION" ]; then
echo "usage: scripts/release.sh <version>" >&2
exit 2
fi
if [ -n "$(git status --porcelain)" ]; then
echo "release: the working tree is not clean" >&2
exit 1
fi
deno test -A
git tag -a "v$VERSION" -m "Release $VERSION"
git push origin --follow-tagsIt works, until a version string has a space in it, or someone copies it to a
project without set -e, or the $(...) quoting is one level off.
#!/usr/bin/env -S deno run -A
import { $ } from "@zuke/core/shell";
const [version, flag] = Deno.args;
if (version === undefined) {
console.error("usage: deno run -A scripts/release.ts <version> [--dry-run]");
Deno.exit(2);
}
const dirty = await $`git status --porcelain`.quiet().text();
if (dirty !== "") throw new Error("release: the working tree is not clean");
await $`deno test -A`;
if (flag === "--dry-run") {
console.log(`dry run: would tag v${version} and push`);
} else {
await $`git tag -a ${`v${version}`} -m ${`Release ${version}`}`;
await $`git push origin --follow-tags`;
}deno run -A scripts/release.ts 1.1.0 --dry-run
deno run -A scripts/release.ts 1.1.0- No shell, so nothing to break out of. Every
${…}interpolation becomes one discrete argv entry; the process is spawned directly. A version with a space, a quote, or a;in it is just a string — the bash script has to trust its quoting. - A non-zero exit throws.
set -eis not something you can forget, and the error names the command and its code..noThrow()is the explicit opt-out, and.code()gives you the exit code as a number when that is what you want. - Typed output.
.text()is trimmed stdout,.lines()is astring[], and both replace$(...),IFStricks and$?..quiet()replaces> /dev/null 2>&1. - The rest of TypeScript. Arrays expand to multiple arguments
(
$`deno fmt ${files}`), a value can be validated before it is used, and the editor knows what$returns. See the shell guide for.env(),.cwd(),.killAfter()and.spawn().
The moment you have two scripts that share a step, give the steps names. The
same routine as targets — each one runnable alone, visible in --list, and free
to depend on the others:
import { Build, parameter, run, target } from "@zuke/core";
import { $ } from "@zuke/core/shell";
import { DenoTasks } from "@zuke/deno";
import { GitTasks } from "@zuke/git";
class Release extends Build {
// Not `version`: that renders as `--version`, which Zuke reserves for
// reporting its own version.
releaseVersion = parameter("The version to release, e.g. 1.1.0").required();
clean = target()
.description("Refuse to release from a dirty working tree")
.executes(async () => {
// `$` still works inside a target, for the command that has no wrapper.
const dirty = await $`git status --porcelain`.quiet().text();
if (dirty !== "") throw new Error("the working tree is not clean");
});
test = target()
.description("Run the tests")
.dependsOn(this.clean)
.executes(() => DenoTasks.test((s) => s.allowAll()));
tag = target()
.description("Create the annotated release tag")
.dependsOn(this.test)
.executes(() =>
GitTasks.tag((s) =>
s.name(`v${this.releaseVersion.value}`)
.message(`Release ${this.releaseVersion.value}`)
)
);
push = target()
.description("Push the tag")
.dependsOn(this.tag)
.executes(() => GitTasks.push((s) => s.remote("origin").followTags()));
}
await run(Release);zuke tag --release-version 1.1.0 # clean → test → tag, and stop there
zuke --list # every step, with its descriptionThe git steps moved from $ to the typed GitTasks wrapper, where the flags
are methods and a typo is a compile error. zuke --list, graph, --parallel
and zuke mcp come with the targets for free. All three versions of this
script, side by side, are in
examples/scripts-to-zuke.