Skip to content

Commit 7a29f84

Browse files
committed
fix(changelog): harden release evidence handling
1 parent d299f5a commit 7a29f84

4 files changed

Lines changed: 288 additions & 45 deletions

File tree

‎scripts/changelog/README.md‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@
22

33
## Replay a historical week locally
44

5-
Set `OPENAI_API_KEY` and a GitHub token, then run:
5+
Set a GitHub token and, when the window contains eligible facts, `OPENAI_API_KEY`, then run:
66

77
```bash
88
GITHUB_TOKEN="$(gh auth token)" bun run preview-changelog \
99
--number 35 \
1010
--since 2026-07-17T15:58:38.000Z \
11-
--until 2026-07-24T13:35:35.621Z
11+
--until 2026-07-24T13:35:35.621Z \
12+
--application-release-base-sha d2f91834047984bf37e09caba27231fb07a4109a \
13+
--application-release-head-sha 4cc44305bae19df7135edda12a602841ad90ffe7
1214
```
1315

14-
Preview mode resolves the application release branch at both historical cutoffs, uses the same
15-
collection, filtering, and model paths as automation, and writes the draft and its review evidence
16-
to a new temporary directory. It does not update changelog content, metadata, generator state, or
17-
GitHub Actions output.
16+
Preview mode requires the exact application release commits at both historical cutoffs because
17+
commit timestamps cannot reconstruct when the release branch moved. It uses the same collection,
18+
filtering, and model paths as automation and writes the draft and its review evidence to a new
19+
temporary directory. It does not update changelog content, metadata, generator state, or GitHub
20+
Actions output.
21+
22+
For later replays, use `applicationReleaseSha` from the generator state at the previous and target
23+
published changelog revisions.
1824

1925
## Auditing filtered evidence
2026

‎scripts/changelog/source.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ function allFilesMatch(group: ChangeGroup, predicate: (filename: string) => bool
315315
return group.changedFiles.every((file) => predicate(file.filename));
316316
}
317317

318+
function fileWasAdded(group: ChangeGroup, filename: string): boolean {
319+
return group.commits.some((commit) =>
320+
commit.changedFiles.some((file) => file.filename === filename && file.status === 'added'),
321+
);
322+
}
323+
318324
function isCommonlyIgnoredFile(filename: string): boolean {
319325
return COMMON_IGNORED_FILE_PATTERNS.some((pattern) => pattern.test(filename));
320326
}
@@ -422,7 +428,7 @@ function classifyGroup(group: ChangeGroup): EligibilityReason {
422428
const materialFiles = readerFacingFiles.filter(
423429
(file) => !/(^|\/)meta\.json$/.test(file.filename),
424430
);
425-
if (materialFiles.some((file) => file.status === 'added')) {
431+
if (materialFiles.some((file) => fileWasAdded(group, file.filename))) {
426432
return 'eligible';
427433
}
428434

@@ -442,7 +448,7 @@ function classifyGroup(group: ChangeGroup): EligibilityReason {
442448
/^(examples|recipes)\//.test(file.filename),
443449
);
444450
const addsRecipe =
445-
recipeFiles.some((file) => file.status === 'added') ||
451+
recipeFiles.some((file) => fileWasAdded(group, file.filename)) ||
446452
(/\b(add|added|new|introduc(e|ed|es|ing))\b/.test(text) &&
447453
group.changedFiles.some((file) => file.filename === 'registry.yaml'));
448454

@@ -456,7 +462,8 @@ function classifyGroup(group: ChangeGroup): EligibilityReason {
456462
) ||
457463
group.changedFiles.some(
458464
(file) =>
459-
file.status === 'added' && /(^|\/)benchmarks?\//.test(file.filename.toLowerCase()),
465+
fileWasAdded(group, file.filename) &&
466+
/(^|\/)benchmarks?\//.test(file.filename.toLowerCase()),
460467
);
461468

462469
if (addsBenchmark) {

‎scripts/generate-changelog-draft.ts‎

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export interface CliOptions {
133133
number?: number;
134134
since?: string;
135135
until?: string;
136+
applicationReleaseBaseSha?: string;
137+
applicationReleaseHeadSha?: string;
136138
}
137139

138140
interface ChangelogState {
@@ -194,15 +196,44 @@ export function parseArgs(argv: string[]): CliOptions {
194196
continue;
195197
}
196198

199+
if (value === '--application-release-base-sha' || value === '--application-release-head-sha') {
200+
const sha = requireOptionValue(argv, index, value);
201+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
202+
throw new Error(`Invalid SHA for ${value}: ${sha}`);
203+
}
204+
205+
if (value === '--application-release-base-sha') {
206+
options.applicationReleaseBaseSha = sha;
207+
} else {
208+
options.applicationReleaseHeadSha = sha;
209+
}
210+
index += 1;
211+
continue;
212+
}
213+
197214
throw new Error(`Unknown argument: ${value}`);
198215
}
199216

200217
if (options.preview) {
201-
if (options.number === undefined || !options.since || !options.until) {
202-
throw new Error('Preview mode requires --number, --since, and --until.');
218+
if (
219+
options.number === undefined ||
220+
!options.since ||
221+
!options.until ||
222+
!options.applicationReleaseBaseSha ||
223+
!options.applicationReleaseHeadSha
224+
) {
225+
throw new Error(
226+
'Preview mode requires --number, --since, --until, --application-release-base-sha, and --application-release-head-sha.',
227+
);
203228
}
204-
} else if (options.number !== undefined) {
205-
throw new Error('--number can only be used with --preview.');
229+
} else if (
230+
options.number !== undefined ||
231+
options.applicationReleaseBaseSha ||
232+
options.applicationReleaseHeadSha
233+
) {
234+
throw new Error(
235+
'--number, --application-release-base-sha, and --application-release-head-sha can only be used with --preview.',
236+
);
206237
}
207238

208239
return options;
@@ -554,10 +585,18 @@ async function fetchSubmoduleShaAtRef(
554585
}
555586

556587
class NonAheadCompareError extends Error {
557-
constructor(repoConfig: ChangelogRepository, baseSha: string, headSha: string, status: string) {
588+
readonly status: GitHubCompareResponse['status'];
589+
590+
constructor(
591+
repoConfig: ChangelogRepository,
592+
baseSha: string,
593+
headSha: string,
594+
status: GitHubCompareResponse['status'],
595+
) {
558596
super(
559597
`Expected an ahead range for ${repoConfig.owner}/${repoConfig.repo}, received ${status} for ${baseSha}...${headSha}.`,
560598
);
599+
this.status = status;
561600
}
562601
}
563602

@@ -570,7 +609,7 @@ async function fetchComparedCommits(
570609
const commits: GitHubListCommit[] = [];
571610
let totalCommits: number | null = null;
572611

573-
for (let page = 1; page <= 3; page += 1) {
612+
for (let page = 1; ; page += 1) {
574613
const url = new URL(
575614
`https://api.github.com/repos/${repoConfig.owner}/${repoConfig.repo}/compare/${baseSha}...${headSha}`,
576615
);
@@ -665,9 +704,9 @@ export async function expandReleasedBrowserCommits(
665704
token,
666705
);
667706
} catch (error) {
668-
// A rolled-back or rewritten submodule pointer must not block the whole draft; the
669-
// application release comparison still fails loudly on the same condition.
670-
if (error instanceof NonAheadCompareError) {
707+
// A pure rollback contains no newly released browser commits. Diverged ranges may contain
708+
// head-only commits, so they must fail instead of silently dropping release evidence.
709+
if (error instanceof NonAheadCompareError && error.status === 'behind') {
671710
console.warn(
672711
`Skipping submodule expansion for ${hostCommit.owner}/${hostCommit.repo}@${hostCommit.shortSha}: ${error.message}`,
673712
);
@@ -1330,8 +1369,8 @@ async function writePreviewDraftArtifacts(input: {
13301369
]);
13311370
}
13321371

1333-
async function main() {
1334-
const options = parseArgs(process.argv.slice(2));
1372+
export async function main(argv = process.argv.slice(2)) {
1373+
const options = parseArgs(argv);
13351374
const githubToken = getGithubToken();
13361375
let nextNumber: number;
13371376
let windowSelection: WindowSelection;
@@ -1344,18 +1383,8 @@ async function main() {
13441383
since: options.since as string,
13451384
until: options.until as string,
13461385
});
1347-
[applicationReleaseBaseSha, applicationReleaseSha] = await Promise.all([
1348-
fetchBranchHeadAtOrBefore(
1349-
CHANGELOG_APPLICATION_REPOSITORY,
1350-
githubToken,
1351-
windowSelection.since,
1352-
),
1353-
fetchBranchHeadAtOrBefore(
1354-
CHANGELOG_APPLICATION_REPOSITORY,
1355-
githubToken,
1356-
windowSelection.until,
1357-
),
1358-
]);
1386+
applicationReleaseBaseSha = options.applicationReleaseBaseSha as string;
1387+
applicationReleaseSha = options.applicationReleaseHeadSha as string;
13591388
} else {
13601389
const state = await readChangelogState();
13611390
windowSelection = await selectWindow(options, state);
@@ -1444,16 +1473,6 @@ async function main() {
14441473
);
14451474
}
14461475

1447-
if (eligible.length === 0) {
1448-
console.log(
1449-
'The source window contains no public changelog facts. Treating it as a quiet week.',
1450-
);
1451-
if (!options.preview) {
1452-
await appendGithubOutput('has_changes', 'false');
1453-
}
1454-
return;
1455-
}
1456-
14571476
const previewWorkspace = options.preview
14581477
? await createPreviewWorkspace({
14591478
number: nextNumber,
@@ -1468,6 +1487,16 @@ async function main() {
14681487
console.log(`Prepared isolated preview evidence at ${previewWorkspace.directory}`);
14691488
}
14701489

1490+
if (eligible.length === 0) {
1491+
console.log(
1492+
'The source window contains no public changelog facts. Treating it as a quiet week.',
1493+
);
1494+
if (!options.preview) {
1495+
await appendGithubOutput('has_changes', 'false');
1496+
}
1497+
return;
1498+
}
1499+
14711500
const openAiToken = getOpenAiToken();
14721501
const draft = await requestDraftFromModel(eligible, nextNumber, windowSelection, openAiToken);
14731502
if (!draft.sections.some((section) => section.entries.length > 0)) {

0 commit comments

Comments
 (0)