Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
16 changes: 16 additions & 0 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 });
}
13 changes: 13 additions & 0 deletions scripts/validate-release-tag.mjs
Original file line number Diff line number Diff line change
@@ -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 ?? "<missing>"}.`);
process.exit(1);
}

console.log(`Release tag ${tag} matches package version ${version}.`);
28 changes: 28 additions & 0 deletions tests/release.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading