Skip to content

Commit 9e91f39

Browse files
authored
fix(release): recover publication convergence
1 parent 79c6421 commit 9e91f39

2 files changed

Lines changed: 105 additions & 39 deletions

File tree

.github/workflows/release.yml

Lines changed: 98 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,40 @@ 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+
664+
async function waitForDraftReleaseTag(releaseId) {
665+
for (let attempt = 0; attempt < 30; attempt += 1) {
666+
const current = await github.rest.repos.getRelease({
667+
owner,
668+
repo,
669+
release_id: releaseId,
670+
});
671+
if (current.data.draft && current.data.tag_name === version) {
672+
return current.data;
673+
}
674+
if (attempt < 29) {
675+
await new Promise((resolve) => setTimeout(resolve, 1000));
676+
}
677+
}
678+
throw new Error(`GitHub did not restore draft release ${version} to its tag.`);
679+
}
680+
641681
const tagSha = await resolveTagCommit(version);
642682
if (tagSha !== releaseSha) {
643683
throw new Error(`Tag ${version} points to ${tagSha}, expected ${releaseSha}.`);
@@ -649,7 +689,36 @@ jobs:
649689
});
650690
const matches = releases.filter((release) => release.tag_name === version);
651691
if (matches.length > 1) throw new Error(`Multiple releases use tag ${version}.`);
692+
const recoverableDrafts = releases.filter((release) =>
693+
release.draft &&
694+
/^untagged-[0-9a-f-]+$/.test(release.tag_name) &&
695+
release.name === version &&
696+
release.target_commitish === releaseSha &&
697+
release.body === notes &&
698+
!release.prerelease &&
699+
release.author?.login === 'github-actions[bot]'
700+
);
701+
if (recoverableDrafts.length > 1 || (matches.length === 1 && recoverableDrafts.length)) {
702+
throw new Error(`Multiple release records could represent ${version}.`);
703+
}
652704
let release = matches[0];
705+
if (!release && recoverableDrafts.length === 1) {
706+
release = recoverableDrafts[0];
707+
await verifyRemoteAssets(release.id);
708+
await github.rest.repos.updateRelease({
709+
owner,
710+
repo,
711+
release_id: release.id,
712+
tag_name: version,
713+
target_commitish: releaseSha,
714+
name: version,
715+
body: notes,
716+
draft: true,
717+
prerelease: false,
718+
});
719+
release = await waitForDraftReleaseTag(release.id);
720+
core.notice(`Restored exact orphaned draft release ${version} to its durable tag.`);
721+
}
653722
if (!release) {
654723
const created = await github.rest.repos.createRelease({
655724
owner,
@@ -685,6 +754,8 @@ jobs:
685754
owner,
686755
repo,
687756
release_id: release.id,
757+
tag_name: version,
758+
target_commitish: releaseSha,
688759
name: version,
689760
body: notes,
690761
draft: true,
@@ -718,40 +789,28 @@ jobs:
718789
if (await resolveTagCommit(version) !== releaseSha) {
719790
throw new Error(`Release tag ${version} changed before publication.`);
720791
}
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;
792+
await github.rest.repos.updateRelease({
793+
owner,
794+
repo,
795+
release_id: release.id,
796+
tag_name: version,
797+
target_commitish: releaseSha,
798+
name: version,
799+
body: notes,
800+
draft: false,
801+
prerelease: false,
802+
make_latest: 'true',
803+
});
804+
release = await waitForPublishedRelease(release.id);
805+
if (await resolveTagCommit(version) !== releaseSha) {
806+
throw new Error(`Release tag ${version} changed during publication.`);
755807
}
808+
await verifyRemoteAssets(release.id);
809+
810+
// All mutable release inputs were verified before publication. Do not
811+
// demote a published release on a later verification failure: GitHub
812+
// detaches its tag and renames the draft to `untagged-*`. A recovery
813+
// run instead revalidates the public release exactly and finishes only
814+
// when the tag and downloaded asset digests still match.
756815
await removeRecoveryBranch();
757816
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)