Skip to content
Open
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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 `<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.

## release

Expand Down
51 changes: 51 additions & 0 deletions action/release-pr/action.yml
Original file line number Diff line number Diff line change
@@ -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"
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