diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 7272da7..090c880 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -29,6 +29,8 @@ jobs: cache: npm - name: Install dependencies run: npm ci + - name: Validate prospective release tag + run: npm run release:tag -- "v$(node -p \"require('./package.json').version\")" - name: Install ReleaseBox run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 007e7ae..f7329d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: registry-url: https://registry.npmjs.org - name: Install dependencies run: npm ci + - name: Validate release tag + run: npm run release:tag -- "$GITHUB_REF_NAME" - name: Install ReleaseBox run: | set -euo pipefail @@ -37,11 +39,13 @@ jobs: run: node /tmp/releasebox/bin/releasebox.js check . - name: Run release checks run: npm run release:check - - name: Build package - run: npm pack + - name: Publish package to npm + run: npm publish --provenance --access public - name: Generate release notes run: node /tmp/releasebox/bin/releasebox.js notes . > RELEASE_NOTES.md - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} - run: gh release create "${GITHUB_REF_NAME}" --notes-file RELEASE_NOTES.md *.tgz + run: | + npm pack + gh release create "${GITHUB_REF_NAME}" --notes-file RELEASE_NOTES.md *.tgz diff --git a/README.md b/README.md index 08d40d3..2312b21 100644 --- a/README.md +++ b/README.md @@ -202,4 +202,6 @@ npm run package:smoke npm run release:check ``` -The package smoke uses `npm pack --dry-run` and fails if the CLI, library entrypoint, license, security policy, changelog, or contribution guide would be missing from the published tarball. +The package smoke checks the tarball contents, installs that tarball into a clean temporary consumer, and invokes the installed CLI. + +Version releases are distributed through npm. Set `package.json` to the intended version, run `npm run release:tag -- v` and `npm run release:check`, then push that exact tag. The tag workflow validates the tag again, publishes the public package to npm with provenance, and creates the GitHub release only after npm publication succeeds. diff --git a/package.json b/package.json index e56b50a..64e830e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test": "npm run build && node --test tests/*.test.mjs", "smoke": "bash scripts/smoke.sh", "package:smoke": "npm run build && node scripts/package-smoke.mjs", + "release:tag": "node scripts/validate-release-tag.mjs", "release:check": "npm run check && npm test && npm run build && npm run smoke && npm run package:smoke" }, "keywords": [ diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 512abae..cf3b16a 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const requiredFiles = [ "dist/cli.js", @@ -26,3 +29,16 @@ if (missing.length > 0) { } console.log(`Package smoke passed with ${pack.files.length} files.`); + +const consumer = mkdtempSync(join(tmpdir(), "promptdiff-package-smoke-")); +try { + const tarball = execFileSync("npm", ["pack", "--silent"], { encoding: "utf8" }).trim(); + execFileSync("npm", ["init", "--yes"], { cwd: consumer, stdio: "ignore" }); + execFileSync("npm", ["install", join(process.cwd(), tarball)], { cwd: consumer, stdio: "inherit" }); + const cli = join(consumer, "node_modules", ".bin", "promptdiff"); + const help = execFileSync(cli, ["--help"], { cwd: consumer, encoding: "utf8" }); + if (!help.includes("promptdiff compare")) throw new Error("Installed CLI help was not usable"); + rmSync(join(process.cwd(), tarball)); +} finally { + rmSync(consumer, { recursive: true, force: true }); +} diff --git a/scripts/validate-release-tag.mjs b/scripts/validate-release-tag.mjs new file mode 100644 index 0000000..55b8f72 --- /dev/null +++ b/scripts/validate-release-tag.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; + +const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME; +const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const expected = `v${version}`; + +if (!tag || !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag) || tag !== expected) { + console.error(`Release tag must exactly match package version: expected ${expected}, received ${tag ?? ""}.`); + process.exit(1); +} + +console.log(`Release tag ${tag} matches package version ${version}.`); diff --git a/tests/release.test.mjs b/tests/release.test.mjs new file mode 100644 index 0000000..b195fb8 --- /dev/null +++ b/tests/release.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +test("release tag must exactly match package version", () => { + for (const tag of [undefined, "0.1.0", "v0.1", "v9.9.9"]) { + const args = ["scripts/validate-release-tag.mjs", ...(tag ? [tag] : [])]; + const run = spawnSync(process.execPath, args, { encoding: "utf8", env: { ...process.env, GITHUB_REF_NAME: "" } }); + assert.equal(run.status, 1, tag); + } + assert.equal(spawnSync(process.execPath, ["scripts/validate-release-tag.mjs", "v0.1.0"]).status, 0); +}); + +test("release workflow validates before publishing and publishes before GitHub release", () => { + const workflow = readFileSync(".github/workflows/release.yml", "utf8"); + const validate = workflow.indexOf("npm run release:tag"); + const publish = workflow.indexOf("npm publish --provenance --access public"); + const githubRelease = workflow.indexOf("gh release create"); + assert.ok(validate >= 0 && validate < publish); + assert.ok(publish < githubRelease); + assert.match(workflow, /permissions:\n contents: write\n id-token: write/); +}); + +test("dry run validates a prospective package tag", () => { + const workflow = readFileSync(".github/workflows/release-dry-run.yml", "utf8"); + assert.match(workflow, /npm run release:tag -- "v\$\(node/); +});