Skip to content

Commit 5b09108

Browse files
authored
feat: add utils for PR-driven release (#120)
1 parent e5f27e7 commit 5b09108

14 files changed

Lines changed: 703 additions & 103 deletions

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
# @vitejs/release-scripts
22

3-
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`
3+
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`
4+
5+
## prepareRelease
6+
7+
`prepareRelease` updates a package manifest without committing, tagging, or pushing. This is intended for release-PR workflows.
8+
9+
```ts
10+
import { generateChangelog, prepareRelease } from "@vitejs/release-scripts";
11+
12+
await prepareRelease({
13+
pkg: "my-package",
14+
release: process.env.RELEASE ?? "next",
15+
generateChangelog: async (pkg) => {
16+
await generateChangelog({
17+
getPkgDir: () => `packages/${pkg}`,
18+
tagPrefix: `${pkg}@`,
19+
});
20+
},
21+
});
22+
```
23+
24+
`prepareRelease` returns the package, previous and next versions, and release tag. Pass `toTag`
25+
when a repository does not use the default `<package>@<version>` convention.
26+
27+
## detectReleaseCommit
28+
29+
```ts
30+
import { detectReleaseCommit } from "@vitejs/release-scripts";
31+
32+
const release = detectReleaseCommit({
33+
subject: "release: v1.2.3 (#42)",
34+
packages: ["vite", "create-vite"],
35+
defaultPackage: "vite",
36+
});
37+
```
38+
39+
Use `extractChangelogEntry({ changelogPath, version })` to obtain the Markdown body for a GitHub release or release PR.
440

541
## release
642

action/release-pr/action.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Open release PR
2+
description: Commit prepared release files and open a pull request
3+
4+
inputs:
5+
app-slug:
6+
description: Slug of the GitHub App used to open the pull request
7+
required: true
8+
token:
9+
description: GitHub App token with contents and pull request write access
10+
required: true
11+
package-path:
12+
description: Path to the package to release
13+
required: true
14+
pr-body:
15+
description: Pull request body
16+
required: true
17+
tag:
18+
description: Release tag
19+
required: true
20+
version:
21+
description: Release version
22+
required: true
23+
24+
runs:
25+
using: composite
26+
steps:
27+
- name: Open release PR
28+
shell: bash
29+
env:
30+
APP_SLUG: ${{ inputs.app-slug }}
31+
GH_TOKEN: ${{ inputs.token }}
32+
PACKAGE_NAME: ${{ inputs.package-name }}
33+
PACKAGE_PATH: ${{ inputs.package-path }}
34+
PR_BODY: ${{ inputs.pr-body }}
35+
TAG: ${{ inputs.tag }}
36+
VERSION: ${{ inputs.version }}
37+
run: |
38+
BRANCH="release-$PACKAGE_NAME-$VERSION-$GITHUB_RUN_ID"
39+
BOT_ID="$(gh api "/users/$APP_SLUG%5Bbot%5D" --jq .id)"
40+
git config user.name "$APP_SLUG[bot]"
41+
git config user.email "$BOT_ID+$APP_SLUG[bot]@users.noreply.github.com"
42+
git switch -c "$BRANCH"
43+
git add "$PACKAGE_PATH"
44+
git commit -m "release: $TAG"
45+
git push -u "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" HEAD
46+
gh pr create \
47+
--assignee "$GITHUB_ACTOR" \
48+
--base main \
49+
--head "$BRANCH" \
50+
--title "release: $TAG" \
51+
--body "$PR_BODY"

src/changelog.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import createPreset, {
55
DEFAULT_COMMIT_TYPES,
66
formatCommitUrl,
77
} from "conventional-changelog-conventionalcommits";
8-
import type { generateChangelog as def } from "./types.d.ts";
8+
import type {
9+
extractChangelogEntry as extractChangelogEntryDef,
10+
generateChangelog as generateChangelogDef,
11+
} from "./types.d.ts";
912
import {
1013
heading,
1114
link,
@@ -38,7 +41,22 @@ interface ExtendedCommitNote extends CommitNote {
3841
};
3942
}
4043

41-
export const generateChangelog: typeof def = async ({ getPkgDir, tagPrefix }) => {
44+
export const extractChangelogEntry: typeof extractChangelogEntryDef = ({
45+
changelogPath,
46+
version,
47+
}) => {
48+
const sections = fs.readFileSync(changelogPath, "utf-8").split(/^## /m).slice(1);
49+
const section = sections.find((candidate) => {
50+
const heading = candidate.split("\n", 1)[0];
51+
return heading.includes(`[${version}](`) || heading.startsWith(`${version} (`);
52+
});
53+
if (!section) throw new Error(`Missing changelog entry for ${version}`);
54+
55+
const entry = section.split("\n").slice(1).join("\n").trim();
56+
return entry;
57+
};
58+
59+
export const generateChangelog: typeof generateChangelogDef = async ({ getPkgDir, tagPrefix }) => {
4260
const preset: Preset = createPreset({
4361
types: DEFAULT_COMMIT_TYPES.map((t) => ({
4462
...t,

src/detectRelease.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type {
4+
detectReleaseCommit as detectReleaseCommitDef,
5+
getReleaseTag as getReleaseTagDef,
6+
isReleaseCommitSubject as isReleaseCommitSubjectDef,
7+
} from "./types.d.ts";
8+
9+
export const getReleaseTag: typeof getReleaseTagDef = (pkg, version, defaultPackage) => {
10+
return pkg === defaultPackage ? `v${version}` : `${pkg}@${version}`;
11+
};
12+
13+
export const isReleaseCommitSubject: typeof isReleaseCommitSubjectDef = (subject, tag) => {
14+
const expected = `release: ${tag}`;
15+
return (
16+
subject === expected ||
17+
(subject.startsWith(expected) && /^ \(#\d+\)$/.test(subject.slice(expected.length)))
18+
);
19+
};
20+
21+
export const detectReleaseCommit: typeof detectReleaseCommitDef = ({
22+
subject,
23+
packages,
24+
defaultPackage,
25+
getPkgDir = (pkg) => `packages/${pkg}`,
26+
toTag = (pkg, version) => getReleaseTag(pkg, version, defaultPackage),
27+
}) => {
28+
for (const pkg of packages) {
29+
const pkgPath = path.resolve(getPkgDir(pkg), "package.json");
30+
const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
31+
version?: unknown;
32+
};
33+
if (typeof packageJson.version !== "string") {
34+
throw new Error(`Package ${JSON.stringify(pkg)} does not have a valid version`);
35+
}
36+
37+
const version = packageJson.version;
38+
const tag = toTag(pkg, version);
39+
if (isReleaseCommitSubject(subject, tag)) return { pkg, version, tag };
40+
}
41+
return undefined;
42+
};

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
export { publish } from "./publish.ts";
1+
export { getPublishTag, publish, validatePublishVersion } from "./publish.ts";
22
export { release } from "./release.ts";
3-
export { generateChangelog } from "./changelog.ts";
3+
export { extractChangelogEntry, generateChangelog } from "./changelog.ts";
4+
export { detectReleaseCommit, getReleaseTag, isReleaseCommitSubject } from "./detectRelease.ts";
5+
export { prepareRelease } from "./prepare.ts";

src/prepare.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import semver from "semver";
4+
import type { ReleaseType } from "semver";
5+
import { getReleaseTag } from "./detectRelease.ts";
6+
import { validatePublishVersion } from "./publish.ts";
7+
import type { prepareRelease as prepareReleaseDef } from "./types.d.ts";
8+
import { updateVersion } from "./utils.ts";
9+
10+
function resolveVersion(currentVersion: string, release: string, preid: string): string {
11+
if (semver.valid(release)) return release;
12+
13+
const releaseType =
14+
release === "next" ? (semver.prerelease(currentVersion) ? "prerelease" : "patch") : release;
15+
if (!semver.RELEASE_TYPES.includes(releaseType as ReleaseType)) {
16+
throw new Error(`Invalid Version: ${release}`);
17+
}
18+
19+
let version = semver.inc(currentVersion, releaseType as ReleaseType, preid);
20+
if (!version) throw new Error(`Invalid Version: ${release}`);
21+
22+
const prerelease = semver.prerelease(version);
23+
if (releaseType.startsWith("pre") && prerelease?.[0] === preid && prerelease[1] === 0) {
24+
version = semver.inc(version, "prerelease", preid)!;
25+
}
26+
return version;
27+
}
28+
29+
export const prepareRelease: typeof prepareReleaseDef = async ({
30+
packages,
31+
pkg,
32+
release,
33+
preid,
34+
getPkgDir = (pkgName) => `packages/${pkgName}`,
35+
toTag = (pkgName, version) => getReleaseTag(pkgName, version),
36+
generateChangelog,
37+
}) => {
38+
if (!pkg || (packages && !packages.includes(pkg))) {
39+
const expected = packages?.length ? ` Expected one of: ${packages.join(", ")}` : "";
40+
throw new Error(`Invalid release package ${JSON.stringify(pkg)}.${expected}`);
41+
}
42+
43+
const pkgDir = path.resolve(getPkgDir(pkg));
44+
const pkgPath = path.join(pkgDir, "package.json");
45+
if (semver.valid(release)) validatePublishVersion(release);
46+
const packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
47+
version?: unknown;
48+
};
49+
if (typeof packageJson.version !== "string") {
50+
throw new Error(`Package ${JSON.stringify(pkg)} does not have a valid version`);
51+
}
52+
const currentPrerelease = semver.prerelease(packageJson.version);
53+
const currentPreid =
54+
typeof currentPrerelease?.[0] === "string" ? currentPrerelease[0] : undefined;
55+
56+
const previousVersion = packageJson.version;
57+
const version = resolveVersion(previousVersion, release, preid ?? currentPreid ?? "beta");
58+
validatePublishVersion(version);
59+
updateVersion(pkgPath, version);
60+
61+
await generateChangelog?.(pkg, version);
62+
63+
return { pkg, previousVersion, version, tag: toTag(pkg, version) };
64+
};

src/publish.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
11
import * as semver from "semver";
22
import { args, getActiveVersion, getPackageInfo, publishPackage, step } from "./utils.ts";
3-
import type { publish as def } from "./types.d.ts";
3+
import type {
4+
getPublishTag as getPublishTagDef,
5+
publish as publishDef,
6+
validatePublishVersion as validatePublishVersionDef,
7+
} from "./types.d.ts";
48

5-
export const publish: typeof def = async ({
9+
export const validatePublishVersion: typeof validatePublishVersionDef = (version) => {
10+
const parsed = semver.parse(version);
11+
if (!parsed) {
12+
throw new Error(`Invalid publish version ${JSON.stringify(version)}`);
13+
}
14+
15+
const prereleaseIdentifier = parsed.prerelease[0];
16+
if (
17+
prereleaseIdentifier !== undefined &&
18+
prereleaseIdentifier !== "alpha" &&
19+
prereleaseIdentifier !== "beta"
20+
) {
21+
throw new Error(`Only alpha and beta prereleases are supported, received ${version}`);
22+
}
23+
};
24+
25+
export const getPublishTag: typeof getPublishTagDef = (version, activeVersion) => {
26+
validatePublishVersion(version);
27+
const prereleaseIdentifier = semver.prerelease(version)?.[0];
28+
if (prereleaseIdentifier === "alpha" || prereleaseIdentifier === "beta") {
29+
return prereleaseIdentifier;
30+
}
31+
return activeVersion && semver.lt(version, activeVersion) ? "previous" : undefined;
32+
};
33+
34+
export const publish: typeof publishDef = async ({
635
defaultPackage,
736
getPkgDir,
837
provenance,
@@ -29,16 +58,11 @@ export const publish: typeof def = async ({
2958
throw new Error(
3059
`Package version from tag "${version}" mismatches with current version "${pkg.version}"`,
3160
);
61+
validatePublishVersion(version);
3262

3363
const activeVersion = await getActiveVersion(pkg.name);
3464

3565
step("Publishing package...");
36-
const releaseTag = version.includes("beta")
37-
? "beta"
38-
: version.includes("alpha")
39-
? "alpha"
40-
: activeVersion && semver.lt(pkg.version, activeVersion)
41-
? "previous"
42-
: undefined;
66+
const releaseTag = getPublishTag(version, activeVersion);
4367
await publishPackage(pkgDir, releaseTag, provenance, packageManager);
4468
};

src/types.d.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,46 @@ export declare function generateChangelog(options: {
55
tagPrefix?: string;
66
}): Promise<void>;
77

8+
export declare function extractChangelogEntry(options: {
9+
changelogPath: string;
10+
version: string;
11+
}): string;
12+
13+
export declare function getReleaseTag(
14+
pkg: string,
15+
version: string,
16+
defaultPackage?: string,
17+
): string;
18+
19+
export declare function isReleaseCommitSubject(subject: string, tag: string): boolean;
20+
21+
export declare function detectReleaseCommit(options: {
22+
subject: string;
23+
packages: readonly string[];
24+
defaultPackage?: string;
25+
/** @default (pkg) => `packages/${pkg}` */
26+
getPkgDir?: (pkg: string) => string;
27+
toTag?: (pkg: string, version: string) => string;
28+
}): { pkg: string; version: string; tag: string } | undefined;
29+
30+
export declare function prepareRelease(options: {
31+
/** Restricts the accepted package names when provided. */
32+
packages?: readonly string[];
33+
pkg: string | undefined;
34+
release: string;
35+
/** @default current prerelease identifier, otherwise "beta" */
36+
preid?: string;
37+
/** @default (pkg) => `packages/${pkg}` */
38+
getPkgDir?: (pkg: string) => string;
39+
/** @default (pkg, version) => `${pkg}@${version}` */
40+
toTag?: (pkg: string, version: string) => string;
41+
generateChangelog?: (pkg: string, version: string) => void | Promise<void>;
42+
}): Promise<{ pkg: string; previousVersion: string; version: string; tag: string }>;
43+
44+
export declare function validatePublishVersion(version: string): void;
45+
46+
export declare function getPublishTag(version: string, activeVersion?: string): string | undefined;
47+
848
export declare function publish(options: {
949
defaultPackage?: string;
1050
getPkgDir?: (pkg: string) => string;

tests/__snapshots__/changelog.test.ts.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`generates a changelog with breaking changes 1`] = `
3+
exports[`generateChangelog > generates a changelog with breaking changes 1`] = `
44
"## [2.0.0](https://github.com/vitejs/test/compare/v1.0.0...v2.0.0) (yyyy-mm-dd)
55
### ⚠ BREAKING CHANGES
66
@@ -22,7 +22,7 @@ exports[`generates a changelog with breaking changes 1`] = `
2222
"
2323
`;
2424

25-
exports[`generates a changelog with commits 1`] = `
25+
exports[`generateChangelog > generates a changelog with commits 1`] = `
2626
"## <small>[1.0.1](https://github.com/vitejs/test/compare/v1.0.0...v1.0.1) (yyyy-mm-dd)</small>
2727
### Bug Fixes
2828
@@ -36,7 +36,7 @@ exports[`generates a changelog with commits 1`] = `
3636
"
3737
`;
3838

39-
exports[`generates a changelog with commits 2`] = `
39+
exports[`generateChangelog > generates a changelog with commits 2`] = `
4040
"## [1.1.0](https://github.com/vitejs/test/compare/v1.0.1...v1.1.0) (yyyy-mm-dd)
4141
### Features
4242
@@ -55,7 +55,7 @@ exports[`generates a changelog with commits 2`] = `
5555
"
5656
`;
5757

58-
exports[`generates a new changelog for empty project 1`] = `
58+
exports[`generateChangelog > generates a new changelog for empty project 1`] = `
5959
"## 1.0.0 (yyyy-mm-dd)
6060
### Miscellaneous Chores
6161

0 commit comments

Comments
 (0)