diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 58d1e76..5159f31 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,6 +4,8 @@ on: push: branches: - main + paths: + - package.json workflow_dispatch: permissions: @@ -132,6 +134,24 @@ jobs: )" version="$(node --print 'require("./package.json").version')" + manifest_version="$(node --print 'require("./.release-please-manifest.json")["."]')" + before_sha="${{ github.event.before }}" + head_sha="${{ github.event.after }}" + if [[ ! "$before_sha" =~ ^[0-9a-f]{40}$ || "$before_sha" == "0000000000000000000000000000000000000000" ]]; then + echo "Release Please push runs require an exact previous main commit." >&2 + exit 1 + fi + if [[ "$head_sha" != "$GITHUB_SHA" ]]; then + echo "Release Please push payload does not match the triggering SHA." >&2 + exit 1 + fi + git fetch --no-tags origin "$before_sha" + package_changed="false" + if git diff --name-only "$before_sha" "$GITHUB_SHA" | grep -Fxq package.json; then + package_changed="true" + fi + previous_version="$(git show "$before_sha:package.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).version))')" tag="v${version}" attempts_file="$RUNNER_TEMP/release-please-prior-attempts.json" printf '[]\n' > "$attempts_file" @@ -175,6 +195,37 @@ jobs: tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" fi + operation="$(CURRENT_RELEASE_FILE="$release_file" \ + CURRENT_TAG_COMMIT="$tag_commit" PREVIOUS_VERSION="$previous_version" \ + MANIFEST_VERSION="$manifest_version" PACKAGE_CHANGED="$package_changed" \ + VERSION="$version" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { classifyPushReleasePresence } from "./scripts/release-workflow-validation.mjs"; + + const result = classifyPushReleasePresence({ + currentRelease: JSON.parse( + readFileSync(process.env.CURRENT_RELEASE_FILE, "utf8"), + ), + currentTagCommit: + process.env.CURRENT_TAG_COMMIT === "" + ? null + : process.env.CURRENT_TAG_COMMIT, + headCommit: process.env.GITHUB_SHA, + manifestVersion: process.env.MANIFEST_VERSION, + packageChanged: process.env.PACKAGE_CHANGED === "true", + previousVersion: process.env.PREVIOUS_VERSION, + version: process.env.VERSION, + }); + process.stdout.write(result.mode); + EOF + )" + echo "operation=${operation}" >> "$GITHUB_OUTPUT" + if [[ "$operation" == "ignore" ]]; then + echo "The push did not change the published package version; no release mutation is needed." + exit 0 + fi + ATTEMPTS_FILE="$attempts_file" RELEASE_FILE="$release_file" \ RUN_CREATED_AT="$run_created_at" RUN_ID="$RUN_ID" \ TAG_COMMIT="$tag_commit" VERSION="$version" \ @@ -207,6 +258,7 @@ jobs: echo "run-created-at=${run_created_at}" >> "$GITHUB_OUTPUT" - name: Reject an unrelated stale Release Please branch id: branch-state + if: steps.release-state.outputs.operation != 'ignore' env: GH_TOKEN: ${{ github.token }} RELEASE_BRANCH: release-please--branches--main--components--cometapi @@ -310,6 +362,7 @@ jobs: echo "branch-sha=${branch_sha}" >> "$GITHUB_OUTPUT" - name: Classify the release operation id: preflight + if: steps.release-state.outputs.operation != 'ignore' env: EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} @@ -373,6 +426,7 @@ jobs: --require-final \ --require-releasable-docs - name: Reconfirm the branch, candidate, and review before mutation + if: steps.release-state.outputs.operation != 'ignore' env: EVENT_NAME: ${{ github.event_name }} EXPECTED_BRANCH_EXISTS: ${{ steps.branch-state.outputs.branch-exists }} @@ -549,7 +603,7 @@ jobs: EOF fi - name: Reconfirm the exact release state before mutation - if: github.event_name == 'push' + if: github.event_name == 'push' && steps.release-state.outputs.operation != 'ignore' env: EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists }} GH_TOKEN: ${{ github.token }} @@ -620,6 +674,7 @@ jobs: fi - name: Run Release Please id: release + if: steps.release-state.outputs.operation != 'ignore' continue-on-error: ${{ steps.preflight.outputs.mode == 'release' }} uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: diff --git a/.prettierignore b/.prettierignore index b9fced0..f452931 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ coverage dist node_modules package-lock.json +CHANGELOG.md diff --git a/RELEASING.md b/RELEASING.md index fd4aab8..67b348c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -238,9 +238,15 @@ The repository maintains four independently auditable workflows: `RELEASE_PLEASE_ENABLED=true` and uses the default `GITHUB_TOKEN`. The authorized repository baseline keeps default workflow permissions read-only and allows Actions to create pull requests; it does not make bot review valid - release approval. A manual dispatch is attempt-1-only, runs with GitHub Release - creation disabled, and prepares exactly one action-authored patch PR after the - variable is enabled. A new dispatch may revalidate an unchanged canonical PR + release approval. Automatic push execution is limited to `main` commits that + change canonical `package.json`; an in-job before/after check then classifies + unchanged published versions without mutation and requires release pushes to + increment exactly one patch. Ordinary repair and documentation merges that do + not change `package.json` therefore cannot be mistaken for an already + published version. A manual dispatch is + attempt-1-only, runs with GitHub Release creation disabled, and prepares + exactly one action-authored patch PR after the variable is enabled. A new + dispatch may revalidate an unchanged canonical PR even when Release Please returns no PR output. The preparation run succeeds only after independently verifying the canonical branch, title, machine-readable body, pending label, four expected release files, and 0.1.x @@ -454,6 +460,12 @@ executed README examples against the packed artifact, release-PR/tag/changelog/ manifest version agreement, reviewed security and compatibility status, and post-publication registry evidence. +`CHANGELOG.md` is owned by Release Please and excluded from Prettier so the +action-generated release notes remain byte-for-byte identical to the release +PR body. Release validators still require the exact dated stable-patch section, +the four-file candidate shape, and exact PR-body note equality; secret, +standalone-content, and public-preview checks continue to scan the file. + The 0.1.1 repair used one explicit `last-release-sha` boundary at the immutable 0.1.0 release commit, `1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca`, only for the initial preparation dispatch. That dispatch proved the normal action-owned diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 2a3c913..b9da318 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -913,6 +913,87 @@ export function validateReleasePresenceBeforeAction({ return { exists: true, ...evidence }; } +export function classifyPushReleasePresence({ + currentRelease, + currentTagCommit, + headCommit, + manifestVersion, + packageChanged, + previousVersion, + version, +}) { + requireCommit(headCommit, "push head commit"); + requireBoolean(packageChanged, "push package.json change state"); + const previousPatch = stablePatch(previousVersion, "push previous version"); + const currentPatch = stablePatch(version, "push current version"); + requireEqual( + manifestVersion, + version, + "push manifest and package version agreement", + ); + const releaseExists = currentRelease !== null && currentRelease !== undefined; + const tagExists = currentTagCommit !== null && currentTagCommit !== undefined; + if (releaseExists !== tagExists) { + fail( + "Release workflow current tag and GitHub Release existence must agree.", + ); + } + if (currentPatch === previousPatch) { + if (packageChanged) { + fail( + "Release workflow package.json push must change the stable package version.", + ); + } + if (!releaseExists) { + fail( + "Release workflow ignored push must retain the exact published current version.", + ); + } + requireCommit(currentTagCommit, "published current tag commit"); + requireEqual( + currentTagCommit, + currentRelease?.target_commitish, + "published current release target", + ); + requireEqual( + currentRelease?.tag_name, + `v${version}`, + "published current release tag", + ); + requireEqual( + currentRelease?.draft, + false, + "published current release draft state", + ); + requireEqual( + currentRelease?.prerelease, + false, + "published current release prerelease state", + ); + requireEqual( + currentRelease?.immutable, + true, + "published current release immutable state", + ); + return { mode: "ignore", version }; + } + if (currentPatch !== previousPatch + 1) { + fail("Release workflow push must increment exactly one stable patch."); + } + if (!packageChanged) { + fail("Release workflow version-changing push must include package.json."); + } + if (releaseExists) { + requireCommit(currentTagCommit, "candidate tag commit"); + if (currentTagCommit !== headCommit) { + fail( + "Release workflow candidate tag must target the triggering commit before recovery.", + ); + } + } + return { mode: "release", version }; +} + export function validateReleasePleaseCompletion({ actionResult, attempts, diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 5149fb4..ea2a94a 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -1,6 +1,10 @@ +import { fileURLToPath, URL } from "node:url"; + +import { getFileInfo } from "prettier"; import { describe, expect, it } from "vitest"; import { + classifyPushReleasePresence, extractReleaseNotesFromChangelog, validatePreparedReleasePullRequest, validateGitHubRelease, @@ -130,6 +134,139 @@ function releaseFixture({ }; } +describe("Release Please generated files", () => { + it("leaves the action-owned CHANGELOG byte-for-byte unchanged by Prettier", async () => { + const changelog = fileURLToPath( + new URL("../CHANGELOG.md", import.meta.url), + ); + + await expect( + getFileInfo(changelog, { ignorePath: ".prettierignore" }), + ).resolves.toMatchObject({ ignored: true }); + expect( + extractReleaseNotesFromChangelog( + `# Changelog\n\n${releaseNotes()}\n\n## [0.1.0] - 2026-07-28\n\nPrevious.`, + "0.1.1", + ), + ).toBe(releaseNotes()); + expect(() => + validateReleasePleasePullRequestBody( + releaseBody().replace("supported options", "different options"), + "0.1.1", + releaseNotes(), + ), + ).toThrow(/pull request notes/i); + }); +}); + +describe("Release Please push classification", () => { + function publishedCurrentRelease(overrides = {}) { + return { + draft: false, + immutable: true, + prerelease: false, + tag_name: "v0.1.0", + target_commitish: RELEASE_SHA, + ...overrides, + }; + } + + it("ignores a metadata-only push whose current version remains published", () => { + expect( + classifyPushReleasePresence({ + currentRelease: publishedCurrentRelease(), + currentTagCommit: RELEASE_SHA, + headCommit: BRANCH_SHA, + manifestVersion: "0.1.0", + packageChanged: false, + previousVersion: "0.1.0", + version: "0.1.0", + }), + ).toEqual({ mode: "ignore", version: "0.1.0" }); + }); + + it("classifies an exact next-patch push as a release", () => { + expect( + classifyPushReleasePresence({ + currentRelease: null, + currentTagCommit: null, + headCommit: RELEASE_SHA, + manifestVersion: "0.1.1", + packageChanged: true, + previousVersion: "0.1.0", + version: "0.1.1", + }), + ).toEqual({ mode: "release", version: "0.1.1" }); + }); + + it.each([ + [ + "unchanged unpublished version", + { currentRelease: null, currentTagCommit: null }, + ], + ["minor bump", { previousVersion: "0.1.0", version: "0.2.0" }], + ["skipped patch", { previousVersion: "0.1.0", version: "0.1.2" }], + ["metadata-only package change", { packageChanged: true }], + ["manifest drift", { manifestVersion: "0.1.1" }], + [ + "published-version tag without a Release", + { currentRelease: null, currentTagCommit: RELEASE_SHA }, + ], + ["published-version Release without a tag", { currentTagCommit: null }], + [ + "mutable published Release", + { currentRelease: publishedCurrentRelease({ immutable: false }) }, + ], + [ + "mismatched published target", + { + currentRelease: publishedCurrentRelease({ + target_commitish: BRANCH_SHA, + }), + }, + ], + [ + "candidate tag on another commit", + { + currentRelease: publishedCurrentRelease({ + tag_name: "v0.1.1", + target_commitish: BRANCH_SHA, + }), + currentTagCommit: BRANCH_SHA, + previousVersion: "0.1.0", + version: "0.1.1", + }, + ], + ])("rejects a %s push", (_name, overrides) => { + expect(() => + classifyPushReleasePresence({ + currentRelease: publishedCurrentRelease(), + currentTagCommit: RELEASE_SHA, + headCommit: RELEASE_SHA, + manifestVersion: "0.1.0", + packageChanged: false, + previousVersion: "0.1.0", + version: "0.1.0", + ...overrides, + }), + ).toThrow(/release workflow/i); + }); + + it("rejects an unchanged-version rerun even if the historical tag exists", () => { + expect(() => + classifyPushReleasePresence({ + currentRelease: publishedCurrentRelease(), + currentTagCommit: RELEASE_SHA, + headCommit: BRANCH_SHA, + manifestVersion: "0.1.0", + packageChanged: true, + previousVersion: "0.1.0", + version: "0.1.0", + }), + ).toThrow(/package\.json push must change/i); + }); +}); + function attemptEvidenceFixture( attempt, { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 2b3e253..30dff59 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { URL } from "node:url"; import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; const workflowNames = [ "ci.yml", @@ -29,6 +30,7 @@ const releaseWorkflowValidation = readFileSync( new URL("../scripts/release-workflow-validation.mjs", import.meta.url), "utf8", ); +const releasePleaseWorkflow = parse(workflows["release-please.yml"]); function workflow(name) { const contents = workflows[name]; @@ -202,9 +204,17 @@ describe("GitHub Actions workflow contract", () => { "continue-on-error: ${{ steps.preflight.outputs.mode == 'release' }}", ); expect(contents).not.toContain("token:"); + expect(releasePleaseWorkflow.on.push).toEqual({ + branches: ["main"], + paths: ["package.json"], + }); expect(contents).toMatch(/^ {2}workflow_dispatch:$/m); expect(contents).toContain("group: release-please-main"); expect(releasePlease).toContain("RUN_ATTEMPT: ${{ github.run_attempt }}"); + expect(releasePlease).toContain( + 'git diff --name-only "$before_sha" "$GITHUB_SHA"', + ); + expect(releasePlease).toContain("classifyPushReleasePresence"); expect(releasePlease).toContain("TRIGGERING_REF: ${{ github.ref }}"); expect(releasePlease).toContain( 'if [[ "$TRIGGERING_REF" != "refs/heads/main" ]]; then',