diff --git a/README.md b/README.md index 505305b..271d365 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,42 @@ # @vitejs/release-scripts -This repo is used to share release & publish scripts for the org. Scripts should be executed from the workspace root via `tsx scripts/release.ts` +This repo is used to share release & publish scripts for the org. Scripts should be executed from the workspace root via `node scripts/release.ts` + +## prepareRelease + +`prepareRelease` updates a package manifest without committing, tagging, or pushing. This is intended for release-PR workflows. + +```ts +import { generateChangelog, prepareRelease } from "@vitejs/release-scripts"; + +await prepareRelease({ + pkg: "my-package", + release: process.env.RELEASE ?? "next", + generateChangelog: async (pkg) => { + await generateChangelog({ + getPkgDir: () => `packages/${pkg}`, + tagPrefix: `${pkg}@`, + }); + }, +}); +``` + +`prepareRelease` returns the package, previous and next versions, and release tag. Pass `toTag` +when a repository does not use the default `@` convention. + +## detectReleaseCommit + +```ts +import { detectReleaseCommit } from "@vitejs/release-scripts"; + +const release = detectReleaseCommit({ + subject: "release: v1.2.3 (#42)", + packages: ["vite", "create-vite"], + defaultPackage: "vite", +}); +``` + +Use `extractChangelogEntry({ changelogPath, version })` to obtain the Markdown body for a GitHub release or release PR. ## release diff --git a/action/release-pr/action.yml b/action/release-pr/action.yml new file mode 100644 index 0000000..336bf0f --- /dev/null +++ b/action/release-pr/action.yml @@ -0,0 +1,51 @@ +name: Open release PR +description: Commit prepared release files and open a pull request + +inputs: + app-slug: + description: Slug of the GitHub App used to open the pull request + required: true + token: + description: GitHub App token with contents and pull request write access + required: true + package-path: + description: Path to the package to release + required: true + pr-body: + description: Pull request body + required: true + tag: + description: Release tag + required: true + version: + description: Release version + required: true + +runs: + using: composite + steps: + - name: Open release PR + shell: bash + env: + APP_SLUG: ${{ inputs.app-slug }} + GH_TOKEN: ${{ inputs.token }} + PACKAGE_NAME: ${{ inputs.package-name }} + PACKAGE_PATH: ${{ inputs.package-path }} + PR_BODY: ${{ inputs.pr-body }} + TAG: ${{ inputs.tag }} + VERSION: ${{ inputs.version }} + run: | + BRANCH="release-$PACKAGE_NAME-$VERSION-$GITHUB_RUN_ID" + BOT_ID="$(gh api "/users/$APP_SLUG%5Bbot%5D" --jq .id)" + git config user.name "$APP_SLUG[bot]" + git config user.email "$BOT_ID+$APP_SLUG[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + git add "$PACKAGE_PATH" + git commit -m "release: $TAG" + git push -u "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" HEAD + gh pr create \ + --assignee "$GITHUB_ACTOR" \ + --base main \ + --head "$BRANCH" \ + --title "release: $TAG" \ + --body "$PR_BODY" diff --git a/src/changelog.ts b/src/changelog.ts index 1472ff5..40285e7 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -5,7 +5,10 @@ import createPreset, { DEFAULT_COMMIT_TYPES, formatCommitUrl, } from "conventional-changelog-conventionalcommits"; -import type { generateChangelog as def } from "./types.d.ts"; +import type { + extractChangelogEntry as extractChangelogEntryDef, + generateChangelog as generateChangelogDef, +} from "./types.d.ts"; import { heading, link, @@ -38,7 +41,22 @@ interface ExtendedCommitNote extends CommitNote { }; } -export const generateChangelog: typeof def = async ({ getPkgDir, tagPrefix }) => { +export const extractChangelogEntry: typeof extractChangelogEntryDef = ({ + changelogPath, + version, +}) => { + const sections = fs.readFileSync(changelogPath, "utf-8").split(/^## /m).slice(1); + const section = sections.find((candidate) => { + const heading = candidate.split("\n", 1)[0]; + return heading.includes(`[${version}](`) || heading.startsWith(`${version} (`); + }); + if (!section) throw new Error(`Missing changelog entry for ${version}`); + + const entry = section.split("\n").slice(1).join("\n").trim(); + return entry; +}; + +export const generateChangelog: typeof generateChangelogDef = async ({ getPkgDir, tagPrefix }) => { const preset: Preset = createPreset({ types: DEFAULT_COMMIT_TYPES.map((t) => ({ ...t, diff --git a/src/detectRelease.ts b/src/detectRelease.ts new file mode 100644 index 0000000..1ea1818 --- /dev/null +++ b/src/detectRelease.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { + detectReleaseCommit as detectReleaseCommitDef, + getReleaseTag as getReleaseTagDef, + isReleaseCommitSubject as isReleaseCommitSubjectDef, +} from "./types.d.ts"; + +export const getReleaseTag: typeof getReleaseTagDef = (pkg, version, defaultPackage) => { + return pkg === defaultPackage ? `v${version}` : `${pkg}@${version}`; +}; + +export const isReleaseCommitSubject: typeof isReleaseCommitSubjectDef = (subject, tag) => { + const expected = `release: ${tag}`; + return ( + subject === expected || + (subject.startsWith(expected) && /^ \(#\d+\)$/.test(subject.slice(expected.length))) + ); +}; + +export const detectReleaseCommit: typeof detectReleaseCommitDef = ({ + subject, + packages, + defaultPackage, + getPkgDir = (pkg) => `packages/${pkg}`, + toTag = (pkg, version) => getReleaseTag(pkg, version, defaultPackage), +}) => { + for (const pkg of packages) { + const pkgPath = path.resolve(getPkgDir(pkg), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string") { + throw new Error(`Package ${JSON.stringify(pkg)} does not have a valid version`); + } + + const version = packageJson.version; + const tag = toTag(pkg, version); + if (isReleaseCommitSubject(subject, tag)) return { pkg, version, tag }; + } + return undefined; +}; diff --git a/src/index.ts b/src/index.ts index a1dbea5..6342841 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export { publish } from "./publish.ts"; +export { getPublishTag, publish, validatePublishVersion } from "./publish.ts"; export { release } from "./release.ts"; -export { generateChangelog } from "./changelog.ts"; +export { extractChangelogEntry, generateChangelog } from "./changelog.ts"; +export { detectReleaseCommit, getReleaseTag, isReleaseCommitSubject } from "./detectRelease.ts"; +export { prepareRelease } from "./prepare.ts"; diff --git a/src/prepare.ts b/src/prepare.ts new file mode 100644 index 0000000..1271d42 --- /dev/null +++ b/src/prepare.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import path from "node:path"; +import semver from "semver"; +import type { ReleaseType } from "semver"; +import { getReleaseTag } from "./detectRelease.ts"; +import { validatePublishVersion } from "./publish.ts"; +import type { prepareRelease as prepareReleaseDef } from "./types.d.ts"; +import { updateVersion } from "./utils.ts"; + +function resolveVersion(currentVersion: string, release: string, preid: string): string { + if (semver.valid(release)) return release; + + const releaseType = + release === "next" ? (semver.prerelease(currentVersion) ? "prerelease" : "patch") : release; + if (!semver.RELEASE_TYPES.includes(releaseType as ReleaseType)) { + throw new Error(`Invalid Version: ${release}`); + } + + let version = semver.inc(currentVersion, releaseType as ReleaseType, preid); + if (!version) throw new Error(`Invalid Version: ${release}`); + + const prerelease = semver.prerelease(version); + if (releaseType.startsWith("pre") && prerelease?.[0] === preid && prerelease[1] === 0) { + version = semver.inc(version, "prerelease", preid)!; + } + return version; +} + +export const prepareRelease: typeof prepareReleaseDef = async ({ + packages, + pkg, + release, + preid, + getPkgDir = (pkgName) => `packages/${pkgName}`, + toTag = (pkgName, version) => getReleaseTag(pkgName, version), + generateChangelog, +}) => { + if (!pkg || (packages && !packages.includes(pkg))) { + const expected = packages?.length ? ` Expected one of: ${packages.join(", ")}` : ""; + throw new Error(`Invalid release package ${JSON.stringify(pkg)}.${expected}`); + } + + const pkgDir = path.resolve(getPkgDir(pkg)); + const pkgPath = path.join(pkgDir, "package.json"); + if (semver.valid(release)) validatePublishVersion(release); + const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string") { + throw new Error(`Package ${JSON.stringify(pkg)} does not have a valid version`); + } + const currentPrerelease = semver.prerelease(packageJson.version); + const currentPreid = + typeof currentPrerelease?.[0] === "string" ? currentPrerelease[0] : undefined; + + const previousVersion = packageJson.version; + const version = resolveVersion(previousVersion, release, preid ?? currentPreid ?? "beta"); + validatePublishVersion(version); + updateVersion(pkgPath, version); + + await generateChangelog?.(pkg, version); + + return { pkg, previousVersion, version, tag: toTag(pkg, version) }; +}; diff --git a/src/publish.ts b/src/publish.ts index 3a57b7e..be6f856 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -1,8 +1,37 @@ import * as semver from "semver"; import { args, getActiveVersion, getPackageInfo, publishPackage, step } from "./utils.ts"; -import type { publish as def } from "./types.d.ts"; +import type { + getPublishTag as getPublishTagDef, + publish as publishDef, + validatePublishVersion as validatePublishVersionDef, +} from "./types.d.ts"; -export const publish: typeof def = async ({ +export const validatePublishVersion: typeof validatePublishVersionDef = (version) => { + const parsed = semver.parse(version); + if (!parsed) { + throw new Error(`Invalid publish version ${JSON.stringify(version)}`); + } + + const prereleaseIdentifier = parsed.prerelease[0]; + if ( + prereleaseIdentifier !== undefined && + prereleaseIdentifier !== "alpha" && + prereleaseIdentifier !== "beta" + ) { + throw new Error(`Only alpha and beta prereleases are supported, received ${version}`); + } +}; + +export const getPublishTag: typeof getPublishTagDef = (version, activeVersion) => { + validatePublishVersion(version); + const prereleaseIdentifier = semver.prerelease(version)?.[0]; + if (prereleaseIdentifier === "alpha" || prereleaseIdentifier === "beta") { + return prereleaseIdentifier; + } + return activeVersion && semver.lt(version, activeVersion) ? "previous" : undefined; +}; + +export const publish: typeof publishDef = async ({ defaultPackage, getPkgDir, provenance, @@ -29,16 +58,11 @@ export const publish: typeof def = async ({ throw new Error( `Package version from tag "${version}" mismatches with current version "${pkg.version}"`, ); + validatePublishVersion(version); const activeVersion = await getActiveVersion(pkg.name); step("Publishing package..."); - const releaseTag = version.includes("beta") - ? "beta" - : version.includes("alpha") - ? "alpha" - : activeVersion && semver.lt(pkg.version, activeVersion) - ? "previous" - : undefined; + const releaseTag = getPublishTag(version, activeVersion); await publishPackage(pkgDir, releaseTag, provenance, packageManager); }; diff --git a/src/types.d.ts b/src/types.d.ts index 00a29ba..db7a978 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -5,6 +5,46 @@ export declare function generateChangelog(options: { tagPrefix?: string; }): Promise; +export declare function extractChangelogEntry(options: { + changelogPath: string; + version: string; +}): string; + +export declare function getReleaseTag( + pkg: string, + version: string, + defaultPackage?: string, +): string; + +export declare function isReleaseCommitSubject(subject: string, tag: string): boolean; + +export declare function detectReleaseCommit(options: { + subject: string; + packages: readonly string[]; + defaultPackage?: string; + /** @default (pkg) => `packages/${pkg}` */ + getPkgDir?: (pkg: string) => string; + toTag?: (pkg: string, version: string) => string; +}): { pkg: string; version: string; tag: string } | undefined; + +export declare function prepareRelease(options: { + /** Restricts the accepted package names when provided. */ + packages?: readonly string[]; + pkg: string | undefined; + release: string; + /** @default current prerelease identifier, otherwise "beta" */ + preid?: string; + /** @default (pkg) => `packages/${pkg}` */ + getPkgDir?: (pkg: string) => string; + /** @default (pkg, version) => `${pkg}@${version}` */ + toTag?: (pkg: string, version: string) => string; + generateChangelog?: (pkg: string, version: string) => void | Promise; +}): Promise<{ pkg: string; previousVersion: string; version: string; tag: string }>; + +export declare function validatePublishVersion(version: string): void; + +export declare function getPublishTag(version: string, activeVersion?: string): string | undefined; + export declare function publish(options: { defaultPackage?: string; getPkgDir?: (pkg: string) => string; diff --git a/tests/__snapshots__/changelog.test.ts.snap b/tests/__snapshots__/changelog.test.ts.snap index 21a2ecd..ca1a3e2 100644 --- a/tests/__snapshots__/changelog.test.ts.snap +++ b/tests/__snapshots__/changelog.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`generates a changelog with breaking changes 1`] = ` +exports[`generateChangelog > generates a changelog with breaking changes 1`] = ` "## [2.0.0](https://github.com/vitejs/test/compare/v1.0.0...v2.0.0) (yyyy-mm-dd) ### ⚠ BREAKING CHANGES @@ -22,7 +22,7 @@ exports[`generates a changelog with breaking changes 1`] = ` " `; -exports[`generates a changelog with commits 1`] = ` +exports[`generateChangelog > generates a changelog with commits 1`] = ` "## [1.0.1](https://github.com/vitejs/test/compare/v1.0.0...v1.0.1) (yyyy-mm-dd) ### Bug Fixes @@ -36,7 +36,7 @@ exports[`generates a changelog with commits 1`] = ` " `; -exports[`generates a changelog with commits 2`] = ` +exports[`generateChangelog > generates a changelog with commits 2`] = ` "## [1.1.0](https://github.com/vitejs/test/compare/v1.0.1...v1.1.0) (yyyy-mm-dd) ### Features @@ -55,7 +55,7 @@ exports[`generates a changelog with commits 2`] = ` " `; -exports[`generates a new changelog for empty project 1`] = ` +exports[`generateChangelog > generates a new changelog for empty project 1`] = ` "## 1.0.0 (yyyy-mm-dd) ### Miscellaneous Chores diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts index 3642418..8df6ae7 100644 --- a/tests/changelog.test.ts +++ b/tests/changelog.test.ts @@ -1,99 +1,166 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { it, expect, onTestFinished } from "vitest"; +import { describe, it, expect, onTestFinished } from "vitest"; import { createFixture, type FileTree } from "fs-fixture"; import { exec } from "tinyexec"; -import { generateChangelog } from "../src/changelog.ts"; - -async function createProjectFixture(source?: FileTree) { - const fixture = await createFixture({ - "package.json": JSON.stringify({ - name: "test-project", - version: "1.0.0", - private: true, - }), - ...source, +import { generateChangelog, extractChangelogEntry } from "../src/changelog.ts"; + +describe("generateChangelog", () => { + async function createProjectFixture(source?: FileTree) { + const fixture = await createFixture({ + "package.json": JSON.stringify({ + name: "test-project", + version: "1.0.0", + private: true, + }), + ...source, + }); + onTestFinished(() => fixture.rm()); + + await exec("git", ["init"], { nodeOptions: { cwd: fixture.path } }); + await exec("git", ["remote", "add", "origin", "https://github.com/vitejs/test.git"], { + nodeOptions: { cwd: fixture.path }, + }); + + return fixture; + } + + async function gitCommit(cwd: string, message: string) { + // Write random text to file to allow conventional-changelog to detect commit + await fs.writeFile(path.join(cwd, "dummy.txt"), Math.random().toString(36).substring(2, 15)); + await exec("git", ["add", "."], { nodeOptions: { cwd } }); + await exec("git", ["commit", "-m", message], { nodeOptions: { cwd } }); + } + + async function updatePackageJsonVersion(cwd: string, version: string) { + const pkgPath = path.join(cwd, "./package.json"); + const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8")); + pkg.version = version; + await fs.writeFile(pkgPath, JSON.stringify(pkg)); + } + + async function initChangelog(cwd: string) { + await gitCommit(cwd, "chore: initial commit"); + await generateChangelogForRelease(cwd); + } + + async function generateChangelogForRelease(cwd: string) { + await generateChangelog({ getPkgDir: () => cwd, tagPrefix: "" }); + + // Tag the version so conventional-changelog tracks this as the last release + // and won't track this commit for the next release. + const version = JSON.parse(await fs.readFile(path.join(cwd, "./package.json"), "utf8")).version; + const tag = `v${version}`; + await exec("git", ["tag", "-a", "-m", tag, tag], { nodeOptions: { cwd } }); + } + + async function readChangelog(cwd: string) { + const changelog = await fs.readFile(path.join(cwd, "./CHANGELOG.md"), "utf8"); + return ( + changelog + // Normalize date + .replace(/\d{4}-\d{2}-\d{2}/g, "yyyy-mm-dd") + // Normalize short commit hashes + .replace(/\[[a-z0-9]{7}\]/g, `[${"x".repeat(7)}]`) + // Normalize full commit hashes + .replace(/\/[a-z0-9]{40}\)/g, `/${"x".repeat(40)})`) + ); + } + + it("generates a new changelog for empty project", async () => { + const fixture = await createProjectFixture(); + await gitCommit(fixture.path, "chore: initial commit"); + await generateChangelogForRelease(fixture.path); + expect(await readChangelog(fixture.path)).toMatchSnapshot(); }); - onTestFinished(() => fixture.rm()); - await exec("git", ["init"], { nodeOptions: { cwd: fixture.path } }); - await exec("git", ["remote", "add", "origin", "https://github.com/vitejs/test.git"], { - nodeOptions: { cwd: fixture.path }, + it("generates a changelog with commits", async () => { + const fixture = await createProjectFixture(); + await initChangelog(fixture.path); + + await gitCommit(fixture.path, "fix: fix a bug (#1)"); + await updatePackageJsonVersion(fixture.path, "1.0.1"); + await generateChangelogForRelease(fixture.path); + expect(await readChangelog(fixture.path)).toMatchSnapshot(); + + await gitCommit(fixture.path, "feat: add new feature"); + await updatePackageJsonVersion(fixture.path, "1.1.0"); + await generateChangelogForRelease(fixture.path); + expect(await readChangelog(fixture.path)).toMatchSnapshot(); }); - return fixture; -} - -async function gitCommit(cwd: string, message: string) { - // Write random text to file to allow conventional-changelog to detect commit - await fs.writeFile(path.join(cwd, "dummy.txt"), Math.random().toString(36).substring(2, 15)); - await exec("git", ["add", "."], { nodeOptions: { cwd } }); - await exec("git", ["commit", "-m", message], { nodeOptions: { cwd } }); -} - -async function updatePackageJsonVersion(cwd: string, version: string) { - const pkgPath = path.join(cwd, "./package.json"); - const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8")); - pkg.version = version; - await fs.writeFile(pkgPath, JSON.stringify(pkg)); -} - -async function initChangelog(cwd: string) { - await gitCommit(cwd, "chore: initial commit"); - await generateChangelogForRelease(cwd); -} - -async function generateChangelogForRelease(cwd: string) { - await generateChangelog({ getPkgDir: () => cwd, tagPrefix: "" }); - - // Tag the version so conventional-changelog tracks this as the last release - // and won't track this commit for the next release. - const version = JSON.parse(await fs.readFile(path.join(cwd, "./package.json"), "utf8")).version; - const tag = `v${version}`; - await exec("git", ["tag", "-a", "-m", tag, tag], { nodeOptions: { cwd } }); -} - -async function readChangelog(cwd: string) { - const changelog = await fs.readFile(path.join(cwd, "./CHANGELOG.md"), "utf8"); - return ( - changelog - // Normalize date - .replace(/\d{4}-\d{2}-\d{2}/g, "yyyy-mm-dd") - // Normalize short commit hashes - .replace(/\[[a-z0-9]{7}\]/g, `[${"x".repeat(7)}]`) - // Normalize full commit hashes - .replace(/\/[a-z0-9]{40}\)/g, `/${"x".repeat(40)})`) - ); -} - -it("generates a new changelog for empty project", async () => { - const fixture = await createProjectFixture(); - await gitCommit(fixture.path, "chore: initial commit"); - await generateChangelogForRelease(fixture.path); - expect(await readChangelog(fixture.path)).toMatchSnapshot(); + it("generates a changelog with breaking changes", async () => { + const fixture = await createProjectFixture(); + await initChangelog(fixture.path); + await gitCommit(fixture.path, "feat!: introduce breaking change"); + await gitCommit(fixture.path, "fix: fix a bug (#1)"); + await updatePackageJsonVersion(fixture.path, "2.0.0"); + await generateChangelogForRelease(fixture.path); + expect(await readChangelog(fixture.path)).toMatchSnapshot(); + }); }); -it("generates a changelog with commits", async () => { - const fixture = await createProjectFixture(); - await initChangelog(fixture.path); +describe("extractChangelogEntry", () => { + async function createChangelog(content: string) { + const fixture = await createFixture({ "CHANGELOG.md": content }); + onTestFinished(() => fixture.rm()); + return path.join(fixture.path, "CHANGELOG.md"); + } - await gitCommit(fixture.path, "fix: fix a bug (#1)"); - await updatePackageJsonVersion(fixture.path, "1.0.1"); - await generateChangelogForRelease(fixture.path); - expect(await readChangelog(fixture.path)).toMatchSnapshot(); + it("extracts a conventional changelog entry", async () => { + const changelogPath = await createChangelog(`# Changelog - await gitCommit(fixture.path, "feat: add new feature"); - await updatePackageJsonVersion(fixture.path, "1.1.0"); - await generateChangelogForRelease(fixture.path); - expect(await readChangelog(fixture.path)).toMatchSnapshot(); -}); +## [1.2.3](https://example.com/compare/v1.2.2...v1.2.3) (2026-07-30) + +### Features + +* add a feature + +## [1.2.2](https://example.com/compare/v1.2.1...v1.2.2) (2026-07-20) + +* older change + `); -it("generates a changelog with breaking changes", async () => { - const fixture = await createProjectFixture(); - await initChangelog(fixture.path); - await gitCommit(fixture.path, "feat!: introduce breaking change"); - await gitCommit(fixture.path, "fix: fix a bug (#1)"); - await updatePackageJsonVersion(fixture.path, "2.0.0"); - await generateChangelogForRelease(fixture.path); - expect(await readChangelog(fixture.path)).toMatchSnapshot(); + expect(extractChangelogEntry({ changelogPath, version: "1.2.3" })).toBe(`### Features + +* add a feature`); + }); + + // plugin-react, plugin-react-swc + it("extracts a manually maintained changelog entry", async () => { + const changelogPath = await createChangelog(`## Unreleased + +## 1.2.3 (2026-07-30) + +- Fix a bug + +## 1.2.2 (2026-07-20) + +- Older fix + `); + + expect(extractChangelogEntry({ changelogPath, version: "1.2.3" })).toBe("- Fix a bug"); + }); + + it("throws when a changelog entry is missing", async () => { + const changelogPath = await createChangelog(`## 1.2.2 (2026-07-20) + +- Older fix + `); + + expect(() => extractChangelogEntry({ changelogPath, version: "1.2.3" })).toThrow( + "Missing changelog entry for 1.2.3", + ); + }); + + it("doesn't throw when a changelog entry is empty", async () => { + const changelogPath = await createChangelog(`## 1.2.3 (2026-07-30) + +## 1.2.2 (2026-07-20) + +- Older fix + `); + + expect(extractChangelogEntry({ changelogPath, version: "1.2.3" })).toBe(""); + }); }); diff --git a/tests/detectRelease.test.ts b/tests/detectRelease.test.ts new file mode 100644 index 0000000..b28a73f --- /dev/null +++ b/tests/detectRelease.test.ts @@ -0,0 +1,82 @@ +import path from "node:path"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { createFixture } from "fs-fixture"; +import { + detectReleaseCommit, + getReleaseTag, + isReleaseCommitSubject, +} from "../src/detectRelease.ts"; + +it("constructs package and default-package release tags", () => { + expect(getReleaseTag("plugin-react", "1.2.3")).toBe("plugin-react@1.2.3"); + expect(getReleaseTag("vite", "1.2.3", "vite")).toBe("v1.2.3"); +}); + +describe("isReleaseCommitSubject", () => { + for (const [subject, expected] of [ + ["release: vite@1.2.3", true], + ["release: vite@1.2.3 (#123)", true], + ["release: vite@1.2.3 arbitrary", false], + ["chore: release vite@1.2.3", false], + ] as const) { + it(`matches release commit subject ${subject}`, () => { + expect(isReleaseCommitSubject(subject, "vite@1.2.3")).toBe(expected); + }); + } +}); + +describe("detectReleaseCommit", () => { + async function createPackages(versions: Record) { + const fixture = await createFixture({ + packages: Object.fromEntries( + Object.entries(versions).map(([pkg, version]) => [ + pkg, + { "package.json": JSON.stringify({ name: pkg, version }) }, + ]), + ), + }); + onTestFinished(() => fixture.rm()); + return fixture; + } + + it("detects a package release from its manifest and commit subject", async () => { + const fixture = await createPackages({ + "plugin-react": "1.2.3", + "plugin-rsc": "0.5.0", + }); + + expect( + detectReleaseCommit({ + subject: "release: plugin-rsc@0.5.0 (#42)", + packages: ["plugin-react", "plugin-rsc"], + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }), + ).toStrictEqual({ pkg: "plugin-rsc", version: "0.5.0", tag: "plugin-rsc@0.5.0" }); + }); + + it("supports a default package with v-prefixed tags", async () => { + const fixture = await createPackages({ vite: "8.0.0", "create-vite": "8.0.0" }); + + expect( + detectReleaseCommit({ + subject: "release: v8.0.0", + packages: ["vite", "create-vite"], + defaultPackage: "vite", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }), + ).toStrictEqual({ pkg: "vite", version: "8.0.0", tag: "v8.0.0" }); + }); + + it("returns undefined when the subject does not identify a release", async () => { + const fixture = await createPackages({ vite: "8.0.0" }); + + expect( + detectReleaseCommit({ + subject: "fix: something else", + packages: ["vite"], + defaultPackage: "vite", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }), + ).toBeUndefined(); + }); +}); diff --git a/tests/prepare.test.ts b/tests/prepare.test.ts new file mode 100644 index 0000000..2608592 --- /dev/null +++ b/tests/prepare.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { createFixture } from "fs-fixture"; +import { prepareRelease } from "../src/prepare.ts"; + +async function createPackage(version: string, isPrivate = false) { + const fixture = await createFixture({ + packages: { + example: { + "package.json": JSON.stringify({ + name: "@vitejs/example", + version, + private: isPrivate, + }), + }, + }, + }); + onTestFinished(() => fixture.rm()); + return fixture; +} + +async function readVersion(root: string): Promise { + const packageJson = JSON.parse( + await fs.readFile(path.join(root, "packages/example/package.json"), "utf8"), + ); + return packageJson.version; +} + +it("prepares an exact version and invokes the changelog callback", async () => { + const fixture = await createPackage("1.2.3", true); + const calls: [string, string][] = []; + + const result = await prepareRelease({ + pkg: "example", + release: "2.0.0", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + generateChangelog: (pkg, version) => { + calls.push([pkg, version]); + }, + }); + + expect(result).toEqual({ + pkg: "example", + previousVersion: "1.2.3", + tag: "example@2.0.0", + version: "2.0.0", + }); + expect(await readVersion(fixture.path)).toBe("2.0.0"); + expect(calls).toEqual([["example", "2.0.0"]]); +}); + +it("returns a custom release tag", async () => { + const fixture = await createPackage("1.2.3"); + + const result = await prepareRelease({ + pkg: "example", + release: "2.0.0", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + toTag: (_pkg, version) => `v${version}`, + }); + + expect(result.tag).toBe("v2.0.0"); +}); + +it("resolves next to a patch for a stable version", async () => { + const fixture = await createPackage("1.2.3"); + + const result = await prepareRelease({ + pkg: "example", + release: "next", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }); + + expect(result.version).toBe("1.2.4"); +}); + +it("preserves the identifier when advancing an existing prerelease", async () => { + const fixture = await createPackage("1.2.3-alpha.1"); + + const result = await prepareRelease({ + pkg: "example", + release: "next", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }); + + expect(result.version).toBe("1.2.3-alpha.2"); +}); + +it("uses beta.1 for a new prerelease by default", async () => { + const fixture = await createPackage("1.2.3"); + + const result = await prepareRelease({ + pkg: "example", + release: "preminor", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }); + + expect(result.version).toBe("1.3.0-beta.1"); +}); + +it("rejects an invalid release without modifying package.json", async () => { + const fixture = await createPackage("1.2.3"); + + await expect( + prepareRelease({ + pkg: "example", + release: "banana", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }), + ).rejects.toThrow("Invalid Version: banana"); + expect(await readVersion(fixture.path)).toBe("1.2.3"); +}); + +for (const pkg of [undefined, "other"]) { + it(`rejects invalid package ${JSON.stringify(pkg)}`, async () => { + await expect( + prepareRelease({ + packages: ["example"], + pkg, + release: "patch", + }), + ).rejects.toThrow(`Invalid release package ${JSON.stringify(pkg)}. Expected one of: example`); + }); +} + +it("rejects an unsupported prerelease without modifying package.json", async () => { + const fixture = await createPackage("1.2.3"); + + await expect( + prepareRelease({ + pkg: "example", + release: "2.0.0-rc.1", + getPkgDir: (pkg) => path.join(fixture.path, "packages", pkg), + }), + ).rejects.toThrow("Only alpha and beta prereleases are supported, received 2.0.0-rc.1"); + expect(await readVersion(fixture.path)).toBe("1.2.3"); +}); diff --git a/tests/publish.test.ts b/tests/publish.test.ts new file mode 100644 index 0000000..74f8ff2 --- /dev/null +++ b/tests/publish.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { getPublishTag, validatePublishVersion } from "../src/publish.ts"; + +describe("getPublishTag", () => { + for (const [version, expected] of [ + ["1.2.3-alpha.1", "alpha"], + ["1.2.3-beta.2", "beta"], + ] as const) { + it(`maps ${version} to the matching npm prerelease tag`, () => { + expect(getPublishTag(version)).toBe(expected); + }); + } + + it("uses previous when publishing an older stable version", () => { + expect(getPublishTag("1.2.3", "2.0.0")).toBe("previous"); + }); + + it("uses the default npm tag for the newest stable version", () => { + expect(getPublishTag("2.0.0", "1.2.3")).toBeUndefined(); + }); +}); + +describe("validatePublishVersion", () => { + for (const version of ["1.2.3-rc.1", "1.2.3-foobaralpha.1"]) { + it(`rejects unsupported prerelease version ${version}`, () => { + expect(() => validatePublishVersion(version)).toThrow( + `Only alpha and beta prereleases are supported, received ${version}`, + ); + }); + } + + it("rejects an invalid semantic version", () => { + expect(() => validatePublishVersion("banana")).toThrow('Invalid publish version "banana"'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a928ef6..3ae3e33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["ES2021"], "target": "ES2021", "skipLibCheck": true, + "types": ["node"], /* Transpile with esbuild */ "moduleResolution": "nodenext",