Skip to content

Commit 553d320

Browse files
committed
fix: bind publication to one current-main attempt
1 parent dc8357a commit 553d320

5 files changed

Lines changed: 31 additions & 21 deletions

File tree

.github/workflows/publish.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ jobs:
145145
exit 1
146146
fi
147147
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
148-
if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then
149-
echo "The release commit is no longer an ancestor of origin/main." >&2
148+
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then
149+
echo "The release commit is no longer the exact origin/main tip." >&2
150150
exit 1
151151
fi
152152
@@ -244,7 +244,7 @@ jobs:
244244
run: |
245245
set -euo pipefail
246246
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
247-
if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then
247+
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT" ]]; then
248248
echo "origin/main moved away from the release commit before handoff." >&2
249249
exit 1
250250
fi
@@ -389,8 +389,8 @@ jobs:
389389
git diff --name-only "$CONTROL_FIRST_PARENT" "$CONTROL_COMMIT" > "$CHANGED_FILES"
390390
;;
391391
release)
392-
if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then
393-
echo "The tag release commit is not an ancestor of origin/main." >&2
392+
if [[ "$MAIN_COMMIT" != "$RELEASE_COMMIT" ]]; then
393+
echo "The tag release commit is no longer the exact origin/main tip." >&2
394394
exit 1
395395
fi
396396
CONTROL_FIRST_PARENT=""
@@ -576,8 +576,8 @@ jobs:
576576
fi
577577
;;
578578
release)
579-
if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then
580-
echo "The release commit is not an ancestor of origin/main." >&2
579+
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then
580+
echo "The release commit is no longer the exact origin/main tip." >&2
581581
exit 1
582582
fi
583583
;;
@@ -925,8 +925,8 @@ jobs:
925925
echo "The tag publication identity changed while awaiting approval." >&2
926926
exit 1
927927
fi
928-
if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then
929-
echo "The release commit is no longer an ancestor of origin/main." >&2
928+
if [[ "$main_commit" != "$RELEASE_COMMIT" ]]; then
929+
echo "The release commit is no longer the exact origin/main tip." >&2
930930
exit 1
931931
fi
932932
;;

RELEASING.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,16 +280,18 @@ The repository maintains four independently auditable workflows:
280280
job. That job has no Environment and no OIDC permission. It validates the
281281
exact source job and treats a preparation run whose result-upload step was
282282
skipped as release-inert. For a release run, it requires the unique
283-
attempt-qualified result, immutable bot-authored Release, tag commit, `main`
284-
ancestry, and the dispatch contract stored in the tag, then uses its sole
283+
attempt-qualified result, immutable bot-authored Release, tag commit, exact
284+
current `main` identity, and the dispatch contract stored in the tag, then
285+
uses its sole
285286
`actions: write` permission to dispatch the same workflow with `ref=v<version>`.
286287
The `verify`, `live-smoke`, and `publish` jobs accept only
287288
that tag-bound `workflow_dispatch`; they are unreachable from the original
288289
main-context `workflow_run`. A successful manual Release Please preparation
289290
run is release-inert and cannot enter the handoff.
290291

291292
The tag run independently revalidates the source run and result artifact,
292-
exact tag and immutable Release, package metadata, and `main` ancestry. The
293+
exact tag and immutable Release, package metadata, and exact current `main`
294+
identity. The
293295
normal operation packs and tests one attempt-qualified artifact, runs a fresh
294296
bounded live smoke, and sends that same file to npm OIDC; the one-time
295297
recovery operation downloads and retests the already live-verified tarball
@@ -573,6 +575,8 @@ The repository maintains four independently auditable workflows:
573575
immediately before registry mutation and fails if a run was created, rerun, or
574576
remains active while the recovery was waiting. This makes the operator freeze
575577
observable rather than relying only on timing.
578+
The one-time recovery accepts only the first workflow attempt; a rerun or a
579+
second dispatch is forbidden even when all other inputs match.
576580
577581
If the npm publish request may have reached the registry but its response or
578582
the remaining workflow result was lost, do not infer success or a safe retry

scripts/release-workflow-validation.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,7 +1158,7 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({
11581158
PUBLISH_RECOVERY.dispatchTask,
11591159
"publish recovery operation",
11601160
);
1161-
requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt");
1161+
requireEqual(workflowRunAttempt, 1, "publish recovery run attempt");
11621162
requirePositiveInteger(recoveryPolicyId, "publish recovery policy ID");
11631163
if (!Array.isArray(changedFiles)) {
11641164
fail("Release workflow publish recovery changed files must be an array.");

tests/release-workflow-validation.test.mjs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ describe("Publish workflow dispatch recovery trigger", () => {
371371
["source commit", { sourceReleaseCommit: RELEASE_SHA }],
372372
["source run ID", { sourceRunId: 30469181725 }],
373373
["source attempt", { sourceRunAttempt: 2 }],
374+
["workflow rerun", { workflowRunAttempt: 2 }],
374375
["missing file", { changedFiles: recoveryFiles.slice(1) }],
375376
["extra file", { changedFiles: [...recoveryFiles, "package.json"] }],
376377
])("rejects recovery trigger drift in %s", (_name, overrides) => {
@@ -380,14 +381,6 @@ describe("Publish workflow dispatch recovery trigger", () => {
380381
),
381382
).toThrow(/release workflow/i);
382383
});
383-
384-
it("accepts a rerun of the same immutable recovery event", () => {
385-
expect(
386-
validatePublishWorkflowDispatchRecoveryTrigger(
387-
recoveryTrigger({ workflowRunAttempt: 2 }),
388-
),
389-
).toMatchObject({ releaseRunId: 30469181724 });
390-
});
391384
});
392385

393386
describe("Publish tag dispatch trigger", () => {

tests/workflow-contract.test.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,12 @@ describe("GitHub Actions workflow contract", () => {
455455
expect(handoff).toContain('-f ref="$RELEASE_TAG"');
456456
expect(handoff).toContain('-f "inputs[publish_operation]=release"');
457457
expect(handoff).toContain('-f "inputs[control_commit]=$RELEASE_COMMIT"');
458+
expect(handoff).toContain(
459+
'$(git rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT',
460+
);
461+
expect(handoff).not.toContain(
462+
'git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main',
463+
);
458464

459465
const verify = job(publish, "verify");
460466
expect(verify).toContain("github.event_name == 'workflow_dispatch'");
@@ -494,6 +500,10 @@ describe("GitHub Actions workflow contract", () => {
494500
);
495501
expect(verify).toContain("if: inputs.publish_operation == 'release'");
496502
expect(verify).toContain("Select the exact release artifact");
503+
expect(verify).toContain('if [[ "$MAIN_COMMIT" != "$RELEASE_COMMIT" ]]');
504+
expect(verify).toContain(
505+
'if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]',
506+
);
497507
expect(verify).toContain("github.triggering_actor");
498508
expect(releaseWorkflowValidation).toContain("30471665743");
499509
expect(releaseWorkflowValidation).toContain("8731956162");
@@ -593,6 +603,9 @@ describe("GitHub Actions workflow contract", () => {
593603
expect(publishJob).toContain(
594604
"RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }}",
595605
);
606+
expect(publishJob).toContain(
607+
'if [[ "$main_commit" != "$RELEASE_COMMIT" ]]',
608+
);
596609
expect(publishJob).toContain("GH_TOKEN: ${{ github.token }}");
597610
expect(publishJob).toContain("status=${state}");
598611
expect(publishJob).toContain("client.chat.completions.create");

0 commit comments

Comments
 (0)