Skip to content

Commit 0d54f23

Browse files
committed
fix(release): recover publication convergence
1 parent 79c6421 commit 0d54f23

2 files changed

Lines changed: 59 additions & 39 deletions

File tree

.github/workflows/release.yml

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,25 @@ jobs:
104104
throw new Error(`PR #${pullNumber} merge tree does not match its validated head tree.`);
105105
}
106106
107-
const allowedSourceRefs = [
107+
const exactSourceRefs = [
108108
`refs/heads/release-run/${version}`,
109109
`refs/tags/${version}`,
110110
];
111111
const sourceRef = process.env.EVENT_REF;
112-
if (!allowedSourceRefs.includes(sourceRef) || process.env.EVENT_SHA !== releaseSha) {
112+
const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' });
113+
const exactReleaseSource =
114+
exactSourceRefs.includes(sourceRef) && process.env.EVENT_SHA === releaseSha;
115+
const trustedMasterRecovery =
116+
sourceRef === 'refs/heads/master' &&
117+
process.env.EVENT_SHA === master.data.object.sha;
118+
if (!exactReleaseSource && !trustedMasterRecovery) {
113119
throw new Error(
114-
`Release workflow must run from an exact release ref at ${releaseSha}; got ` +
115-
`${process.env.EVENT_REF} at ${process.env.EVENT_SHA}.`,
120+
`Release workflow must run from an exact release ref at ${releaseSha} or the ` +
121+
`current protected master for workflow recovery; got ${sourceRef} at ` +
122+
`${process.env.EVENT_SHA}.`,
116123
);
117124
}
118125
119-
const master = await github.rest.git.getRef({ owner, repo, ref: 'heads/master' });
120126
const comparison = await github.rest.repos.compareCommitsWithBasehead({
121127
owner,
122128
repo,
@@ -638,6 +644,23 @@ jobs:
638644
}
639645
}
640646
647+
async function waitForPublishedRelease(releaseId) {
648+
for (let attempt = 0; attempt < 30; attempt += 1) {
649+
const current = await github.rest.repos.getRelease({
650+
owner,
651+
repo,
652+
release_id: releaseId,
653+
});
654+
if (!current.data.draft && current.data.tag_name === version) {
655+
return current.data;
656+
}
657+
if (attempt < 29) {
658+
await new Promise((resolve) => setTimeout(resolve, 1000));
659+
}
660+
}
661+
throw new Error(`GitHub did not converge release ${version} to published state.`);
662+
}
663+
641664
const tagSha = await resolveTagCommit(version);
642665
if (tagSha !== releaseSha) {
643666
throw new Error(`Tag ${version} points to ${tagSha}, expected ${releaseSha}.`);
@@ -685,6 +708,8 @@ jobs:
685708
owner,
686709
repo,
687710
release_id: release.id,
711+
tag_name: version,
712+
target_commitish: releaseSha,
688713
name: version,
689714
body: notes,
690715
draft: true,
@@ -718,40 +743,28 @@ jobs:
718743
if (await resolveTagCommit(version) !== releaseSha) {
719744
throw new Error(`Release tag ${version} changed before publication.`);
720745
}
721-
try {
722-
const published = await github.rest.repos.updateRelease({
723-
owner,
724-
repo,
725-
release_id: release.id,
726-
name: version,
727-
body: notes,
728-
draft: false,
729-
prerelease: false,
730-
make_latest: 'true',
731-
});
732-
if (published.data.draft || published.data.tag_name !== version) {
733-
throw new Error(`GitHub did not publish release ${version}.`);
734-
}
735-
if (await resolveTagCommit(version) !== releaseSha) {
736-
throw new Error(`Release tag ${version} changed during publication.`);
737-
}
738-
await verifyRemoteAssets(release.id);
739-
} catch (error) {
740-
try {
741-
await github.rest.repos.updateRelease({
742-
owner,
743-
repo,
744-
release_id: release.id,
745-
name: version,
746-
body: notes,
747-
draft: true,
748-
prerelease: false,
749-
});
750-
core.warning(`Returned release ${version} to draft after final verification failed.`);
751-
} catch (rollbackError) {
752-
core.error(`Could not return release ${version} to draft: ${rollbackError.message}`);
753-
}
754-
throw error;
746+
await github.rest.repos.updateRelease({
747+
owner,
748+
repo,
749+
release_id: release.id,
750+
tag_name: version,
751+
target_commitish: releaseSha,
752+
name: version,
753+
body: notes,
754+
draft: false,
755+
prerelease: false,
756+
make_latest: 'true',
757+
});
758+
release = await waitForPublishedRelease(release.id);
759+
if (await resolveTagCommit(version) !== releaseSha) {
760+
throw new Error(`Release tag ${version} changed during publication.`);
755761
}
762+
await verifyRemoteAssets(release.id);
763+
764+
// All mutable release inputs were verified before publication. Do not
765+
// demote a published release on a later verification failure: GitHub
766+
// detaches its tag and renames the draft to `untagged-*`. A recovery
767+
// run instead revalidates the public release exactly and finishes only
768+
// when the tag and downloaded asset digests still match.
756769
await removeRecoveryBranch();
757770
core.notice(`Published and verified PodNotes ${version} at ${releaseSha}.`);

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,15 @@ the recovery branch. Recover an interrupted run from its exact remaining ref:
224224
gh workflow run release.yml --ref release-run/<version> -f releasePr=<merged-pr-number>
225225
# After the durable tag exists:
226226
gh workflow run release.yml --ref <version> -f releasePr=<merged-pr-number>
227+
# If the release workflow itself needed a reviewed fix on master:
228+
gh workflow run release.yml --ref master -f releasePr=<merged-pr-number>
227229
```
228230

231+
The `master` recovery path still rebuilds and publishes the exact merge commit
232+
validated from the machine-generated release PR. It exists only so a reviewed
233+
workflow fix can recover an already-tagged release without moving the durable
234+
tag or rewriting the release commit.
235+
229236
## PR Expectations
230237
Pull requests should include:
231238
- a concise summary of the user-facing change;

0 commit comments

Comments
 (0)