@@ -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}.`);
0 commit comments