Skip to content

Commit 9923946

Browse files
committed
ci: recover alpha train after empty changesets
1 parent f9710d7 commit 9923946

6 files changed

Lines changed: 232 additions & 11 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
---
3+

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,9 @@ jobs:
842842
- name: Build packages
843843
run: corepack pnpm nx run-many -t build
844844

845+
- name: Prune empty changesets before publish decision
846+
run: node scripts/release-alpha-train.mjs prune-empty-changesets
847+
845848
- name: Create release PR or publish to npm
846849
id: changesets
847850
uses: changesets/action@v1

apps/cli/tests/unit/scripts/check-alpha-release-plan.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ describe("check-alpha-release-plan script", () => {
107107
).rejects.toThrow("Changesets must not create package-shaped GitHub Releases");
108108
});
109109

110+
it("rejects a workflow that lets empty changesets suppress npm publishing", async () => {
111+
const { cwd, statusPath } = await fixtureRepo({
112+
ciWorkflow: validCiWorkflow().replace(
113+
[
114+
" - name: Prune empty changesets before publish decision",
115+
" run: node scripts/release-alpha-train.mjs prune-empty-changesets",
116+
].join("\n"),
117+
"",
118+
),
119+
});
120+
121+
await expect(
122+
checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }),
123+
).rejects.toThrow("must prune empty changesets before Changesets decides whether to publish");
124+
});
125+
110126
it("rejects alpha release notes generated inside the checkout", async () => {
111127
const { cwd, statusPath } = await fixtureRepo({
112128
ciWorkflow: validCiWorkflow().replace(
@@ -241,7 +257,11 @@ function validCiWorkflow(): string {
241257
" packages: write",
242258
" id-token: write",
243259
" steps:",
244-
" - uses: changesets/action@v1",
260+
" - name: Prune empty changesets before publish decision",
261+
" run: node scripts/release-alpha-train.mjs prune-empty-changesets",
262+
"",
263+
" - name: Create release PR or publish to npm",
264+
" uses: changesets/action@v1",
245265
" with:",
246266
" createGithubReleases: false",
247267
" - id: alpha-status",

apps/cli/tests/unit/scripts/release-alpha-train.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
77
type ReleaseAlphaTrainModule = {
88
PUBLIC_PACKAGE_MANIFESTS: string[];
99
PUBLIC_PACKAGE_NAMES: string[];
10+
pruneEmptyChangesets: (options: { cwd: string }) => Promise<string[]>;
1011
inspectAlphaReleaseTrain: (options: {
1112
cwd: string;
1213
execFile?: ExecFileMock;
@@ -147,6 +148,65 @@ describe("release-alpha-train script", () => {
147148
});
148149
});
149150

151+
it("ignores prerelease-tracked and empty changesets when recovering a versioned train", async () => {
152+
const cwd = await fixtureRepo({
153+
changesets: {
154+
"add-spa-sdks.md": releaseChangeset("@zitadel/cli", "minor"),
155+
"ci-only.md": emptyChangeset(),
156+
},
157+
preChangesets: ["add-spa-sdks"],
158+
});
159+
160+
await expect(
161+
releaseAlphaTrain.inspectAlphaReleaseTrain({
162+
cwd,
163+
execFile: commandMock(),
164+
remote: false,
165+
}),
166+
).resolves.toMatchObject({
167+
shouldComplete: true,
168+
skipReason: "",
169+
});
170+
});
171+
172+
it("blocks unconsumed release changesets before npm publishes", async () => {
173+
const cwd = await fixtureRepo({
174+
changesets: {
175+
"feature.md": releaseChangeset("@zitadel/cli", "minor"),
176+
},
177+
});
178+
179+
await expect(
180+
releaseAlphaTrain.inspectAlphaReleaseTrain({
181+
cwd,
182+
execFile: commandMock(),
183+
remote: false,
184+
}),
185+
).resolves.toMatchObject({
186+
shouldComplete: false,
187+
skipReason: "pending changesets: feature.md",
188+
});
189+
});
190+
191+
it("prunes empty changesets before Changesets decides whether to publish", async () => {
192+
const cwd = await fixtureRepo({
193+
changesets: {
194+
"ci-only.md": emptyChangeset(),
195+
"feature.md": releaseChangeset("@zitadel/cli", "minor"),
196+
},
197+
});
198+
199+
await expect(releaseAlphaTrain.pruneEmptyChangesets({ cwd })).resolves.toEqual([
200+
"ci-only.md",
201+
]);
202+
await expect(readFile(join(cwd, ".changeset/ci-only.md"), "utf8")).rejects.toMatchObject({
203+
code: "ENOENT",
204+
});
205+
await expect(readFile(join(cwd, ".changeset/feature.md"), "utf8")).resolves.toContain(
206+
"@zitadel/cli",
207+
);
208+
});
209+
150210
it("skips GoReleaser when both the GitHub Release and container image already exist", async () => {
151211
const cwd = await fixtureRepo();
152212

@@ -196,8 +256,10 @@ describe("release-alpha-train script", () => {
196256

197257
async function fixtureRepo(
198258
options: {
259+
changesets?: Record<string, string>;
199260
versions?: Record<string, string>;
200261
fixedGroupExtra?: string[];
262+
preChangesets?: string[];
201263
} = {},
202264
): Promise<string> {
203265
const cwd = await mkdtemp(join(tmpdir(), "zitadel-alpha-train-"));
@@ -231,10 +293,36 @@ async function fixtureRepo(
231293
2,
232294
)}\n`,
233295
);
296+
if (options.preChangesets) {
297+
await writeFile(
298+
join(cwd, ".changeset/pre.json"),
299+
`${JSON.stringify(
300+
{
301+
mode: "pre",
302+
tag: "alpha",
303+
initialVersions: {},
304+
changesets: options.preChangesets,
305+
},
306+
null,
307+
2,
308+
)}\n`,
309+
);
310+
}
311+
for (const [file, content] of Object.entries(options.changesets ?? {})) {
312+
await writeFile(join(cwd, ".changeset", file), content);
313+
}
234314

235315
return cwd;
236316
}
237317

318+
function releaseChangeset(name: string, type: "major" | "minor" | "patch"): string {
319+
return ["---", `"${name}": ${type}`, "---", "", "Release package change.", ""].join("\n");
320+
}
321+
322+
function emptyChangeset(): string {
323+
return ["---", "---", "", "CI-only change.", ""].join("\n");
324+
}
325+
238326
function commandMock(
239327
options: {
240328
headCommit?: string;

scripts/check-alpha-release-plan.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,17 @@ export async function validateReleaseTooling(cwd, readFileFn = readFileDefault)
177177
"createGithubReleases: false",
178178
"Changesets must not create package-shaped GitHub Releases",
179179
);
180+
assertJobContains(
181+
ciWorkflow,
182+
"release-alpha-train",
183+
[
184+
" - name: Prune empty changesets before publish decision",
185+
" run: node scripts/release-alpha-train.mjs prune-empty-changesets",
186+
"",
187+
" - name: Create release PR or publish to npm",
188+
].join("\n"),
189+
"release-alpha-train must prune empty changesets before Changesets decides whether to publish",
190+
);
180191
assertContains(
181192
ciWorkflow,
182193
"node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false",

scripts/release-alpha-train.mjs

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22
import { execFile as execFileCallback } from "node:child_process";
3-
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
44
import { join } from "node:path";
55
import { promisify } from "node:util";
66

@@ -113,7 +113,10 @@ export async function inspectAlphaReleaseTrain(options = {}) {
113113
const tagName = `v${version}`;
114114
const image = `${SERVER_IMAGE_NAME}:${version}`;
115115
const title = `ZITADEL Alpha ${version}`;
116-
const activeChangesets = await activeChangesetFiles(cwd, readdirFn);
116+
const activeChangesets = await activeChangesetFiles(cwd, {
117+
readFile: readFileFn,
118+
readdir: readdirFn,
119+
});
117120
const headCommit = await gitOutput(execFileFn, cwd, ["rev-parse", "HEAD"]);
118121
const tagCommit = await tagCommitFor(tagName, execFileFn, cwd);
119122
const tagExists = Boolean(tagCommit);
@@ -246,6 +249,26 @@ export async function tagExists(tagName, execFileFn = execFile, cwd = process.cw
246249
return Boolean(await tagCommitFor(tagName, execFileFn, cwd));
247250
}
248251

252+
export async function pruneEmptyChangesets(options = {}) {
253+
const cwd = options.cwd ?? process.cwd();
254+
const readFileFn = options.readFile ?? readFile;
255+
const readdirFn = options.readdir ?? readdir;
256+
const unlinkFn = options.unlink ?? unlink;
257+
258+
const removed = [];
259+
const files = await changesetMarkdownFiles(cwd, readdirFn);
260+
for (const file of files) {
261+
const path = join(cwd, ".changeset", file);
262+
const content = await readFileFn(path, "utf8");
263+
if (!changesetHasReleaseBump(content)) {
264+
await unlinkFn(path);
265+
removed.push(file);
266+
}
267+
}
268+
269+
return removed.sort();
270+
}
271+
249272
export function renderAlphaReleaseNotes({ title, version, image, packages }) {
250273
const lines = [
251274
`# ${title}`,
@@ -286,8 +309,10 @@ export function renderAlphaReleaseNotes({ title, version, image, packages }) {
286309

287310
export function parseAlphaReleaseArgs(args) {
288311
const command = args[0];
289-
if (command !== "prepare" && command !== "status") {
290-
throw new Error("Usage: release-alpha-train.mjs <status|prepare> [--out-dir <path>] [--published <true|false>]");
312+
if (command !== "prepare" && command !== "status" && command !== "prune-empty-changesets") {
313+
throw new Error(
314+
"Usage: release-alpha-train.mjs <status|prepare|prune-empty-changesets> [--out-dir <path>] [--published <true|false>]",
315+
);
291316
}
292317
const values = {};
293318
for (let index = 1; index < args.length; index += 1) {
@@ -310,7 +335,25 @@ function camelCase(value) {
310335
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
311336
}
312337

313-
async function activeChangesetFiles(cwd, readdirFn = readdir) {
338+
async function activeChangesetFiles(cwd, options = {}) {
339+
const readFileFn = options.readFile ?? readFile;
340+
const readdirFn = options.readdir ?? readdir;
341+
const prereleaseChangesets = await prereleaseTrackedChangesets(cwd, readFileFn);
342+
const files = await changesetMarkdownFiles(cwd, readdirFn);
343+
const active = [];
344+
for (const file of files) {
345+
if (prereleaseChangesets.has(changesetSlug(file))) {
346+
continue;
347+
}
348+
const content = await readFileFn(join(cwd, ".changeset", file), "utf8");
349+
if (changesetHasReleaseBump(content)) {
350+
active.push(file);
351+
}
352+
}
353+
return active.sort();
354+
}
355+
356+
async function changesetMarkdownFiles(cwd, readdirFn = readdir) {
314357
let entries = [];
315358
try {
316359
entries = await readdirFn(join(cwd, ".changeset"), { withFileTypes: true });
@@ -327,6 +370,51 @@ async function activeChangesetFiles(cwd, readdirFn = readdir) {
327370
.sort();
328371
}
329372

373+
async function prereleaseTrackedChangesets(cwd, readFileFn = readFile) {
374+
let preState;
375+
try {
376+
preState = JSON.parse(await readFileFn(join(cwd, ".changeset/pre.json"), "utf8"));
377+
} catch (error) {
378+
if (isMissingPath(error)) {
379+
return new Set();
380+
}
381+
throw error;
382+
}
383+
if (!Array.isArray(preState.changesets)) {
384+
return new Set();
385+
}
386+
return new Set(preState.changesets.filter((name) => typeof name === "string"));
387+
}
388+
389+
function changesetSlug(file) {
390+
return file.replace(/\.md$/, "");
391+
}
392+
393+
function changesetHasReleaseBump(content) {
394+
const frontmatter = changesetFrontmatter(content);
395+
if (frontmatter === undefined) {
396+
return true;
397+
}
398+
return frontmatter.split("\n").some((line) => {
399+
const trimmed = line.trim();
400+
return trimmed.length > 0 && !trimmed.startsWith("#");
401+
});
402+
}
403+
404+
function changesetFrontmatter(content) {
405+
const normalized = content.replace(/\r\n/g, "\n");
406+
if (!normalized.startsWith("---\n")) {
407+
return undefined;
408+
}
409+
const lines = normalized.split("\n");
410+
for (let index = 1; index < lines.length; index += 1) {
411+
if (lines[index] === "---") {
412+
return lines.slice(1, index).join("\n");
413+
}
414+
}
415+
return undefined;
416+
}
417+
330418
async function tagCommitFor(tagName, execFileFn = execFile, cwd = process.cwd()) {
331419
try {
332420
return await gitOutput(execFileFn, cwd, ["rev-list", "-n", "1", tagName]);
@@ -400,18 +488,26 @@ function printAlphaOutputs(result) {
400488
}
401489
}
402490

491+
function printPrunedChangesets(files) {
492+
console.log(`pruned_empty_changesets=${files.join(",")}`);
493+
}
494+
403495
function isDirectRun(url) {
404496
return process.argv[1] && url === new URL(`file://${process.argv[1]}`).href;
405497
}
406498

407499
if (isDirectRun(import.meta.url)) {
408500
try {
409501
const { command, values } = parseAlphaReleaseArgs(process.argv.slice(2));
410-
const result =
411-
command === "status"
412-
? await inspectAlphaReleaseTrain(values)
413-
: await prepareAlphaReleaseTrain(values);
414-
printAlphaOutputs(result);
502+
if (command === "prune-empty-changesets") {
503+
printPrunedChangesets(await pruneEmptyChangesets(values));
504+
} else {
505+
const result =
506+
command === "status"
507+
? await inspectAlphaReleaseTrain(values)
508+
: await prepareAlphaReleaseTrain(values);
509+
printAlphaOutputs(result);
510+
}
415511
} catch (error) {
416512
console.error(error instanceof Error ? error.message : String(error));
417513
process.exit(1);

0 commit comments

Comments
 (0)