Skip to content
Merged
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
7 changes: 7 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export default {
"oxc-transform-react",
// OG tests resolve these from vinext's @vercel/og install, not the root workspace.
"@vercel/og",
// scripts/version.mts resolves release APIs from the pinned Changesets CLI,
// keeping its dependency tree authoritative instead of installing separate copies.
"@manypkg/get-packages",
"@changesets/config",
"@changesets/get-release-plan",
"@changesets/apply-release-plan",
"@changesets/should-skip-package",
],
},
"packages/vinext": {
Expand Down
147 changes: 147 additions & 0 deletions scripts/changeset-prerelease.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { expect, it } from "vite-plus/test";

import { versionPackages } from "./version.mts";

// Regression for https://github.com/changesets/changesets/issues/2024.
it.each([false, true])(
"exits prereleases without bumping skipped packages (private versioning: %s)",
async (versionPrivatePackages) => {
const root = mkdtempSync(join(tmpdir(), "vinext-changeset-pre-exit-"));
const packages = [
{ name: "public-beta", version: "1.0.0-beta.2" },
{ name: "public-stable", version: "1.2.3" },
{ name: "private-fixture", private: true },
{ name: "private-stable", private: true, version: "0.2.0" },
{ name: "private-beta", private: true, version: "1.0.0-beta.1" },
{ name: "ignored-beta", version: "1.0.0-beta.1" },
{
name: "private-dependent",
private: true,
dependencies: { "public-beta": "workspace:^1.0.0-beta.2" },
},
];

try {
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "test-workspace", private: true, workspaces: ["packages/*"] }),
);
mkdirSync(join(root, ".changeset"));
writeFileSync(
join(root, ".changeset/config.json"),
JSON.stringify({
changelog: "@changesets/cli/changelog",
ignore: ["ignored-beta"],
privatePackages: { version: versionPrivatePackages, tag: false },
}),
);
writeFileSync(
join(root, ".changeset/pre.json"),
JSON.stringify({ mode: "exit", tag: "beta", initialVersions: {}, changesets: [] }),
);
const changesetPath = join(root, ".changeset/public-beta.md");
writeFileSync(
changesetPath,
'---\n"public-beta": patch\n---\n\nRelease the public package.\n',
);
for (const pkg of packages) {
const dir = join(root, "packages", pkg.name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg));
}
const existingChangelog = join(root, "packages/private-stable/CHANGELOG.md");
writeFileSync(existingChangelog, "# Existing private changelog\n");

await versionPackages(root);

for (const pkg of packages) {
const shouldGraduate =
pkg.name === "public-beta" || (pkg.name === "private-beta" && versionPrivatePackages);
const actual = JSON.parse(
readFileSync(join(root, "packages", pkg.name, "package.json"), "utf8"),
);
const expected = shouldGraduate ? { ...pkg, version: "1.0.0" } : { ...pkg };
if (pkg.name === "private-dependent") {
expected.dependencies = { "public-beta": "workspace:^1.0.0" };
}
expect(actual, pkg.name).toEqual(expected);
const changelogPath = join(root, "packages", pkg.name, "CHANGELOG.md");
if (shouldGraduate) {
expect(readFileSync(changelogPath, "utf8")).toContain("## 1.0.0");
} else if (pkg.name === "private-stable") {
expect(readFileSync(changelogPath, "utf8")).toBe("# Existing private changelog\n");
} else {
expect(existsSync(changelogPath), pkg.name).toBe(false);
}
}
expect(existsSync(join(root, ".changeset/pre.json"))).toBe(false);
expect(existsSync(changesetPath)).toBe(false);

writeFileSync(changesetPath, '---\n"public-beta": patch\n---\n\nNext stable release.\n');
await versionPackages(root);
expect(
JSON.parse(readFileSync(join(root, "packages/public-beta/package.json"), "utf8")).version,
).toBe("1.0.1");
expect(
JSON.parse(readFileSync(join(root, "packages/private-dependent/package.json"), "utf8")),
).toEqual({
name: "private-dependent",
private: true,
dependencies: { "public-beta": "workspace:^1.0.1" },
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);

it.each(["dependencies", "devDependencies"])(
"validates expanded ignore globs for %s before writing releases",
async (dependencyType) => {
const root = mkdtempSync(join(tmpdir(), "vinext-changeset-ignore-"));
const pkg = {
name: "public-package",
version: "1.0.0",
[dependencyType]: { "ignored-package": "workspace:^1.0.0" },
};
try {
writeFileSync(
join(root, "package.json"),
JSON.stringify({ name: "test-workspace", private: true, workspaces: ["packages/*"] }),
);
mkdirSync(join(root, ".changeset"));
writeFileSync(
join(root, ".changeset/config.json"),
JSON.stringify({ changelog: false, ignore: ["ignored-*"] }),
);
const changesetPath = join(root, ".changeset/public-package.md");
writeFileSync(changesetPath, '---\n"public-package": patch\n---\n\nPublic package fix.\n');
for (const manifest of [pkg, { name: "ignored-package", version: "1.0.0" }]) {
const dir = join(root, "packages", manifest.name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify(manifest));
}

const manifestPath = join(root, "packages/public-package/package.json");
if (dependencyType === "dependencies") {
await expect(versionPackages(root)).rejects.toThrow(
'"public-package" depends on the skipped package "ignored-package"',
);
expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toEqual(pkg);
expect(existsSync(changesetPath)).toBe(true);
} else {
await versionPackages(root);
expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toEqual({
...pkg,
version: "1.0.1",
});
}
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
65 changes: 54 additions & 11 deletions scripts/version.mts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env node
/**
* `changeset version` + a grouped, conventional-commits changelog with a bottom
* `## Contributors` list. Used as the `version:` command for `changesets/action`
* (see .github/workflows/release.yml).
* Apply the Changesets release plan + a grouped, conventional-commits changelog
* with a bottom `## Contributors` list. Used as the `version:` command for
* `changesets/action` (see .github/workflows/release.yml).
*
* Changesets' default changelog groups by bump level (Minor/Patch Changes), not
* by commit type, and has no end-of-release contributor hook. So after running
* `changeset version` we rewrite each bumped package's newest CHANGELOG section:
* by commit type, and has no end-of-release contributor hook. After applying the
* release plan we rewrite each bumped package's newest CHANGELOG section:
* the release commits are regrouped into `### Features` / `### Bug Fixes` / etc.
* and a deduped, bot-filtered `## Contributors` list is appended. The pure
* builders (groupedChangelogBody, rewriteReleaseSection, dedupeSortLogins) are
Expand All @@ -18,6 +18,7 @@

import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand Down Expand Up @@ -232,19 +233,61 @@ function resolveContributors(from: string, repository: string, commits: Commit[]
}
}

function main(): void {
/** Apply only eligible releases, while retaining dependency-only updates. */
export async function versionPackages(root: string): Promise<void> {
// Use the APIs from the pinned CLI's dependency tree, without a second toolchain.
const require = createRequire(import.meta.resolve("@changesets/cli/package.json"));
const { getPackages } = require("@manypkg/get-packages");
const { parse: parseConfig } = require("@changesets/config");
const { default: getReleasePlan } = require("@changesets/get-release-plan");
const { default: applyReleasePlan } = require("@changesets/apply-release-plan");
const { shouldSkipPackage } = require("@changesets/should-skip-package");
const packages = await getPackages(root);
const writtenConfig = JSON.parse(
readFileSync(join(packages.root.dir, ".changeset/config.json"), "utf8"),
);
const config = parseConfig(writtenConfig, packages);
// Match the CLI's second dependency-policy validation after expanding ignore globs.
parseConfig({ ...writtenConfig, ignore: config.ignore }, packages);
const plan = await getReleasePlan(root, undefined, config);
const skippedNames = new Set<string>();
for (const pkg of packages.packages) {
if (
shouldSkipPackage(pkg, {
ignore: config.ignore,
allowPrivatePackages: config.privatePackages.version,
})
) {
skippedNames.add(pkg.packageJson.name);
}
}
// Prerelease exit can reintroduce skipped packages as patch releases:
// https://github.com/changesets/changesets/issues/2024
plan.releases = plan.releases.filter(
(release: { name: string; type: string }) =>
release.type === "none" || !skippedNames.has(release.name),
);
await applyReleasePlan(
plan,
packages,
config,
undefined,
dirname(require.resolve("@changesets/cli")),
);
}

async function main(): Promise<void> {
const repository = process.env.GITHUB_REPOSITORY || "";
const packages = discoverPublishablePackages();
// Load the same SHA-named changeset overrides the generator uses, so a commit
// reclassified there (e.g. feat → fix) lands in the matching changelog section.
// Must happen BEFORE `changeset version` below, which consumes and deletes the
// Must happen BEFORE applying the plan below, which consumes and deletes the
// `.changeset/<sha>.md` files; the grouping later reads this in-memory copy.
const overrides = loadOverrides();
const before = readVersions(packages);

console.log("[version] Running `changeset version`...");
// `vp exec` runs the pinned, installed @changesets/cli — not a floating `dlx` fetch.
execFileSync("vp", ["exec", "changeset", "version"], { cwd: REPO_ROOT, stdio: "inherit" });
console.log("[version] Applying the Changesets release plan...");
await versionPackages(REPO_ROOT);

const after = readVersions(packages);

Expand Down Expand Up @@ -274,4 +317,4 @@ function main(): void {
}
}

if (import.meta.url === `file://${process.argv[1]}`) main();
if (import.meta.url === `file://${process.argv[1]}`) await main();
Loading