diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 500299c..1a40b66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release alpha +name: Release env: HUSKY: 0 @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: Alpha version already committed in package.json (without v) + description: Version already committed in package.json, without v (0.1.0, 0.1.0-rc.0, 0.1.0-alpha.3) required: true type: string @@ -84,19 +84,26 @@ jobs: - name: Check npm supports trusted publishing run: | node --input-type=module -e 'import { execFileSync } from "node:child_process"; const [major, minor, patch] = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim().split(".").map(Number); if (!(major > 11 || (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))))) throw new Error("npm >=11.5.1 is required");' - - name: Publish alpha to npm + - name: Publish to npm env: RELEASE_VERSION: ${{ inputs.version }} - run: npm publish "$RUNNER_TEMP/release/monarcode-effetch-$RELEASE_VERSION.tgz" --tag latest --access public --ignore-scripts - - name: Create GitHub prerelease and tag + run: | + DIST_TAG=$(jq -er .distTag "$RUNNER_TEMP/release/release.json") + npm publish "$RUNNER_TEMP/release/monarcode-effetch-$RELEASE_VERSION.tgz" --tag "$DIST_TAG" --access public --ignore-scripts + - name: Create GitHub release and tag env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_VERSION: ${{ inputs.version }} RELEASE_COMMIT: ${{ github.sha }} run: | + if jq -e .prerelease "$RUNNER_TEMP/release/release.json" > /dev/null; then + KIND=(--prerelease --latest=false) + else + KIND=(--latest) + fi gh release create "v$RELEASE_VERSION" \ --target "$RELEASE_COMMIT" \ --title "v$RELEASE_VERSION" \ - --prerelease --latest=false \ + "${KIND[@]}" \ --notes-file "$RUNNER_TEMP/release/release-notes.md" diff --git a/scripts/prepare-release.d.mts b/scripts/prepare-release.d.mts index 0e58574..2a5be94 100644 --- a/scripts/prepare-release.d.mts +++ b/scripts/prepare-release.d.mts @@ -10,18 +10,28 @@ export interface PackageManifest { export interface Release { readonly version: string; readonly tag: string; + readonly prerelease: boolean; readonly notes: string; } +export type DistTag = "latest" | "next"; + export declare function prepareRelease( manifest: PackageManifest, changelog: string, version: unknown, ): Release; +export declare function chooseDistTag( + version: string, + published: ReadonlyArray, +): DistTag; + export declare function checkRegistry( version: string, - request?: ( - url: string, - ) => Promise<{ readonly status: number; readonly ok: boolean }>, -): Promise; + request?: (url: string) => Promise<{ + readonly status: number; + readonly ok: boolean; + readonly json: () => Promise; + }>, +): Promise<{ readonly distTag: DistTag }>; diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 16447a2..a32892c 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -2,18 +2,20 @@ import { readFile, mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +const number = "(?:0|[1-9][0-9]*)"; +const release = new RegExp( + `^${number}\\.${number}\\.${number}(?:-(?:alpha|beta|rc)\\.${number})?$`, +); +const stable = new RegExp(`^${number}\\.${number}\\.${number}$`); + export function prepareRelease(manifest, changelog, version) { - const number = "(?:0|[1-9][0-9]*)"; - const alpha = new RegExp( - `^${number}\\.${number}\\.${number}-alpha\\.${number}$`, - ); if ( typeof version !== "string" || version.trim() !== version || - !alpha.test(version) + !release.test(version) ) { throw new Error( - "Use an alpha version such as 0.1.0-alpha.1, without a v prefix.", + "Use a version such as 0.1.0, 0.1.0-rc.0 or 0.1.0-alpha.3, without a v prefix.", ); } if (manifest.version !== version) @@ -41,16 +43,34 @@ export function prepareRelease(manifest, changelog, version) { .slice(section.index + section[0].length, next?.index) .trim(); if (!notes) throw new Error("Release notes must not be empty."); - return { version, tag: `v${version}`, notes }; + return { + version, + tag: `v${version}`, + prerelease: !stable.test(version), + notes, + }; +} + +export function chooseDistTag(version, published) { + if (stable.test(version)) return "latest"; + return published.some((existing) => stable.test(existing)) + ? "next" + : "latest"; } export async function checkRegistry(version, request = fetch) { const response = await request( - `https://registry.npmjs.org/@monarcode%2Feffetch/${version}`, + "https://registry.npmjs.org/@monarcode%2Feffetch", ); - if (response.status === 404) return; - if (response.ok) throw new Error(`Version ${version} is already published.`); - throw new Error(`npm registry check failed with HTTP ${response.status}.`); + if (response.status === 404) return { distTag: chooseDistTag(version, []) }; + if (!response.ok) + throw new Error( + `npm registry check failed with HTTP ${response.status}.`, + ); + const published = Object.keys((await response.json()).versions ?? {}); + if (published.includes(version)) + throw new Error(`Version ${version} is already published.`); + return { distTag: chooseDistTag(version, published) }; } if ( @@ -65,15 +85,15 @@ if ( } const manifest = JSON.parse(await readFile("package.json", "utf8")); const changelog = await readFile("CHANGELOG.md", "utf8"); - const release = prepareRelease(manifest, changelog, version); - if (registryFlag) await checkRegistry(release.version); + const prepared = prepareRelease(manifest, changelog, version); + const registry = registryFlag ? await checkRegistry(prepared.version) : {}; await mkdir(directory, { recursive: true }); await writeFile( resolve(directory, "release-notes.md"), - `${release.notes}\n`, + `${prepared.notes}\n`, ); await writeFile( resolve(directory, "release.json"), - `${JSON.stringify(release, null, 2)}\n`, + `${JSON.stringify({ ...prepared, ...registry }, null, 2)}\n`, ); } diff --git a/test/prepare-release.test.ts b/test/prepare-release.test.ts index d469d32..c284cb9 100644 --- a/test/prepare-release.test.ts +++ b/test/prepare-release.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { checkRegistry, prepareRelease } from "../scripts/prepare-release.mjs"; +import { + checkRegistry, + chooseDistTag, + prepareRelease, +} from "../scripts/prepare-release.mjs"; const manifest = { name: "@monarcode/effetch", @@ -25,6 +29,7 @@ describe("prepareRelease", () => { expect(prepareRelease(manifest, changelog, manifest.version)).toEqual({ version: "0.1.0-alpha.1", tag: "v0.1.0-alpha.1", + prerelease: true, notes: "### Added\n\n- New things.\n\n### Fixed\n\n- A bug.", }); expect( @@ -36,19 +41,45 @@ describe("prepareRelease", () => { ).toBe("v0.1.0-alpha.1"); }); + it.each([ + ["0.1.0", false], + ["1.0.0", false], + ["0.1.0-alpha.3", true], + ["0.1.0-beta.0", true], + ["0.1.0-rc.0", true], + ["0.1.0-rc.12", true], + ])("accepts %s (prerelease: %s)", (version, prerelease) => { + const release = prepareRelease( + { ...manifest, version }, + `# Changelog\n\n## ${version}\n\nNotes.\n`, + version, + ); + expect(release).toEqual({ + version, + tag: `v${version}`, + prerelease, + notes: "Notes.", + }); + }); + it.each([ "v0.1.0-alpha.1", - "0.1.0", - "0.1.0-beta.1", + "v0.1.0", + "0.1", + "0.1.0-rc", + "0.1.0-rc.0.1", + "0.1.0-pre.1", + "0.1.0-RC.1", "0.1.0-alpha.01", "01.1.0-alpha.1", + "0.1.0+build.1", " 0.1.0-alpha.1", "0.1.0-alpha.1\n", "$(echo bad)", undefined, - ])("rejects %j, which isn't an alpha version", (version) => { + ])("rejects %j, which isn't a release version", (version) => { expect(() => prepareRelease(manifest, changelog, version)).toThrow( - /alpha version/, + /Use a version such as/, ); }); @@ -102,27 +133,58 @@ describe("prepareRelease", () => { }); }); +describe("chooseDistTag", () => { + it.each([ + ["0.1.0", [], "latest"], + ["0.1.0", ["0.1.0-rc.0"], "latest"], + ["0.2.0", ["0.1.0"], "latest"], + ["0.1.0-rc.0", [], "latest"], + ["0.1.0-rc.0", ["0.0.0-stage", "0.1.0-alpha.2"], "latest"], + ["0.2.0-rc.0", ["0.1.0-rc.0", "0.1.0"], "next"], + ] as const)("tags %s with %j published as %s", (version, published, tag) => { + expect(chooseDistTag(version, published)).toBe(tag); + }); +}); + describe("checkRegistry", () => { - const answering = (status: number) => { + const answering = (status: number, versions: Array = []) => { const urls: Array = []; const request = async (url: string) => { urls.push(url); - return { status, ok: status >= 200 && status < 300 }; + return { + status, + ok: status >= 200 && status < 300, + json: async () => ({ + versions: Object.fromEntries(versions.map((v) => [v, {}])), + }), + }; }; return { urls, request }; }; - it("passes when the version isn't on npm (404)", async () => { + it("passes when the package isn't on npm (404)", async () => { const { urls, request } = answering(404); - await checkRegistry("0.1.0-alpha.1", request); - expect(urls).toEqual([ - "https://registry.npmjs.org/@monarcode%2Feffetch/0.1.0-alpha.1", - ]); + await expect(checkRegistry("0.1.0-alpha.1", request)).resolves.toEqual({ + distTag: "latest", + }); + expect(urls).toEqual(["https://registry.npmjs.org/@monarcode%2Feffetch"]); + }); + + it("passes with the dist-tag when the version is new", async () => { + await expect( + checkRegistry("0.1.0-rc.0", answering(200, ["0.1.0-alpha.2"]).request), + ).resolves.toEqual({ distTag: "latest" }); + await expect( + checkRegistry("0.2.0-rc.0", answering(200, ["0.1.0"]).request), + ).resolves.toEqual({ distTag: "next" }); }); - it("fails when the version is already published (200)", async () => { + it("fails when the version is already published", async () => { await expect( - checkRegistry("0.1.0-alpha.1", answering(200).request), + checkRegistry( + "0.1.0-alpha.1", + answering(200, ["0.1.0-alpha.0", "0.1.0-alpha.1"]).request, + ), ).rejects.toThrow(/already published/); }); @@ -153,7 +215,12 @@ describe("the command", () => { JSON.parse( readFileSync(path.join(dir, "out", "release.json"), "utf8"), ), - ).toMatchObject({ version: "0.1.0-alpha.1", tag: "v0.1.0-alpha.1" }); + ).toEqual({ + version: "0.1.0-alpha.1", + tag: "v0.1.0-alpha.1", + prerelease: true, + notes: "### Added\n\n- New things.\n\n### Fixed\n\n- A bug.", + }); }); it("fails without an output directory or with an unknown flag", () => {