feat(release): let a release lead record an exact-job flake instead of blocking publication (#161515)

* feat(release): accept exact-job recorded flakes in release validation

A release lead can classify one failed Normal CI job of a Full Release
Validation run as a flake through the trusted classification workflow. The
receipt binds the exact job id and attempt, CI child run, FRV parent run and
attempt, and Release SHA, and carries a tracking issue or PR plus a reason.
Release Decision, the manifest, the publisher's live re-derivation, the step
summary, and the GitHub release notes tail treat it as a visible advisory.
Required classes and every other child stay blocking.

* docs(release): fold recorded flakes into the shared release boundaries

* fix(release): scope flake receipt discovery to the CI child run

* fix(release): require main lineage for flake receipt producers

* test(release): copy the flake classification module into tooling fixtures

* fix(release): keep advisory-only release notes verifiable
This commit is contained in:
Peter Steinberger 2026-09-29 21:31:07 -07:00 • committed by GitHub
parent cc9c535d78
commit f34c2a21db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2485 additions and 147 deletions

View file

@ -2,6 +2,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { validateReleaseManifestAdvisoryJobs } from "./full-release-validation-policy.mjs";
import { changelogFormat, findReleaseChangelog } from "./lib/release-changelog.mjs";
import {
compactReleaseNotes,
@ -36,9 +37,11 @@ type ReleaseNotesTarget = {
repository: unknown;
regularStableVersion?: unknown;
contributionRecordPath?: string;
validationManifest?: unknown;
};
const RELEASE_VERIFICATION_HEADING = "### Release verification";
const ADVISORY_LINE_PREFIX = "- Advisory job (";
const SHIPPED_BASELINE_EXCLUSIONS_PREFIX = "Shipped baseline exclusions:";
const MONTH_NAMES = [
"January",
@ -80,6 +83,24 @@ function joinBody(notes: string, tail: string | undefined) {
return normalizedTail ? `${normalizedNotes}\n\n${normalizedTail}` : normalizedNotes;
}
function verificationWithAdvisories(verification: string, manifest: unknown) {
if (manifest === undefined) {
return normalizeTail(verification);
}
const escape = (value: string) => value.replace(/[\\`*_{}[\]()<>!#|]/gu, "\\$&");
const lines = validateReleaseManifestAdvisoryJobs(manifest).map((job) => {
const detail =
job.class === "recorded-flake" ? `; ${escape(job.reason)}; tracking: ${job.trackingUrl}` : "";
return `${ADVISORY_LINE_PREFIX}${job.class}): ${escape(job.child)} / ${escape(job.job)} (${job.conclusion}): ${job.url}${detail}`;
});
const proof = normalizeTail(verification)
.split("\n")
.filter((line) => !line.startsWith(ADVISORY_LINE_PREFIX))
.join("\n")
.trimEnd();
return lines.length > 0 ? [proof || RELEASE_VERIFICATION_HEADING, ...lines].join("\n") : proof;
}
function extendedStableReleaseNotice({
version,
repository,
@ -386,6 +407,7 @@ export function renderGithubReleaseNotes({
repository,
regularStableVersion,
contributionRecordPath,
validationManifest,
verification = "",
}: ReleaseNotesTarget & { verification?: string }) {
assertString(repository, "repository");
@ -421,10 +443,17 @@ export function renderGithubReleaseNotes({
`compacted release notes are still too large for GitHub: ${size.characters} characters, ${size.bytes} bytes`,
);
}
const normalizedVerification = normalizeTail(verification);
const normalizedVerification = verificationWithAdvisories(verification, validationManifest);
const bodyWithVerification = joinBody(baseBody, normalizedVerification);
const verificationIncluded =
normalizedVerification !== "" && fitsGithubReleaseBody(bodyWithVerification);
if (
!verificationIncluded &&
validationManifest !== undefined &&
validateReleaseManifestAdvisoryJobs(validationManifest).length > 0
) {
fail("release notes exceed GitHub's body limit with required advisory evidence");
}
const body = verificationIncluded ? bodyWithVerification : baseBody;
return {
body,
@ -441,19 +470,12 @@ export function verifyGithubReleaseNotes({
}: ReleaseNotesTarget & { body: unknown }) {
assertString(body, "release body");
const normalizedBody = body.trimEnd();
const base = renderGithubReleaseNotes(target);
if (normalizedBody === base.body) {
return {
...base,
matches: true,
actualSize: githubReleaseBodySize(normalizedBody),
};
}
const base = renderGithubReleaseNotes({ ...target, validationManifest: undefined });
const verificationPrefix = `${base.body}\n\n${RELEASE_VERIFICATION_HEADING}`;
const verification = normalizedBody.startsWith(verificationPrefix)
? normalizedBody.slice(base.body.length + 2)
: "";
const expected = verification ? renderGithubReleaseNotes({ ...target, verification }) : base;
const expected = renderGithubReleaseNotes({ ...target, verification });
return {
...expected,
matches: normalizedBody === expected.body,
@ -467,7 +489,7 @@ function usage() {
(--root <repository-path> [--ref <ref>] | --changelog <legacy-file>) \\
--tag <tag> --repository <owner/repo> \\
[--version <version>] [--regular-stable-version <version>] \\
[--verification-file <path>] [--output <path>] \\
[--verification-file <path>] [--validation-manifest <path>] [--output <path>] \\
[--metadata-output <path>]
Verification uses the same target arguments with --verify-body <path>.
`;
@ -483,6 +505,7 @@ function parseArgs(argv: string[]) {
["--repository", "repository"],
["--regular-stable-version", "regularStableVersion"],
["--verification-file", "verificationFile"],
["--validation-manifest", "validationManifest"],
["--output", "output"],
["--metadata-output", "metadataOutput"],
["--verify-body", "verifyBody"],
@ -558,6 +581,9 @@ function main() {
repository,
regularStableVersion: options.regularStableVersion,
contributionRecordPath: source?.recordPath ?? undefined,
validationManifest: options.validationManifest
? JSON.parse(readFileSync(options.validationManifest, "utf8"))
: undefined,
};
if (options.verifyBody) {
const result = verifyGithubReleaseNotes({