diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml deleted file mode 100644 index 295fc8a..0000000 --- a/.github/workflows/prepare-release.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Prepare release - -# Manually-triggered release prep. Bumps the version, rolls the CHANGELOG, and pushes a -# `release/vX` branch — nothing is pushed to master directly. It then prints a one-click -# "open PR" link to the job summary (we intentionally don't have the bot create the PR, so -# the repo's "Allow GitHub Actions to create and approve pull requests" toggle can stay -# off). You open the PR and merge it; merging triggers release.yml. -on: - workflow_dispatch: - inputs: - version: - description: "Target version, e.g. 0.2.0 (must be > current package.json version)" - required: true - -permissions: {} - -concurrency: - group: prepare-release - cancel-in-progress: false - -# Injection-safety: the free-text `version` input is bound ONCE here; every step below -# references the quoted shell variable "$VERSION" and never expands ${{ inputs.version }} -# inside a `run:` script (which Actions would substitute before the shell parses it). -env: - VERSION: ${{ inputs.version }} - -jobs: - prepare: - runs-on: ubuntu-latest - permissions: - contents: write # push the release branch (no pull-requests: write — we don't create the PR) - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 - with: - cache: true - - run: npm ci - - run: ./scripts/check-version-increases.ts "$VERSION" - - run: ./scripts/roll-changelog.ts "$VERSION" - - run: npm version "$VERSION" --no-git-tag-version - - name: Commit on a branch and print the PR link - env: - GH_TOKEN: ${{ github.token }} - run: | - BRANCH="release/v${VERSION}" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git switch -c "$BRANCH" - git commit package.json package-lock.json CHANGELOG.md -m "Release v${VERSION}" - # Credential helper reads the token from env at git runtime — token never lands - # in argv (URL) or on-disk git config. Leading empty helper clears inherited ones. - git -c credential.helper= \ - -c credential.helper='!f() { echo username=x-access-token; echo "password=${GH_TOKEN}"; }; f' \ - push "https://github.com/${{ github.repository }}.git" "$BRANCH" - # We don't create the PR (keeps the bot from needing PR-create/approve rights). - # Print a pre-filled compare link instead; ?expand=1 opens the PR form with the - # commit subject ("Release vX") as the default title. github.repository is the - # repo slug (not attacker-controlled), so expanding it here is injection-safe. - PR_URL="https://github.com/${{ github.repository }}/compare/master...${BRANCH}?expand=1" - { - echo "## Release prep for v${VERSION} is ready" - echo "" - echo "Branch \`${BRANCH}\` pushed. [**Open the release PR →**](${PR_URL})" - echo "" - echo "Review the version bump and rolled CHANGELOG, then merge to trigger the gated release." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/check-version-increases.ts b/scripts/check-version-increases.ts deleted file mode 100755 index 6b54cbe..0000000 --- a/scripts/check-version-increases.ts +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env -S node --disable-warning=ExperimentalWarning -import { readFileSync } from "node:fs"; -import { assertGreater } from "./lib/semver.ts"; - -// Prepare-time guard: verify the target version strictly increases over the current -// package.json version. Mutates nothing — `npm version` does the actual bump. - -const next = process.argv[2]; -if (next === undefined) { - throw new Error("Usage: check-version-increases.ts "); -} - -const parsed: unknown = JSON.parse(readFileSync("./package.json", "utf8")); -if ( - typeof parsed !== "object" || - parsed === null || - !("version" in parsed) || - typeof parsed.version !== "string" -) { - throw new Error("package.json does not contain a string `version` field."); -} - -assertGreater(parsed.version, next); diff --git a/scripts/lib/semver.ts b/scripts/lib/semver.ts index d748b16..9a288cb 100644 --- a/scripts/lib/semver.ts +++ b/scripts/lib/semver.ts @@ -23,8 +23,6 @@ export function assertValid(version: string): string { * version, so this is what actually blocks downgrades. */ export function assertGreater(current: string, next: string): void { - assertValid(current); - assertValid(next); if (!semver.gt(next, current)) { throw new Error( `Target version ${next} must be strictly greater than the current version ${current}.`, diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts new file mode 100755 index 0000000..1438e9e --- /dev/null +++ b/scripts/prepare-release.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env -S node --disable-warning=ExperimentalWarning +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { rollUnreleasedSection } from "./lib/changelog.ts"; +import { assertGreater, assertValid } from "./lib/semver.ts"; + +// Local release prep. Must be run from a clean, up-to-date `master` checkout. +// From there it: +// - validates that the target version increases, +// - branches off master, bumps package.json + lockfile, +// - rolls the CHANGELOG's `# Unreleased` section into a dated one, +// - commits, +// - pushes `release/vX`, and +// - prints a PR link. +// +// Merging that PR is what triggers the actual release (master.yml -> +// release.yml). +// +// Run: `./scripts/prepare-release.ts 0.2.0` + +const rawVersion = process.argv[2]; + +if (rawVersion === undefined) { + throw new Error("Usage: prepare-release.ts (e.g. 0.2.0)"); +} +const version = rawVersion.replace(/^v/, ""); +assertValid(version); + +const branch = `release/v${version}`; +const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); + +// Must be run from master: the release is cut from here, and this guarantees +// the prepare-release.ts being executed is master's own copy (not a +// stale/divergent one on a feature branch). This script does not fetch — make +// sure master is current (git pull) first. +const currentBranch = capture("git", "rev-parse", "--abbrev-ref", "HEAD"); +if (currentBranch !== "master") { + throw new Error( + `Must be on master to prepare a release (currently on '${currentBranch}').`, + ); +} + +// Refuse to run on a dirty tree: we commit only the release files, so stray +// local changes would just be confusing (or silently left behind on the release +// branch). +if (capture("git", "status", "--porcelain") !== "") { + throw new Error( + "Working tree is not clean — commit or stash your changes first.", + ); +} + +// Validate everything that can throw BEFORE branching or mutating files. We're +// on a clean master, so the working tree is exactly master. +// rollUnreleasedSection throws on a missing/empty `# Unreleased` section; +// assertGreater on a non-increasing version. +const currentVersion = packageVersion( + readFileSync(join(repoRoot, "package.json"), "utf8"), +); +assertGreater(currentVersion, version); +const rolledChangelog = rollUnreleasedSection( + readFileSync(join(repoRoot, "CHANGELOG.md"), "utf8"), + version, + new Date().toISOString().slice(0, 10), // today, UTC +); +console.log( + `Preparing release v${version} (current master is ${currentVersion})…`, +); + +// Apply: branch off master (HEAD), bump, roll, commit, push. +run("git", "switch", "--create", branch); +run("npm", "version", version, "--no-git-tag-version"); // bumps package.json + package-lock.json +writeFileSync(join(repoRoot, "CHANGELOG.md"), rolledChangelog); +run( + "git", + "commit", + "package.json", + "package-lock.json", + "CHANGELOG.md", + "-m", + `Release v${version}`, +); +run("git", "push", "--set-upstream", "origin", branch); + +console.log(`\n✓ Pushed ${branch}. Open the release PR:\n ${compareUrl()}\n`); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Runs a command in the repo root and returns its trimmed stdout. */ +function capture(file: string, ...args: string[]): string { + return execFileSync(file, args, { cwd: repoRoot, encoding: "utf8" }).trim(); +} + +/** Runs a command in the repo root, streaming its output to the terminal. */ +function run(file: string, ...args: string[]): void { + execFileSync(file, args, { cwd: repoRoot, stdio: "inherit" }); +} + +/** Extracts the `version` field from package.json text (validated, no casts). */ +function packageVersion(packageJson: string): string { + const parsed: unknown = JSON.parse(packageJson); + if ( + typeof parsed !== "object" || + parsed === null || + !("version" in parsed) || + typeof parsed.version !== "string" + ) { + throw new Error("package.json does not contain a string `version` field."); + } + return parsed.version; +} + +/** Builds the GitHub "open a PR" compare link for the pushed branch. */ +function compareUrl(): string { + const url = capture("git", "remote", "get-url", "origin"); + const match = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(url); + const owner = match?.[1]; + const repo = match?.[2]; + if (owner === undefined || repo === undefined) { + throw new Error( + `Could not parse a GitHub repo from the origin URL: ${url}`, + ); + } + return `https://github.com/${owner}/${repo}/compare/master...${branch}?expand=1`; +} diff --git a/scripts/roll-changelog.ts b/scripts/roll-changelog.ts deleted file mode 100755 index 0d928ee..0000000 --- a/scripts/roll-changelog.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env -S node --disable-warning=ExperimentalWarning -import { readFileSync, writeFileSync } from "node:fs"; -import { rollUnreleasedSection } from "./lib/changelog.ts"; - -// Prepare-time: roll `# Unreleased` into a dated section in place. Date is today (UTC). - -const rawVersion = process.argv[2]; -if (rawVersion === undefined) { - throw new Error("Usage: roll-changelog.ts "); -} -const version = rawVersion.replace(/^v/, ""); -const dateIso = new Date().toISOString().slice(0, 10); - -const path = "CHANGELOG.md"; -const content = readFileSync(path, "utf8"); -writeFileSync(path, rollUnreleasedSection(content, version, dateIso));