Skip to content

Commit 3589b00

Browse files
authored
ci: recover alpha train after empty changesets (#277)
## Summary - prune empty changesets in the release-alpha-train runner checkout before changesets/action decides between versioning and publishing - make release-alpha-train ignore prerelease-tracked changesets from .changeset/pre.json and empty changesets when checking whether GoReleaser can complete the current train - extend release-plan and release-helper tests so consumed prerelease changesets plus empty CI changesets do not block recovery, while real unconsumed package changesets still do ## Validation - corepack pnpm nx test @zitadel/cli -- tests/unit/scripts/release-alpha-train.test.ts tests/unit/scripts/check-alpha-release-plan.test.ts - corepack pnpm run check -- --only release - node scripts/release-alpha-train.mjs status --published false --remote false - git diff --check ## Release notes / changeset - Added an empty changeset: .changeset/fix-alpha-prerelease-recovery.md ## Notes - Run 27445059622 skipped GoReleaser because changesets/action returned before npm publish on empty changesets, then the alpha status guard treated prerelease bookkeeping files as pending changesets. After this patch, the local status probe for the same 0.1.0-alpha.3 state reports should_complete=true, create_tag=true, and run_goreleaser=true.
1 parent f9710d7 commit 3589b00

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)