Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

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`
Comment thread
sapphi-red marked this conversation as resolved.
Outdated

## 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 `<package>@<version>` 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.
Comment thread
sapphi-red marked this conversation as resolved.
Outdated

## release

```ts
Expand Down
22 changes: 20 additions & 2 deletions src/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions src/detectRelease.ts
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
64 changes: 64 additions & 0 deletions src/prepare.ts
Original file line number Diff line number Diff line change
@@ -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) };
};
42 changes: 33 additions & 9 deletions src/publish.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
};
40 changes: 40 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ export declare function generateChangelog(options: {
tagPrefix?: string;
}): Promise<void>;

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<void>;
}): 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;
Expand Down
8 changes: 4 additions & 4 deletions tests/__snapshots__/changelog.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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`] = `
"## <small>[1.0.1](https://github.com/vitejs/test/compare/v1.0.0...v1.0.1) (yyyy-mm-dd)</small>
### Bug Fixes

Expand All @@ -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

Expand All @@ -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

Expand Down
Loading