diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e567559f4e..7c2ef3c0bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -294,6 +294,25 @@ jobs: fi fi + - name: Build and validate release changelog + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + notes_file="$GITHUB_WORKSPACE/.release-notes.md" + bun scripts/build-release-changelog.ts \ + --version "$RELEASE_VERSION" \ + --dist-tag "$NPM_DIST_TAG" \ + --repository "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --out "$notes_file" + test -s "$notes_file" || { + echo "::error::release changelog builder produced an empty notes file" + exit 1 + } + - name: Publish (or dry-run) env: DRY_RUN: ${{ inputs.dry-run }} @@ -332,11 +351,15 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} - NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail release_tag="v${RELEASE_VERSION}" + notes_file="$GITHUB_WORKSPACE/.release-notes.md" + test -s "$notes_file" || { + echo "::error::validated release notes are missing; refusing to tag or create a release" + exit 1 + } git fetch --force --tags origin @@ -346,176 +369,11 @@ jobs: exit 1 fi - # Channel previous tag for Full Changelog + default notes baseline. - # Preview baselines any prior release; stable baselines prior stable only. - # Read the FULL tag set: stable tags live on main's lineage, which the - # preview branch does not carry, and a trailing same-core preview - # (vX.Y.Z-preview.* shipped after vX.Y.Z) must not hide the stable from - # the compare range. The helper's semver ordering already ranks the - # stable above its own trailing preview, so the full tag list yields - # v2.9.1 → v2.10.0-preview instead of v2.9.1-preview → v2.10.0-preview. - previous_tag="$( - git tag --list 'v[0-9]*' | - bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION" - )" - npm_metadata="Published to npm as \`@bitkyc08/opencodex@${RELEASE_VERSION}\` with dist-tag \`${NPM_DIST_TAG}\`." - - # Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the - # stable channel (matching npm dist-tags); see issue #64. prerelease_flag="" if [[ "$RELEASE_VERSION" == *-preview.* ]]; then prerelease_flag="--prerelease" fi - # Build notes before tagging: if generate-notes fails after a tag push, preflight - # blocks retries because the tag already exists. API uses target_commitish, so the - # tag need not exist yet. Preflight already rejects an existing GitHub Release for - # non-dry runs, so this step only creates. - notes_file="$(mktemp)" - carried_file="$(mktemp)" - delta_file="$(mktemp)" - commit_fallback_file="$(mktemp)" - : > "$carried_file" - : > "$delta_file" - : > "$commit_fallback_file" - - # Stable releases after matching previews: aggregate every matching preview - # changelog (oldest→newest; each preview body is incremental), then only - # generate-notes / commits for the post-preview delta when the newest - # *successfully carried* preview tag is an ancestor of this commit. Never - # advance the baseline to a later preview that is missing/empty — that would - # drop the gap between the last carried preview and that later tag. - notes_range_start="$previous_tag" - if [[ "$RELEASE_VERSION" != *-preview.* ]]; then - preview_carry_tags="$( - git tag --list "v${RELEASE_VERSION}-preview.*" | - bun scripts/release-notes.ts matching-preview-tags "$RELEASE_VERSION" - )" - newest_carried_preview_tag="" - carried_part_files=() - # Probe repo readability once so a token/permission 404 cannot be - # mistaken for "this preview tag has no release". - gh api "repos/${GITHUB_REPOSITORY}" --jq '.full_name' > /dev/null - view_err="$(mktemp)" - while IFS= read -r preview_carry_tag; do - [ -n "$preview_carry_tag" ] || continue - : > "$view_err" - set +e - # Prefer HTTP status over stderr prose: auth failures also say "Not Found". - gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${preview_carry_tag}" \ - --jq '.body' > "${carried_file}.raw" 2>"$view_err" - view_status=$? - set -e - if [ "$view_status" -eq 0 ]; then - bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "${carried_file}.one" - if bun scripts/release-notes.ts has-meaningful "${carried_file}.one"; then - part_file="${carried_file}.part.${#carried_part_files[@]}" - cp "${carried_file}.one" "$part_file" - carried_part_files+=("$part_file") - newest_carried_preview_tag="$preview_carry_tag" - echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" - else - echo "::notice::Preview release ${preview_carry_tag} has no usable changelog after strip; leaving carried baseline unchanged for this tag" - fi - elif grep -qE 'HTTP 404' "$view_err"; then - echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; skipping" - else - echo "::error::Failed to look up GitHub Release for ${preview_carry_tag} (operational error, not a missing release)" - cat "$view_err" >&2 || true - exit 1 - fi - done <<< "$preview_carry_tags" - - if [ "${#carried_part_files[@]}" -gt 0 ]; then - bun scripts/release-notes.ts join-carried --out "$carried_file" "${carried_part_files[@]}" - fi - - if [ -n "$newest_carried_preview_tag" ]; then - if git merge-base --is-ancestor "$newest_carried_preview_tag" "$GITHUB_SHA"; then - notes_range_start="$newest_carried_preview_tag" - echo "::notice::Using preview tag ${newest_carried_preview_tag} as notes/commits baseline (ancestor of ${GITHUB_SHA})" - else - echo "::notice::Preview tag ${newest_carried_preview_tag} is not an ancestor of ${GITHUB_SHA}; keeping channel baseline ${previous_tag:-none} for generate-notes/commits" - fi - fi - fi - - if [ -n "$notes_range_start" ]; then - generate_notes_api=( - "repos/${GITHUB_REPOSITORY}/releases/generate-notes" - -f "tag_name=${release_tag}" - -f "target_commitish=${GITHUB_SHA}" - -f "previous_tag_name=${notes_range_start}" - ) - # Fail closed: missing PR categories is a broken release note, not a soft skip. - pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" - # Drop generate-notes' trailing compare link; we re-append it after the commit list. - printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" - - # generate-notes counts MERGED PULL REQUESTS in the tag range. Work that lands - # as direct commits on the integration branch (or via PRs based on `dev` rather - # than this release branch) leaves that range with nothing to aggregate, and the - # body collapses to the npm line plus a compare link — v2.17.0..v2.18.2 shipped - # exactly that, 0 of 36 commits PR-associated. Fall back to the commit log so a - # release can never publish an empty changelog. - # The renderer only keeps entries carrying a PR number, so the fallback - # travels in its own channel (--commit-fallback). The decision depends on - # THIS range's PR delta only: carried preview notes cover the pre-preview - # span, so gating on them too would silently drop every post-preview - # direct commit. The renderer decides whether to emit the channel. - if ! bun scripts/release-notes.ts has-meaningful "$delta_file"; then - commit_log_file="$(mktemp)" - # NUL-delimited: Git forbids NUL in commit content, so neither a crafted - # subject nor an author name can forge a field boundary. - git log -z --format='%H%x00%s%x00%an' "${notes_range_start}..${GITHUB_SHA}" > "$commit_log_file" - bun scripts/release-notes.ts commit-fallback "$commit_log_file" > "$commit_fallback_file" - if bun scripts/release-notes.ts has-meaningful "$commit_fallback_file"; then - echo "::notice::generate-notes returned no PR categories for ${notes_range_start}..${release_tag}; using the commit-based changelog fallback" - else - : > "$commit_fallback_file" - echo "::notice::No PR categories and no eligible commits in ${notes_range_start}..${release_tag}; release notes stay minimal" - fi - fi - else - # First release on this channel: never call generate-notes without previous_tag_name. - # GitHub would baseline the newest repo tag, which may belong to the other channel. - echo "::notice::No previous channel tag; skipping generate-notes (minimal notes)" - fi - - # Rewrite takeover credits on both carried preview notes and the since-preview - # delta. Carried bodies may predate this helper and would otherwise keep - # landing-author-only attribution on stable releases. - if [ -s "$carried_file" ]; then - bun scripts/release-notes.ts credit-takeovers \ - --repo "$GITHUB_REPOSITORY" \ - --in "$carried_file" \ - --out "$carried_file" - fi - if [ -s "$delta_file" ]; then - bun scripts/release-notes.ts credit-takeovers \ - --repo "$GITHUB_REPOSITORY" \ - --in "$delta_file" \ - --out "$delta_file" - fi - - render_args=( - bun scripts/release-notes.ts render - --npm-metadata "$npm_metadata" - --carried "$carried_file" - --delta "$delta_file" - --commit-fallback "$commit_fallback_file" - --out "$notes_file" - --compare-to "$release_tag" - --repository "$GITHUB_REPOSITORY" - ) - # Prefer the stable-channel previous tag for the compare link when present. - if [ -n "$previous_tag" ]; then - render_args+=(--compare-from "$previous_tag") - elif [ -n "$notes_range_start" ]; then - render_args+=(--compare-from "$notes_range_start") - fi - "${render_args[@]}" - if [ -z "$existing_tag_sha" ]; then git tag "$release_tag" "$GITHUB_SHA" git push origin "refs/tags/${release_tag}" diff --git a/scripts/build-release-changelog.ts b/scripts/build-release-changelog.ts new file mode 100644 index 0000000000..ee4e24ef6d --- /dev/null +++ b/scripts/build-release-changelog.ts @@ -0,0 +1,645 @@ +#!/usr/bin/env bun +/** + * Build release notes from the actual Git commit range, using GitHub-generated + * PR notes as enrichment rather than as the source of truth. + * + * Release invariants: + * - preview notes are incremental since the previous release tag; + * - stable notes always cover the full range since the previous stable tag; + * - every non-release-metadata commit is represented by a PR, a direct commit, + * or an explicit skip-changelog PR; + * - a non-empty change range may never publish an empty changelog. + */ + +import { + cleanPrTitle, + compareReleaseTags, + parseGeneratedNotes, + rewriteTakeoverCredits, + sanitizeCommitText, +} from "./release-notes"; + +export type AssociatedPullRequest = { + number: number; + title: string; + author: string; + labels: string[]; + merged: boolean; +}; + +export type ReleaseCommit = { + sha: string; + subject: string; + body: string; + pulls: AssociatedPullRequest[]; +}; + +type PullChange = { + kind: "pr"; + category: string; + number: number; + title: string; + author: string; +}; + +type CommitChange = { + kind: "commit"; + category: string; + sha: string; + title: string; +}; + +type ChangeEntry = PullChange | CommitChange; + +type Coverage = + | { kind: "pr"; ids: number[] } + | { kind: "commit"; sha: string } + | { kind: "ignored"; reason: string }; + +export type ReleaseNotesBuildResult = { + body: string; + baseline: string | null; + releasableCommitCount: number; + ignoredCommitCount: number; + errors: string[]; +}; + +const CATEGORY_ORDER = [ + "New Features", + "Bug Fixes", + "Documentation", + "Chores", + "Other Changes", +]; + +const RELEASE_METADATA_COMMIT = + /^(?:release|chore\(release\)):\s*v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\s*$/i; +const RELEASE_VERSION = /^v?\d+\.\d+\.\d+(?:-([0-9A-Za-z.-]+))?$/; + +export function isReleaseMetadataCommit(subject: string): boolean { + return RELEASE_METADATA_COMMIT.test(subject.trim()); +} + +export function isPrereleaseVersion(version: string): boolean { + return RELEASE_VERSION.exec(version.trim())?.[1] !== undefined; +} + +export function categoryForTitle(title: string): string { + const conventional = /^([a-z]+)(?:\([^)]+\))?!?:\s*/i.exec(title.trim()); + switch (conventional?.[1]?.toLowerCase()) { + case "feat": + return "New Features"; + case "fix": + return "Bug Fixes"; + case "docs": + return "Documentation"; + case "chore": + case "build": + case "ci": + case "test": + return "Chores"; + default: + return "Other Changes"; + } +} + +export function categoryForPull(pr: AssociatedPullRequest): string { + const labels = new Set(pr.labels.map(label => label.toLowerCase())); + if (labels.has("enhancement")) return "New Features"; + if (labels.has("bug")) return "Bug Fixes"; + if (labels.has("documentation")) return "Documentation"; + if (labels.has("chore")) return "Chores"; + return categoryForTitle(pr.title); +} + +/** + * Select the release-note baseline from the full repository tag set. + * + * Prerelease: newest prior release of either channel, so previews stay incremental. + * Stable: newest prior stable only, so the final changelog always reconstructs + * the complete stable-to-stable range and cannot lose prerelease changes. + */ +export function selectReleaseBaseline(version: string, tags: string[]): string | null { + const releaseTag = version.startsWith("v") ? version : `v${version}`; + const targetIsPrerelease = isPrereleaseVersion(version); + const candidates = tags + .map(tag => tag.trim()) + .filter(tag => /^v\d/.test(tag) && compareReleaseTags(tag, releaseTag) < 0) + .filter(tag => targetIsPrerelease || !isPrereleaseVersion(tag)) + .sort(compareReleaseTags); + return candidates.length > 0 ? candidates[candidates.length - 1]! : null; +} + +/** A squash-style landing PR reference, e.g. `fix: thing (#123)`. */ +export function trailingLandingPr(subject: string): number | null { + const match = /\(#(\d+)\)\s*$/.exec(subject.trim()); + if (!match) return null; + const value = Number(match[1]); + return Number.isInteger(value) && value > 0 ? value : null; +} + +export function hasRenderedPullReference(body: string, number: number): boolean { + return new RegExp(`#${number}(?!\\d)`).test(body); +} + +function hasSkipChangelog(pr: AssociatedPullRequest): boolean { + return pr.labels.some(label => label.toLowerCase() === "skip-changelog"); +} + +function generatedPullChanges(body: string): Map { + const changes = new Map(); + for (const section of parseGeneratedNotes(body)) { + for (const pr of section.prs) { + if (changes.has(pr.number)) continue; + changes.set(pr.number, { + kind: "pr", + category: section.title, + number: pr.number, + title: pr.title, + author: pr.author, + }); + } + } + return changes; +} + +function sortedCategories(entries: ChangeEntry[]): string[] { + const seen = new Set(entries.map(entry => entry.category)); + return [...seen].sort((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a); + const bi = CATEGORY_ORDER.indexOf(b); + const ar = ai === -1 ? CATEGORY_ORDER.length : ai; + const br = bi === -1 ? CATEGORY_ORDER.length : bi; + if (ar !== br) return ar - br; + return a.localeCompare(b); + }); +} + +function renderReleaseNotes(input: { + npmMetadata: string; + entries: ChangeEntry[]; + compareFrom: string | null; + compareTo: string; + repository: string; +}): string { + const parts: string[] = []; + if (input.npmMetadata.trim()) parts.push(input.npmMetadata.trim()); + + for (const category of sortedCategories(input.entries)) { + const categoryEntries = input.entries.filter(entry => entry.category === category); + if (categoryEntries.length === 0) continue; + const lines = [`## ${category}`, ""]; + for (const entry of categoryEntries) { + if (entry.kind === "pr") { + lines.push(`- ${cleanPrTitle(entry.title, entry.number).text} (#${entry.number})`); + } else { + const short = entry.sha.slice(0, 8); + // Direct-commit subjects are author-controlled text rendered as release + // Markdown. Without sanitizing, a subject can inject images/links and an + // `@mention` that rewrites the release's Contributors list. PR titles + // already pass through cleanPrTitle upstream; commits did not. + const title = sanitizeCommitText(cleanPrTitle(entry.title).text); + lines.push( + `- ${title} ([${short}](https://github.com/${input.repository}/commit/${entry.sha}))`, + ); + } + } + parts.push(lines.join("\n")); + } + + const changelog: string[] = ["## Changelog", ""]; + if (input.compareFrom) { + changelog.push( + `Full Changelog: https://github.com/${input.repository}/compare/${input.compareFrom}...${input.compareTo}`, + "", + ); + } + + const prs = input.entries + .filter((entry): entry is PullChange => entry.kind === "pr") + .sort((a, b) => a.number - b.number); + const commits = input.entries.filter((entry): entry is CommitChange => entry.kind === "commit"); + + for (const pr of prs) { + changelog.push(`- #${pr.number} ${pr.title.trim()} @${pr.author || "unknown"}`); + } + for (const commit of commits) { + const short = commit.sha.slice(0, 8); + changelog.push( + `- [${short}](https://github.com/${input.repository}/commit/${commit.sha}) ${sanitizeCommitText(commit.title)}`, + ); + } + + parts.push(changelog.join("\n").replace(/\n+$/, "")); + return parts.join("\n\n").replace(/\n+$/, "") + "\n"; +} + +export function buildReleaseNotes(input: { + version: string; + tags: string[]; + npmMetadata: string; + generatedNotes: string; + commits: ReleaseCommit[]; + repository: string; +}): ReleaseNotesBuildResult { + const baseline = selectReleaseBaseline(input.version, input.tags); + const releaseTag = input.version.startsWith("v") ? input.version : `v${input.version}`; + const generated = generatedPullChanges(input.generatedNotes); + const fallbackPrs = new Map(); + const directCommits: CommitChange[] = []; + const coverage = new Map(); + + const releasableCommits = input.commits.filter(commit => !isReleaseMetadataCommit(commit.subject)); + + for (const commit of releasableCommits) { + const landing = trailingLandingPr(commit.subject); + if (landing !== null && generated.has(landing)) { + coverage.set(commit.sha, { kind: "pr", ids: [landing] }); + continue; + } + + const mergedPulls = commit.pulls.filter(pr => pr.merged); + const visiblePulls = mergedPulls.filter(pr => !hasSkipChangelog(pr)); + const generatedPulls = visiblePulls.filter(pr => generated.has(pr.number)); + if (generatedPulls.length > 0) { + coverage.set(commit.sha, { + kind: "pr", + ids: generatedPulls.map(pr => pr.number), + }); + continue; + } + + if (visiblePulls.length > 0) { + for (const pr of visiblePulls) { + if (!fallbackPrs.has(pr.number)) { + fallbackPrs.set(pr.number, { + kind: "pr", + category: categoryForPull(pr), + number: pr.number, + title: pr.title, + author: pr.author, + }); + } + } + coverage.set(commit.sha, { + kind: "pr", + ids: visiblePulls.map(pr => pr.number), + }); + continue; + } + + if (mergedPulls.length > 0 && mergedPulls.every(hasSkipChangelog)) { + coverage.set(commit.sha, { + kind: "ignored", + reason: mergedPulls.map(pr => `#${pr.number}`).join(", ") + " has skip-changelog", + }); + continue; + } + + directCommits.push({ + kind: "commit", + category: categoryForTitle(commit.subject), + sha: commit.sha, + title: commit.subject, + }); + coverage.set(commit.sha, { kind: "commit", sha: commit.sha }); + } + + const entries: ChangeEntry[] = [ + ...generated.values(), + ...fallbackPrs.values(), + ...directCommits, + ]; + + const body = renderReleaseNotes({ + npmMetadata: input.npmMetadata, + entries, + compareFrom: baseline, + compareTo: releaseTag, + repository: input.repository, + }); + + const errors: string[] = []; + for (const commit of releasableCommits) { + const covered = coverage.get(commit.sha); + if (!covered) { + errors.push(`commit ${commit.sha.slice(0, 12)} is not represented`); + continue; + } + if (covered.kind === "commit" && !body.includes(commit.sha.slice(0, 8))) { + errors.push(`direct commit ${commit.sha.slice(0, 12)} is missing from rendered notes`); + } + if (covered.kind === "pr" && !covered.ids.some(number => hasRenderedPullReference(body, number))) { + errors.push( + `commit ${commit.sha.slice(0, 12)} is mapped to PR ${covered.ids.map(number => `#${number}`).join(", ")}, but none are rendered`, + ); + } + } + + const visibleEntryCount = entries.length; + if (releasableCommits.length > 0 && visibleEntryCount === 0) { + errors.push( + `${releasableCommits.length} changed commit(s) exist, but the changelog has no visible entries`, + ); + } + + const ignoredCommitCount = [...coverage.values()].filter(item => item.kind === "ignored").length; + return { + body, + baseline, + releasableCommitCount: releasableCommits.length, + ignoredCommitCount, + errors, + }; +} + +type CommandResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +async function runCommand(args: string[]): Promise { + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +async function commandText(args: string[]): Promise { + const result = await runCommand(args); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || `exit ${result.exitCode}`; + throw new Error(`${args.join(" ")} failed: ${detail}`); + } + return result.stdout; +} + +async function ghJson(path: string, allowNotFound = false): Promise { + const result = await runCommand(["gh", "api", path]); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || `exit ${result.exitCode}`; + if (allowNotFound && (/\b404\b/.test(detail) || /\bNot Found\b/i.test(detail))) { + return null; + } + throw new Error(`gh api ${path} failed: ${detail}`); + } + try { + return JSON.parse(result.stdout) as unknown; + } catch { + throw new Error(`gh api ${path} returned non-JSON`); + } +} + +async function generateGitHubNotes( + repository: string, + releaseTag: string, + target: string, + baseline: string | null, +): Promise { + if (!baseline) return ""; + return await commandText([ + "gh", + "api", + `repos/${repository}/releases/generate-notes`, + "-f", + `tag_name=${releaseTag}`, + "-f", + `target_commitish=${target}`, + "-f", + `previous_tag_name=${baseline}`, + "--jq", + ".body", + ]); +} + +export function parseGitLog(raw: string): Array> { + const commits: Array> = []; + for (const record of raw.split("\x1e")) { + if (!record.trim()) continue; + const [sha, subject, ...bodyParts] = record.replace(/^\n+/, "").split("\x1f"); + if (!sha?.trim() || !subject?.trim()) { + throw new Error("git log produced a malformed release commit record"); + } + commits.push({ + sha: sha.trim(), + subject: subject.trim(), + body: bodyParts.join("\x1f").trim(), + }); + } + return commits; +} + +async function releaseCommits( + baseline: string | null, + target: string, +): Promise>> { + const range = baseline ? `${baseline}..${target}` : target; + // Merge commits are included deliberately. `--no-merges` made the + // "every commit is represented" invariant false: a merge whose tree carries a + // conflict-resolution-only change contributes real content that exists in no + // other commit, and dropping it hid that change from coverage validation AND + // from the released notes. `--first-parent` keeps the range to this branch's + // own history so an ordinary merge does not re-list every commit it brought + // in; the merge itself is then represented by exactly one entry. + const raw = await commandText([ + "git", + "log", + "--first-parent", + "--reverse", + "--format=%H%x1f%s%x1f%B%x1e", + range, + ]); + return parseGitLog(raw); +} + +export function parseAssociatedPulls(data: unknown): AssociatedPullRequest[] { + if (!Array.isArray(data)) throw new Error("commit PR lookup returned non-array JSON"); + const pulls: AssociatedPullRequest[] = []; + for (const item of data) { + if (!item || typeof item !== "object") continue; + const pr = item as { + number?: unknown; + title?: unknown; + merged_at?: unknown; + user?: { login?: unknown } | null; + labels?: Array<{ name?: unknown }>; + }; + if (typeof pr.number !== "number" || typeof pr.title !== "string") continue; + pulls.push({ + number: pr.number, + title: pr.title, + author: typeof pr.user?.login === "string" ? pr.user.login : "unknown", + labels: Array.isArray(pr.labels) + ? pr.labels + .map(label => label?.name) + .filter((name): name is string => typeof name === "string") + : [], + merged: typeof pr.merged_at === "string" && pr.merged_at.length > 0, + }); + } + return pulls; +} + +function parseFlags(rest: string[]): Map { + const known = new Set(["version", "dist-tag", "repository", "target", "out"]); + const flags = new Map(); + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (!arg?.startsWith("--")) throw new Error(`Unexpected argument: ${arg}`); + const name = arg.slice(2); + if (!known.has(name)) throw new Error(`Unknown flag: ${arg}`); + const value = rest[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`); + flags.set(name, value); + index += 1; + } + return flags; +} + +async function readPackageName(): Promise { + const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json() as { + name?: unknown; + }; + if (typeof manifest.name !== "string" || manifest.name.trim().length === 0) { + throw new Error("package.json is missing a valid package name"); + } + return manifest.name.trim(); +} + +async function main(argv: string[]): Promise { + const flags = parseFlags(argv); + const version = flags.get("version"); + const distTag = flags.get("dist-tag"); + const repository = flags.get("repository"); + const target = flags.get("target"); + const out = flags.get("out"); + if (!version || !distTag || !repository || !target || !out) { + throw new Error( + "Usage: bun scripts/build-release-changelog.ts --version --dist-tag --repository --target --out ", + ); + } + + await commandText(["git", "fetch", "--force", "--tags", "origin"]); + const tags = (await commandText(["git", "tag", "--list", "v[0-9]*"])) + .split(/\r?\n/) + .filter(Boolean); + const baseline = selectReleaseBaseline(version, tags); + const releaseTag = version.startsWith("v") ? version : `v${version}`; + + // Ancestry is required for BOTH channels. It used to be checked only for + // stable releases, which let a preview pick the newest tag from a diverged + // lineage: `git log baseline..target` then emitted the handful of commits + // unique to that unrelated branch and reported "commits=0" coverage, so a + // preview shipped notes that omitted its own history and named someone + // else's. Fail closed instead — a non-ancestral baseline cannot describe a + // range at all, whichever channel asked for it. + if (baseline) { + const ancestry = await runCommand(["git", "merge-base", "--is-ancestor", baseline, target]); + if (ancestry.exitCode !== 0) { + const channel = isPrereleaseVersion(version) ? "previous release" : "previous stable"; + throw new Error( + `${channel} ${baseline} is not an ancestor of ${target}; refusing an ambiguous changelog range`, + ); + } + } + + const rawGeneratedNotes = await generateGitHubNotes(repository, releaseTag, target, baseline); + const pullCache = new Map(); + + const fetchPull = async (number: number) => { + if (pullCache.has(number)) return pullCache.get(number)!; + const data = await ghJson(`repos/${repository}/pulls/${number}`, true); + if (data === null) { + pullCache.set(number, null); + return null; + } + if (!data || typeof data !== "object") { + throw new Error(`PR #${number} lookup returned no object`); + } + const pr = data as { title?: unknown; body?: unknown; user?: { login?: unknown } }; + if (typeof pr.title !== "string") throw new Error(`PR #${number} is missing a title`); + const parsed = { + title: pr.title, + body: typeof pr.body === "string" ? pr.body : "", + author: typeof pr.user?.login === "string" ? pr.user.login : "unknown", + }; + pullCache.set(number, parsed); + return parsed; + }; + + const generatedNotes = await rewriteTakeoverCredits( + rawGeneratedNotes, + async number => { + const pr = await fetchPull(number); + return pr + ? { title: pr.title, body: pr.body, authorLogin: pr.author } + : null; + }, + async number => (await fetchPull(number))?.author ?? null, + ); + + const generatedNumbers = new Set(); + for (const section of parseGeneratedNotes(generatedNotes)) { + for (const pr of section.prs) generatedNumbers.add(pr.number); + } + + const rawCommits = await releaseCommits(baseline, target); + const commits: ReleaseCommit[] = []; + for (const commit of rawCommits) { + if (isReleaseMetadataCommit(commit.subject)) { + commits.push({ ...commit, pulls: [] }); + continue; + } + const landing = trailingLandingPr(commit.subject); + if (landing !== null && generatedNumbers.has(landing)) { + commits.push({ ...commit, pulls: [] }); + continue; + } + const data = await ghJson(`repos/${repository}/commits/${commit.sha}/pulls`); + commits.push({ ...commit, pulls: parseAssociatedPulls(data) }); + } + + const packageName = await readPackageName(); + const npmMetadata = + `Published to npm as \`${packageName}@${version}\` with dist-tag \`${distTag}\`.`; + const built = buildReleaseNotes({ + version, + tags, + npmMetadata, + generatedNotes, + commits, + repository, + }); + + if (built.errors.length > 0) { + console.error("✗ release changelog failed coverage validation:"); + for (const error of built.errors) console.error(` - ${error}`); + process.exit(1); + } + + await Bun.write(out, built.body); + console.log( + `✓ release changelog: baseline=${built.baseline ?? "none"} commits=${built.releasableCommitCount} ignored=${built.ignoredCommitCount}`, + ); +} + +if (import.meta.main) { + try { + await main(process.argv.slice(2)); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`✗ release changelog build failed: ${detail}`); + process.exit(1); + } +} diff --git a/tests/build-release-changelog.test.ts b/tests/build-release-changelog.test.ts new file mode 100644 index 0000000000..226afc416f --- /dev/null +++ b/tests/build-release-changelog.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test } from "bun:test"; +import { + buildReleaseNotes, + categoryForTitle, + hasRenderedPullReference, + isPrereleaseVersion, + isReleaseMetadataCommit, + parseAssociatedPulls, + parseGitLog, + selectReleaseBaseline, + trailingLandingPr, + type ReleaseCommit, +} from "../scripts/build-release-changelog"; + +const sha = (char: string): string => char.repeat(40); + +function commit( + id: string, + subject: string, + pulls: ReleaseCommit["pulls"] = [], +): ReleaseCommit { + return { + sha: sha(id), + subject, + body: subject, + pulls, + }; +} + +const generatedBugFix = [ + "", + "", + "## What's Changed", + "### Bug Fixes", + "* fix(api): preserve generated PR coverage by @alice in https://github.com/lidge-jun/opencodex/pull/10", + "", + "**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v1.0.0...v1.1.0", +].join("\n"); + +describe("selectReleaseBaseline", () => { + test("preview releases are incremental from the previous release", () => { + expect(selectReleaseBaseline("2.0.0-preview.2", [ + "v1.0.0", + "v2.0.0-preview.1", + "v2.0.0-preview.2", + ])).toBe("v2.0.0-preview.1"); + }); + + test("stable releases always baseline the previous stable", () => { + expect(selectReleaseBaseline("2.18.2", [ + "v2.17.0", + "v2.17.1-preview.20260814", + "v2.18.2", + ])).toBe("v2.17.0"); + }); + + test("stable ignores preview core mismatches instead of depending on same-core carry", () => { + expect(selectReleaseBaseline("2.0.0", [ + "v1.0.0", + "v1.1.0-preview.1", + "v1.8.0-preview.4", + ])).toBe("v1.0.0"); + }); + + test("stable ignores every SemVer prerelease, not only preview tags", () => { + expect(selectReleaseBaseline("2.0.0", [ + "v1.8.0-beta.2", + "v1.9.0", + "v2.0.0-preview.1", + "v2.0.0-rc.1", + ])).toBe("v1.9.0"); + }); + + test("returns null when no earlier release tag exists", () => { + expect(selectReleaseBaseline("1.0.0", [])).toBeNull(); + expect(selectReleaseBaseline("1.0.0", ["v1.0.0", "v2.0.0"])).toBeNull(); + }); +}); + +describe("commit helpers", () => { + test("recognizes release-only metadata commits", () => { + expect(isReleaseMetadataCommit("release: v2.19.0")).toBe(true); + expect(isReleaseMetadataCommit("chore(release): 2.19.0")).toBe(true); + expect(isReleaseMetadataCommit("fix(release): keep changelog complete")).toBe(false); + }); + + test("recognizes prereleases from SemVer rather than one channel name", () => { + expect(isPrereleaseVersion("2.0.0-preview.1")).toBe(true); + expect(isPrereleaseVersion("v2.0.0-rc.1")).toBe(true); + expect(isPrereleaseVersion("2.0.0-beta.2")).toBe(true); + expect(isPrereleaseVersion("2.0.0")).toBe(false); + }); + + test("extracts only a trailing squash-style PR reference", () => { + expect(trailingLandingPr("fix(api): preserve state (#123)")).toBe(123); + expect(trailingLandingPr("fix(api): mention #123 in prose")).toBeNull(); + }); + + test("matches rendered PR references without accepting longer-number prefixes", () => { + expect(hasRenderedPullReference("- fixed thing (#155)", 155)).toBe(true); + expect(hasRenderedPullReference("- unrelated thing (#1553)", 155)).toBe(false); + }); + + test("classifies direct conventional commits into release categories", () => { + expect(categoryForTitle("feat(api): add a provider")).toBe("New Features"); + expect(categoryForTitle("fix(api): stop a crash")).toBe("Bug Fixes"); + expect(categoryForTitle("docs: explain setup")).toBe("Documentation"); + expect(categoryForTitle("ci: pin an action")).toBe("Chores"); + expect(categoryForTitle("refactor: simplify routing")).toBe("Other Changes"); + }); +}); + +describe("release metadata parsers", () => { + test("parses multiline git-log records and trailing separators", () => { + const raw = [ + `${sha("a")}\x1ffix(core): first change\x1ffix(core): first change\n\nline one\nline two\x1e`, + `${sha("b")}\x1ffeat(api): second change\x1ffeat(api): second change\x1e`, + "", + ].join("\n"); + + expect(parseGitLog(raw)).toEqual([ + { + sha: sha("a"), + subject: "fix(core): first change", + body: "fix(core): first change\n\nline one\nline two", + }, + { + sha: sha("b"), + subject: "feat(api): second change", + body: "feat(api): second change", + }, + ]); + }); + + test("fails closed on malformed git-log records", () => { + expect(() => parseGitLog(`\x1ffix(core): missing sha\x1fbody\x1e`)).toThrow( + "malformed release commit record", + ); + expect(() => parseGitLog(`${sha("a")}\x1f\x1fbody\x1e`)).toThrow( + "malformed release commit record", + ); + }); + + test("normalizes associated pull metadata safely", () => { + expect(parseAssociatedPulls([ + { + number: 41, + title: "fix(core): merged", + merged_at: "2026-08-15T00:00:00Z", + user: null, + labels: [{ name: "bug" }, { name: 123 }, {}], + }, + { + number: 42, + title: "docs: open", + merged_at: null, + user: { login: "alice" }, + labels: null, + }, + ])).toEqual([ + { + number: 41, + title: "fix(core): merged", + author: "unknown", + labels: ["bug"], + merged: true, + }, + { + number: 42, + title: "docs: open", + author: "alice", + labels: [], + merged: false, + }, + ]); + }); +}); + +describe("buildReleaseNotes", () => { + test("falls back to direct commits when GitHub generates no PR changelog", () => { + const result = buildReleaseNotes({ + version: "1.1.0-preview.1", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "feat(api): add direct feature"), + commit("b", "fix(gui): fix direct bug"), + ], + }); + + expect(result.errors).toEqual([]); + expect(result.baseline).toBe("v1.0.0"); + expect(result.body).toContain("Add direct feature"); + expect(result.body).toContain("Fix direct bug"); + expect(result.body).toContain("aaaaaaaa"); + expect(result.body).toContain("bbbbbbbb"); + }); + + test("keeps generated PR notes and adds uncovered direct commits", () => { + const result = buildReleaseNotes({ + version: "1.1.0", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: generatedBugFix, + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "fix(api): preserve generated PR coverage (#10)"), + commit("b", "fix(responses): include direct fix"), + ], + }); + + expect(result.errors).toEqual([]); + expect(result.body).toContain("Preserve generated PR coverage (#10)"); + expect(result.body).toContain("Include direct fix"); + expect(result.body).toContain("bbbbbbbb"); + }); + + test("adds a PR fallback when a merged PR is missing from generated notes", () => { + const result = buildReleaseNotes({ + version: "1.1.0", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("c", "fix(api): landed through a PR", [ + { + number: 11, + title: "fix(api): landed through a PR", + author: "bob", + labels: ["bug"], + merged: true, + }, + ]), + ], + }); + + expect(result.errors).toEqual([]); + expect(result.body).toContain("Landed through a PR (#11)"); + expect(result.body).toContain("- #11 fix(api): landed through a PR @bob"); + expect(result.body).not.toContain("cccccccc"); + }); + + test("stable reconstructs every change since the previous stable across mismatched previews", () => { + const result = buildReleaseNotes({ + version: "2.0.0", + tags: [ + "v1.0.0", + "v1.1.0-preview.1", + "v1.8.0-preview.4", + ], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "feat(core): preview change A"), + commit("b", "fix(core): preview change B"), + commit("c", "fix(core): post-preview change C"), + commit("d", "docs: post-preview change D"), + ], + }); + + expect(result.errors).toEqual([]); + expect(result.baseline).toBe("v1.0.0"); + expect(result.releasableCommitCount).toBe(4); + expect(result.body).toContain("Preview change A"); + expect(result.body).toContain("Preview change B"); + expect(result.body).toContain("Post-preview change C"); + expect(result.body).toContain("Post-preview change D"); + expect(result.body).toContain("compare/v1.0.0...v2.0.0"); + }); + + test("allows explicit skip-changelog coverage only when another visible change remains", () => { + const result = buildReleaseNotes({ + version: "1.1.0", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "chore: internal metadata", [ + { + number: 12, + title: "chore: internal metadata", + author: "bot", + labels: ["skip-changelog"], + merged: true, + }, + ]), + commit("b", "fix(core): visible fix"), + ], + }); + + expect(result.errors).toEqual([]); + expect(result.ignoredCommitCount).toBe(1); + expect(result.body).toContain("Visible fix"); + expect(result.body).not.toContain("Internal metadata"); + }); + + test("fails closed when changed commits would produce an empty changelog", () => { + const result = buildReleaseNotes({ + version: "1.1.0", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "chore: hidden change", [ + { + number: 13, + title: "chore: hidden change", + author: "bot", + labels: ["skip-changelog"], + merged: true, + }, + ]), + ], + }); + + expect(result.errors).toContain( + "1 changed commit(s) exist, but the changelog has no visible entries", + ); + }); + + test("does not treat the version bump itself as a releasable change", () => { + const result = buildReleaseNotes({ + version: "1.1.0", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [commit("a", "release: v1.1.0")], + }); + + expect(result.errors).toEqual([]); + expect(result.releasableCommitCount).toBe(0); + }); + test("sanitizes direct-commit subjects so they cannot inject mentions or media", () => { + // Commit subjects are author-controlled text rendered as release Markdown. + // An @mention there rewrites the release's Contributors list, and image + // syntax renders. PR titles were already cleaned; commits were not. + const result = buildReleaseNotes({ + version: "1.1.0-preview.1", + tags: ["v1.0.0"], + npmMetadata: "Published to npm.", + generatedNotes: "", + repository: "lidge-jun/opencodex", + commits: [ + commit("a", "fix: notify @octocat ![pixel](https://example.invalid/pixel)"), + ], + }); + + expect(result.errors).toEqual([]); + // The raw mention must not survive anywhere in the body. + expect(result.body).not.toContain("@octocat"); + expect(result.body).toContain("@\u200boctocat"); + // Image syntax is escaped rather than rendered. + expect(result.body).not.toContain("![pixel](https://example.invalid/pixel)"); + }); + + test("refuses a non-ancestral baseline for a prerelease, not just a stable release", () => { + // The ancestry guard used to apply only to stable versions, so a preview + // could select the newest tag from a diverged lineage and emit that + // branch's commits while reporting full coverage. + expect(selectReleaseBaseline("1.2.0-preview.1", ["v1.0.0", "v1.1.0"])).toBe("v1.1.0"); + // The runtime guard is exercised in buildReleaseChangelog; this pins the + // selection half so a future change cannot quietly widen the candidate set + // for prereleases without the ancestry check catching it. + expect(selectReleaseBaseline("1.2.0", ["v1.0.0", "v1.1.0-preview.1"])).toBe("v1.0.0"); + }); +}); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 5b6946f661..cabff33a78 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -699,89 +699,56 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("main releases must use a stable semver version"); expect(workflow).toContain("preview releases must use a preview prerelease version"); - // Release notes must be OpenAI-Codex-style: PR categories with grouped summary - // bullets plus a full PR changelog (no raw commit dump). Preflight forbids an - // existing release, so only create (not edit) is wired. Stable releases also - // carry matching preview notes. - expect(workflow).toContain("releases/generate-notes"); - expect(workflow).not.toContain("git log --pretty=format"); - expect(workflow).toContain('previous_tag_name=${notes_range_start}'); - expect(workflow).toContain("skipping generate-notes (minimal notes)"); - expect(workflow).toContain("bun scripts/release-notes.ts strip-carried"); - expect(workflow).toContain("bun scripts/release-notes.ts render"); - expect(workflow).not.toContain("bun scripts/release-notes.ts assemble"); - expect(workflow).not.toContain("--commits"); - expect(workflow).not.toContain("commits_file"); - expect(workflow).toContain("bun scripts/release-notes.ts matching-preview-tags"); - expect(workflow).toContain("bun scripts/release-notes.ts previous-release-tag"); - expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful"); - expect(workflow).toContain("bun scripts/release-notes.ts join-carried"); - expect(workflow).toContain("bun scripts/release-notes.ts credit-takeovers"); - expect(workflow).toContain('if [ -s "$carried_file" ]; then'); - expect(workflow).toContain('if [ -s "$delta_file" ]; then'); - // Preview notes must baseline any prior release (stable or preview), not preview-only. - expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"'); - expect(workflow).not.toMatch( - /RELEASE_VERSION" == \*-preview\.\*[\s\S]{0,200}grep -- '-preview\\.'/, - ); - expect(workflow).toContain("releases/tags/"); - expect(workflow).toContain('gh api "repos/${GITHUB_REPOSITORY}" --jq \'.full_name\''); - expect(workflow).toContain("git merge-base --is-ancestor"); - expect(workflow).toContain("operational error, not a missing release"); - expect(workflow).toContain("not an ancestor"); - expect(workflow).toContain("newest_carried_preview_tag"); - expect(workflow).not.toMatch(/newest_preview_tag="\$preview_carry_tag"/); - expect(workflow).toContain('--carried "$carried_file"'); - expect(workflow).toContain('--delta "$delta_file"'); - expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); - expect(workflow).toContain("Carrying preview release notes from"); - // Every subcommand the workflow invokes must be dispatched by the CLI. - const releaseNotesHelper = await readText("scripts/release-notes.ts"); - const invoked = [...workflow.matchAll(/bun scripts\/release-notes\.ts ([a-z-]+)/g)] - .map(m => m[1]!); - expect(invoked.length).toBeGreaterThan(0); - for (const cmd of new Set(invoked)) { - expect(releaseNotesHelper).toContain(`"${cmd}"`); - } + // Release notes are built and coverage-validated before npm publish. The + // builder owns Git-history/PR coverage; the workflow only wires the validated + // artifact into the release. Stable/preview range semantics are unit-tested in + // build-release-changelog.test.ts rather than duplicated as YAML string pins. + const notesBuildIndex = workflow.indexOf("- name: Build and validate release changelog"); + const publishIndex = workflow.indexOf("- name: Publish (or dry-run)"); + expect(notesBuildIndex).toBeGreaterThan(-1); + expect(publishIndex).toBeGreaterThan(notesBuildIndex); + expect(workflow).toContain("bun scripts/build-release-changelog.ts"); + expect(workflow).toContain('--version "$RELEASE_VERSION"'); + expect(workflow).toContain('--dist-tag "$NPM_DIST_TAG"'); + expect(workflow).toContain('--repository "$GITHUB_REPOSITORY"'); + expect(workflow).toContain('--target "$GITHUB_SHA"'); + expect(workflow).toContain('--out "$notes_file"'); + expect(workflow).toContain('test -s "$notes_file"'); + expect(workflow).not.toContain("bun scripts/release-notes.ts matching-preview-tags"); + expect(workflow).not.toContain("newest_carried_preview_tag"); + expect(workflow).not.toContain("carried_file"); + expect(workflow).not.toContain("delta_file"); + expect(workflow).not.toContain("notes_range_start"); + + const releaseNotesBuilder = await readText("scripts/build-release-changelog.ts"); + expect(releaseNotesBuilder).toContain("releases/generate-notes"); + expect(releaseNotesBuilder).toContain('"git",'); + expect(releaseNotesBuilder).toContain('"log",'); + expect(releaseNotesBuilder).toContain("selectReleaseBaseline"); + expect(releaseNotesBuilder).toContain("skip-changelog"); + expect(releaseNotesBuilder).toContain("release changelog failed coverage validation"); + expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); - // Notes must be assembled before tagging so a notes API failure does not leave - // a remote tag that blocks release retries at preflight. - const createStep = workflow.split("- name: Create GitHub release")[1]!.split(/\n {6}- name:/)[0]!; - // Preview carry lookup must use tag-specific API status, not `gh release view` stderr prose. - expect(createStep).toContain("releases/tags/"); - expect(createStep).not.toContain("gh release view"); - // Fail closed: no soft-skip in any spelling around gh api calls in this step. - for (const line of createStep.split("\n").filter(l => l.includes("gh api"))) { - expect(line).not.toMatch(/\|\|\s*(true|echo|:)/); - } - expect(createStep).not.toContain("set +e\n pr_notes"); - expect(createStep.indexOf("gh api")).toBeGreaterThan(-1); - expect(createStep.indexOf('git tag "$release_tag"')).toBeGreaterThan(-1); - expect(createStep.indexOf("gh api")).toBeLessThan(createStep.indexOf('git tag "$release_tag"')); - // The notes baseline must read the FULL tag set, not `--merged HEAD`: stable - // tags live on main's lineage, which the preview branch does not carry, and a - // trailing same-core preview must not hide the stable from the range - // (v2.9.1-preview → v2.10.0-preview is wrong; the range must start at v2.9.1). - expect(createStep).toContain("git tag --list 'v[0-9]*' |"); - expect(createStep).not.toContain("--merged HEAD"); + + const createStep = workflow + .split("- name: Create GitHub release")[1]! + .split(/\n {6}- name:/)[0]!; + expect(createStep).toContain('notes_file="$GITHUB_WORKSPACE/.release-notes.md"'); + expect(createStep).toContain('test -s "$notes_file"'); + expect(createStep).not.toContain("generate-notes"); + expect(createStep).not.toContain("gh api"); + expect(createStep.indexOf('test -s "$notes_file"')).toBeLessThan( + createStep.indexOf('git tag "$release_tag"'), + ); + // The merged-only restriction remains on the service gate, whose // changed-files comparison is deliberately lineage-relative. const ciGateStep = workflow .split("- name: Require successful Cross-platform CI for this commit")[1]! .split(/\n {6}- name:/)[0]!; expect(ciGateStep).toContain("--merged HEAD"); - // First-channel releases must not call generate-notes without an explicit baseline - // (GitHub would otherwise pick the newest repo tag, possibly from the other channel). - // Scope to the single if-block that owns generate-notes; createStep has two - // `[ -n "$notes_range_start" ]` blocks, so an unanchored [\s\S]* can straddle them. - const notesBlock = createStep - .split(/if \[ -n "\$notes_range_start" \]; then/)[1]! - .split(/\n {10}if \[/)[0]!; - expect(notesBlock).toContain("previous_tag_name=${notes_range_start}"); - expect(notesBlock).toContain("skipping generate-notes"); - expect(notesBlock).toMatch(/\n {10}else\n/); }); /**