diff --git a/.github/actions/changeset-policy/check.mjs b/.github/actions/changeset-policy/check.mjs index 2018203..c25b9e5 100644 --- a/.github/actions/changeset-policy/check.mjs +++ b/.github/actions/changeset-policy/check.mjs @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import process from "node:process"; import { pathToFileURL } from "node:url"; @@ -80,6 +80,45 @@ export const generatedExclusions = (generatedPaths, changesetPathspec) => (pathspec) => `:(top,exclude)${pathspec}`, ); +const FINALIZER_WORKFLOW = "/.github/workflows/npm-version-finalize.yml@"; + +export const validateChangelogOwnership = (file, text) => { + const workflowLines = text.split(/\r?\n/); + for (let index = 0; index < workflowLines.length; index += 1) { + const use = workflowLines[index].match(/^(\s*)uses:\s*(\S+)\s*(?:#.*)?$/); + if (!use?.[2].includes(FINALIZER_WORKFLOW)) continue; + + const useIndent = use[1].length; + const jobLines = [workflowLines[index]]; + for (let cursor = index + 1; cursor < workflowLines.length; cursor += 1) { + const line = workflowLines[cursor]; + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + const indent = line.length - line.trimStart().length; + if (indent < useIndent) break; + } + jobLines.push(line); + } + + if (!jobLines.some((line) => /^\s+update-changelog:\s*false\s*(?:#.*)?$/.test(line))) { + fail( + `${file} delegates to npm-version-finalize.yml without ` + + "`update-changelog: false`. Changesets owns CHANGELOG.md; a post-release " + + "generator would create a second, conflicting writer.", + ); + } + } +}; + +export const validateWorkflowChangelogOwnership = (directory = ".github/workflows") => { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile() || !/\.ya?ml$/.test(entry.name)) continue; + const file = `${directory}/${entry.name}`; + validateChangelogOwnership(file, readFileSync(file, "utf8")); + } +}; + const run = (command, args) => execFileSync(command, args, { encoding: "utf8", @@ -118,6 +157,8 @@ const main = (environment) => { validateChangeset(file, readFileSync(file, "utf8"), packageNames); } + validateWorkflowChangelogOwnership(); + const runtimeChanged = diff("ACMRD", lines(required(environment, "RELEASE_PATHS"))).length > 0; if (!runtimeChanged) { diff --git a/.github/actions/changeset-policy/check.test.mjs b/.github/actions/changeset-policy/check.test.mjs index 7939cc7..eb70355 100644 --- a/.github/actions/changeset-policy/check.test.mjs +++ b/.github/actions/changeset-policy/check.test.mjs @@ -4,6 +4,7 @@ import { test } from "node:test"; import { generatedExclusions, isGeneratedVersionPullRequest, + validateChangelogOwnership, validateChangeset, } from "./check.mjs"; @@ -83,3 +84,54 @@ test("generated version exclusions stay rooted and include consumed changesets", ], ); }); + +test("accepts a release finalizer when Changesets remains the changelog owner", () => { + validateChangelogOwnership( + ".github/workflows/release.yml", + `jobs: + finalize: + uses: stella/.github/.github/workflows/npm-version-finalize.yml@immutable + with: + package-files: package.json + update-changelog: false + permissions: + contents: write +`, + ); +}); + +test("rejects a release finalizer with the conflicting changelog writer enabled", () => { + assert.throws( + () => + validateChangelogOwnership( + ".github/workflows/release.yml", + `jobs: + finalize: + uses: stella/.github/.github/workflows/npm-version-finalize.yml@immutable + with: + package-files: package.json +`, + ), + /second, conflicting writer/, + ); +}); + +test("does not mistake another job's input for the finalizer setting", () => { + assert.throws( + () => + validateChangelogOwnership( + ".github/workflows/release.yml", + `jobs: + finalize: + uses: stella/.github/.github/workflows/npm-version-finalize.yml@immutable + with: + package-files: package.json + unrelated: + uses: owner/repository/.github/workflows/example.yml@immutable + with: + update-changelog: false +`, + ), + /second, conflicting writer/, + ); +});