Skip to content

Commit a7a03de

Browse files
committed
fix: classify Release Please push state
1 parent fbd5a22 commit a7a03de

6 files changed

Lines changed: 294 additions & 6 deletions

File tree

.github/workflows/release-please.yml

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@ jobs:
134134
)"
135135
136136
version="$(node --print 'require("./package.json").version')"
137+
manifest_version="$(node --print 'require("./.release-please-manifest.json")["."]')"
138+
before_sha="${{ github.event.before }}"
139+
head_sha="${{ github.event.after }}"
140+
if [[ ! "$before_sha" =~ ^[0-9a-f]{40}$ || "$before_sha" == "0000000000000000000000000000000000000000" ]]; then
141+
echo "Release Please push runs require an exact previous main commit." >&2
142+
exit 1
143+
fi
144+
if [[ "$head_sha" != "$GITHUB_SHA" ]]; then
145+
echo "Release Please push payload does not match the triggering SHA." >&2
146+
exit 1
147+
fi
148+
git fetch --no-tags origin "$before_sha"
149+
package_changed="false"
150+
if git diff --name-only "$before_sha" "$GITHUB_SHA" | grep -Fxq package.json; then
151+
package_changed="true"
152+
fi
153+
previous_version="$(git show "$before_sha:package.json" | node -e \
154+
'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).version))')"
137155
tag="v${version}"
138156
attempts_file="$RUNNER_TEMP/release-please-prior-attempts.json"
139157
printf '[]\n' > "$attempts_file"
@@ -177,6 +195,37 @@ jobs:
177195
tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")"
178196
fi
179197
198+
operation="$(CURRENT_RELEASE_FILE="$release_file" \
199+
CURRENT_TAG_COMMIT="$tag_commit" PREVIOUS_VERSION="$previous_version" \
200+
MANIFEST_VERSION="$manifest_version" PACKAGE_CHANGED="$package_changed" \
201+
VERSION="$version" \
202+
node --input-type=module <<'EOF'
203+
import { readFileSync } from "node:fs";
204+
import { classifyPushReleasePresence } from "./scripts/release-workflow-validation.mjs";
205+
206+
const result = classifyPushReleasePresence({
207+
currentRelease: JSON.parse(
208+
readFileSync(process.env.CURRENT_RELEASE_FILE, "utf8"),
209+
),
210+
currentTagCommit:
211+
process.env.CURRENT_TAG_COMMIT === ""
212+
? null
213+
: process.env.CURRENT_TAG_COMMIT,
214+
headCommit: process.env.GITHUB_SHA,
215+
manifestVersion: process.env.MANIFEST_VERSION,
216+
packageChanged: process.env.PACKAGE_CHANGED === "true",
217+
previousVersion: process.env.PREVIOUS_VERSION,
218+
version: process.env.VERSION,
219+
});
220+
process.stdout.write(result.mode);
221+
EOF
222+
)"
223+
echo "operation=${operation}" >> "$GITHUB_OUTPUT"
224+
if [[ "$operation" == "ignore" ]]; then
225+
echo "The push did not change the published package version; no release mutation is needed."
226+
exit 0
227+
fi
228+
180229
ATTEMPTS_FILE="$attempts_file" RELEASE_FILE="$release_file" \
181230
RUN_CREATED_AT="$run_created_at" RUN_ID="$RUN_ID" \
182231
TAG_COMMIT="$tag_commit" VERSION="$version" \
@@ -209,6 +258,7 @@ jobs:
209258
echo "run-created-at=${run_created_at}" >> "$GITHUB_OUTPUT"
210259
- name: Reject an unrelated stale Release Please branch
211260
id: branch-state
261+
if: steps.release-state.outputs.operation != 'ignore'
212262
env:
213263
GH_TOKEN: ${{ github.token }}
214264
RELEASE_BRANCH: release-please--branches--main--components--cometapi
@@ -312,6 +362,7 @@ jobs:
312362
echo "branch-sha=${branch_sha}" >> "$GITHUB_OUTPUT"
313363
- name: Classify the release operation
314364
id: preflight
365+
if: steps.release-state.outputs.operation != 'ignore'
315366
env:
316367
EVENT_NAME: ${{ github.event_name }}
317368
GH_TOKEN: ${{ github.token }}
@@ -375,6 +426,7 @@ jobs:
375426
--require-final \
376427
--require-releasable-docs
377428
- name: Reconfirm the branch, candidate, and review before mutation
429+
if: steps.release-state.outputs.operation != 'ignore'
378430
env:
379431
EVENT_NAME: ${{ github.event_name }}
380432
EXPECTED_BRANCH_EXISTS: ${{ steps.branch-state.outputs.branch-exists }}
@@ -551,7 +603,7 @@ jobs:
551603
EOF
552604
fi
553605
- name: Reconfirm the exact release state before mutation
554-
if: github.event_name == 'push'
606+
if: github.event_name == 'push' && steps.release-state.outputs.operation != 'ignore'
555607
env:
556608
EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists }}
557609
GH_TOKEN: ${{ github.token }}
@@ -622,6 +674,7 @@ jobs:
622674
fi
623675
- name: Run Release Please
624676
id: release
677+
if: steps.release-state.outputs.operation != 'ignore'
625678
continue-on-error: ${{ steps.preflight.outputs.mode == 'release' }}
626679
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
627680
with:

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ coverage
44
dist
55
node_modules
66
package-lock.json
7+
CHANGELOG.md

RELEASING.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,11 @@ The repository maintains four independently auditable workflows:
239239
authorized repository baseline keeps default workflow permissions read-only
240240
and allows Actions to create pull requests; it does not make bot review valid
241241
release approval. Automatic push execution is limited to `main` commits that
242-
change canonical `package.json`, so ordinary repair and documentation merges
243-
cannot be mistaken for an already published version. A manual dispatch is
242+
change canonical `package.json`; an in-job before/after check then classifies
243+
unchanged published versions without mutation and requires release pushes to
244+
increment exactly one patch. Ordinary repair and documentation merges that do
245+
not change `package.json` therefore cannot be mistaken for an already
246+
published version. A manual dispatch is
244247
attempt-1-only, runs with GitHub Release creation disabled, and prepares
245248
exactly one action-authored patch PR after the variable is enabled. A new
246249
dispatch may revalidate an unchanged canonical PR
@@ -457,6 +460,12 @@ executed README examples against the packed artifact, release-PR/tag/changelog/
457460
manifest version agreement, reviewed security and compatibility status, and
458461
post-publication registry evidence.
459462

463+
`CHANGELOG.md` is owned by Release Please and excluded from Prettier so the
464+
action-generated release notes remain byte-for-byte identical to the release
465+
PR body. Release validators still require the exact dated stable-patch section,
466+
the four-file candidate shape, and exact PR-body note equality; secret,
467+
standalone-content, and public-preview checks continue to scan the file.
468+
460469
The 0.1.1 repair used one explicit `last-release-sha` boundary at the immutable
461470
0.1.0 release commit, `1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca`, only for
462471
the initial preparation dispatch. That dispatch proved the normal action-owned

scripts/release-workflow-validation.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,87 @@ export function validateReleasePresenceBeforeAction({
913913
return { exists: true, ...evidence };
914914
}
915915

916+
export function classifyPushReleasePresence({
917+
currentRelease,
918+
currentTagCommit,
919+
headCommit,
920+
manifestVersion,
921+
packageChanged,
922+
previousVersion,
923+
version,
924+
}) {
925+
requireCommit(headCommit, "push head commit");
926+
requireBoolean(packageChanged, "push package.json change state");
927+
const previousPatch = stablePatch(previousVersion, "push previous version");
928+
const currentPatch = stablePatch(version, "push current version");
929+
requireEqual(
930+
manifestVersion,
931+
version,
932+
"push manifest and package version agreement",
933+
);
934+
const releaseExists = currentRelease !== null && currentRelease !== undefined;
935+
const tagExists = currentTagCommit !== null && currentTagCommit !== undefined;
936+
if (releaseExists !== tagExists) {
937+
fail(
938+
"Release workflow current tag and GitHub Release existence must agree.",
939+
);
940+
}
941+
if (currentPatch === previousPatch) {
942+
if (packageChanged) {
943+
fail(
944+
"Release workflow package.json push must change the stable package version.",
945+
);
946+
}
947+
if (!releaseExists) {
948+
fail(
949+
"Release workflow ignored push must retain the exact published current version.",
950+
);
951+
}
952+
requireCommit(currentTagCommit, "published current tag commit");
953+
requireEqual(
954+
currentTagCommit,
955+
currentRelease?.target_commitish,
956+
"published current release target",
957+
);
958+
requireEqual(
959+
currentRelease?.tag_name,
960+
`v${version}`,
961+
"published current release tag",
962+
);
963+
requireEqual(
964+
currentRelease?.draft,
965+
false,
966+
"published current release draft state",
967+
);
968+
requireEqual(
969+
currentRelease?.prerelease,
970+
false,
971+
"published current release prerelease state",
972+
);
973+
requireEqual(
974+
currentRelease?.immutable,
975+
true,
976+
"published current release immutable state",
977+
);
978+
return { mode: "ignore", version };
979+
}
980+
if (currentPatch !== previousPatch + 1) {
981+
fail("Release workflow push must increment exactly one stable patch.");
982+
}
983+
if (!packageChanged) {
984+
fail("Release workflow version-changing push must include package.json.");
985+
}
986+
if (releaseExists) {
987+
requireCommit(currentTagCommit, "candidate tag commit");
988+
if (currentTagCommit !== headCommit) {
989+
fail(
990+
"Release workflow candidate tag must target the triggering commit before recovery.",
991+
);
992+
}
993+
}
994+
return { mode: "release", version };
995+
}
996+
916997
export function validateReleasePleaseCompletion({
917998
actionResult,
918999
attempts,

tests/release-workflow-validation.test.mjs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { fileURLToPath, URL } from "node:url";
2+
3+
import { getFileInfo } from "prettier";
14
import { describe, expect, it } from "vitest";
25

36
import {
7+
classifyPushReleasePresence,
48
extractReleaseNotesFromChangelog,
59
validatePreparedReleasePullRequest,
610
validateGitHubRelease,
@@ -130,6 +134,139 @@ function releaseFixture({
130134
};
131135
}
132136

137+
describe("Release Please generated files", () => {
138+
it("leaves the action-owned CHANGELOG byte-for-byte unchanged by Prettier", async () => {
139+
const changelog = fileURLToPath(
140+
new URL("../CHANGELOG.md", import.meta.url),
141+
);
142+
143+
await expect(
144+
getFileInfo(changelog, { ignorePath: ".prettierignore" }),
145+
).resolves.toMatchObject({ ignored: true });
146+
expect(
147+
extractReleaseNotesFromChangelog(
148+
`# Changelog\n\n${releaseNotes()}\n\n## [0.1.0] - 2026-07-28\n\nPrevious.`,
149+
"0.1.1",
150+
),
151+
).toBe(releaseNotes());
152+
expect(() =>
153+
validateReleasePleasePullRequestBody(
154+
releaseBody().replace("supported options", "different options"),
155+
"0.1.1",
156+
releaseNotes(),
157+
),
158+
).toThrow(/pull request notes/i);
159+
});
160+
});
161+
162+
describe("Release Please push classification", () => {
163+
function publishedCurrentRelease(overrides = {}) {
164+
return {
165+
draft: false,
166+
immutable: true,
167+
prerelease: false,
168+
tag_name: "v0.1.0",
169+
target_commitish: RELEASE_SHA,
170+
...overrides,
171+
};
172+
}
173+
174+
it("ignores a metadata-only push whose current version remains published", () => {
175+
expect(
176+
classifyPushReleasePresence({
177+
currentRelease: publishedCurrentRelease(),
178+
currentTagCommit: RELEASE_SHA,
179+
headCommit: BRANCH_SHA,
180+
manifestVersion: "0.1.0",
181+
packageChanged: false,
182+
previousVersion: "0.1.0",
183+
version: "0.1.0",
184+
}),
185+
).toEqual({ mode: "ignore", version: "0.1.0" });
186+
});
187+
188+
it("classifies an exact next-patch push as a release", () => {
189+
expect(
190+
classifyPushReleasePresence({
191+
currentRelease: null,
192+
currentTagCommit: null,
193+
headCommit: RELEASE_SHA,
194+
manifestVersion: "0.1.1",
195+
packageChanged: true,
196+
previousVersion: "0.1.0",
197+
version: "0.1.1",
198+
}),
199+
).toEqual({ mode: "release", version: "0.1.1" });
200+
});
201+
202+
it.each([
203+
[
204+
"unchanged unpublished version",
205+
{ currentRelease: null, currentTagCommit: null },
206+
],
207+
["minor bump", { previousVersion: "0.1.0", version: "0.2.0" }],
208+
["skipped patch", { previousVersion: "0.1.0", version: "0.1.2" }],
209+
["metadata-only package change", { packageChanged: true }],
210+
["manifest drift", { manifestVersion: "0.1.1" }],
211+
[
212+
"published-version tag without a Release",
213+
{ currentRelease: null, currentTagCommit: RELEASE_SHA },
214+
],
215+
["published-version Release without a tag", { currentTagCommit: null }],
216+
[
217+
"mutable published Release",
218+
{ currentRelease: publishedCurrentRelease({ immutable: false }) },
219+
],
220+
[
221+
"mismatched published target",
222+
{
223+
currentRelease: publishedCurrentRelease({
224+
target_commitish: BRANCH_SHA,
225+
}),
226+
},
227+
],
228+
[
229+
"candidate tag on another commit",
230+
{
231+
currentRelease: publishedCurrentRelease({
232+
tag_name: "v0.1.1",
233+
target_commitish: BRANCH_SHA,
234+
}),
235+
currentTagCommit: BRANCH_SHA,
236+
previousVersion: "0.1.0",
237+
version: "0.1.1",
238+
},
239+
],
240+
])("rejects a %s push", (_name, overrides) => {
241+
expect(() =>
242+
classifyPushReleasePresence({
243+
currentRelease: publishedCurrentRelease(),
244+
currentTagCommit: RELEASE_SHA,
245+
headCommit: RELEASE_SHA,
246+
manifestVersion: "0.1.0",
247+
packageChanged: false,
248+
previousVersion: "0.1.0",
249+
version: "0.1.0",
250+
...overrides,
251+
}),
252+
).toThrow(/release workflow/i);
253+
});
254+
255+
it("rejects an unchanged-version rerun even if the historical tag exists", () => {
256+
expect(() =>
257+
classifyPushReleasePresence({
258+
currentRelease: publishedCurrentRelease(),
259+
currentTagCommit: RELEASE_SHA,
260+
headCommit: BRANCH_SHA,
261+
manifestVersion: "0.1.0",
262+
packageChanged: true,
263+
previousVersion: "0.1.0",
264+
version: "0.1.0",
265+
}),
266+
).toThrow(/package\.json push must change/i);
267+
});
268+
});
269+
133270
function attemptEvidenceFixture(
134271
attempt,
135272
{

0 commit comments

Comments
 (0)