Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths:
- package.json
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
dist
node_modules
package-lock.json
CHANGELOG.md
18 changes: 15 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions scripts/release-workflow-validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
137 changes: 137 additions & 0 deletions tests/release-workflow-validation.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
{
Expand Down
Loading