Skip to content

Commit 525d4d0

Browse files
committed
fix: recover exact publish artifact
1 parent c98b514 commit 525d4d0

5 files changed

Lines changed: 322 additions & 18 deletions

File tree

.github/workflows/publish.yml

Lines changed: 138 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
name: Publish
22

33
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- .github/workflows/publish.yml
49
workflow_run:
510
workflows:
611
- Release Please
@@ -20,32 +25,100 @@ jobs:
2025
name: Verify the immutable release artifact
2126
if: >-
2227
vars.RELEASE_PLEASE_ENABLED == 'true' &&
23-
github.event.workflow_run.conclusion == 'success' &&
24-
github.event.workflow_run.event == 'push' &&
25-
github.event.workflow_run.head_branch == 'main'
28+
((github.event_name == 'workflow_run' &&
29+
github.event.workflow_run.conclusion == 'success' &&
30+
github.event.workflow_run.event == 'push' &&
31+
github.event.workflow_run.head_branch == 'main') ||
32+
(github.event_name == 'push' && github.ref == 'refs/heads/main'))
2633
runs-on: ubuntu-latest
2734
timeout-minutes: 30
35+
env:
36+
SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'push' && 'c98b514227858cd183c781270a7f78f65b577e82' || github.event.workflow_run.head_sha }}
37+
SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'push' && '1' || github.event.workflow_run.run_attempt }}
38+
SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'push' && '30469181724' || github.event.workflow_run.id }}
2839
outputs:
2940
artifact-name: ${{ steps.artifact-name.outputs.name }}
3041
dist-tag: ${{ steps.version.outputs.dist-tag }}
3142
release-commit: ${{ steps.trust.outputs.release-commit }}
3243
release-tag: ${{ steps.trust.outputs.release-tag }}
3344
version: ${{ steps.version.outputs.version }}
3445
steps:
35-
- name: Check out the current main branch
46+
- name: Check out the workflow control commit
3647
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
3748
with:
3849
fetch-depth: 0
3950
persist-credentials: false
40-
ref: refs/heads/main
51+
ref: ${{ github.sha }}
52+
- name: Validate the one-cycle exact release recovery
53+
if: github.event_name == 'push'
54+
env:
55+
ACTOR: ${{ github.actor }}
56+
CHANGED_FILES: ${{ runner.temp }}/publish-recovery-files
57+
EVENT_AFTER: ${{ github.event.after }}
58+
EVENT_BEFORE: ${{ github.event.before }}
59+
EVENT_NAME: ${{ github.event_name }}
60+
EVENT_REF: ${{ github.ref }}
61+
MAIN_COMMIT: ${{ github.sha }}
62+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
63+
shell: bash
64+
run: |
65+
set -euo pipefail
66+
if [[ "$(git rev-parse HEAD)" != "$MAIN_COMMIT" ]]; then
67+
echo "The recovery control checkout does not match the triggering SHA." >&2
68+
exit 1
69+
fi
70+
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
71+
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$MAIN_COMMIT" ]]; then
72+
echo "main moved after the publish recovery was triggered." >&2
73+
exit 1
74+
fi
75+
git diff --name-only "$EVENT_BEFORE" "$MAIN_COMMIT" > "$CHANGED_FILES"
76+
MAIN_FIRST_PARENT="$(git rev-parse "${MAIN_COMMIT}^1")" \
77+
node --input-type=module <<'EOF'
78+
import { readFileSync } from "node:fs";
79+
import { validatePublishRecoveryTrigger } from "./scripts/release-workflow-validation.mjs";
80+
81+
validatePublishRecoveryTrigger({
82+
actor: process.env.ACTOR,
83+
changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8")
84+
.split("\n")
85+
.filter((file) => file !== ""),
86+
eventAfter: process.env.EVENT_AFTER,
87+
eventBefore: process.env.EVENT_BEFORE,
88+
eventName: process.env.EVENT_NAME,
89+
eventRef: process.env.EVENT_REF,
90+
mainCommit: process.env.MAIN_COMMIT,
91+
mainFirstParent: process.env.MAIN_FIRST_PARENT,
92+
sourceReleaseCommit: process.env.SOURCE_RELEASE_COMMIT,
93+
sourceRunAttempt: Number(process.env.SOURCE_RELEASE_RUN_ATTEMPT),
94+
sourceRunId: Number(process.env.SOURCE_RELEASE_RUN_ID),
95+
workflowRunAttempt: Number(process.env.WORKFLOW_RUN_ATTEMPT),
96+
});
97+
EOF
98+
- name: Check out the exact release commit
99+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
100+
with:
101+
fetch-depth: 0
102+
persist-credentials: false
103+
ref: ${{ env.SOURCE_RELEASE_COMMIT }}
104+
- name: Read the exact Release Please source run
105+
env:
106+
GH_TOKEN: ${{ github.token }}
107+
SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json
108+
shell: bash
109+
run: |
110+
set -euo pipefail
111+
gh api \
112+
"repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RELEASE_RUN_ID}" \
113+
> "$SOURCE_RUN_FILE"
41114
- name: Download the exact Release Please result
42115
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
43116
with:
44-
name: release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
45-
path: release-please-result
117+
name: release-please-result-${{ env.SOURCE_RELEASE_RUN_ID }}-${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}
118+
path: ${{ runner.temp }}/release-please-result
46119
github-token: ${{ github.token }}
47120
repository: ${{ github.repository }}
48-
run-id: ${{ github.event.workflow_run.id }}
121+
run-id: ${{ env.SOURCE_RELEASE_RUN_ID }}
49122
- name: Reject an untrusted Release Please workflow run
50123
id: trust
51124
env:
@@ -54,8 +127,13 @@ jobs:
54127
EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git
55128
EXPECTED_WORKFLOW: Release Please
56129
EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml
57-
RELEASE_RESULT: release-please-result/result.json
58-
WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }}
130+
CONTROL_SHA: ${{ github.sha }}
131+
EVENT_NAME: ${{ github.event_name }}
132+
RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json
133+
SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json
134+
SOURCE_RUN_ATTEMPT: ${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}
135+
SOURCE_RUN_ID: ${{ env.SOURCE_RELEASE_RUN_ID }}
136+
WORKFLOW_SHA: ${{ env.SOURCE_RELEASE_COMMIT }}
59137
shell: bash
60138
run: |
61139
set -euo pipefail
@@ -66,8 +144,13 @@ jobs:
66144
fi
67145
68146
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
69-
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then
70-
echo "origin/main moved after the successful Release Please run." >&2
147+
case "$EVENT_NAME" in
148+
workflow_run) expected_main="$WORKFLOW_SHA" ;;
149+
push) expected_main="$CONTROL_SHA" ;;
150+
*) echo "Publish received an unsupported event." >&2; exit 1 ;;
151+
esac
152+
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$expected_main" ]]; then
153+
echo "origin/main moved after the trusted publish event." >&2
71154
exit 1
72155
fi
73156
@@ -78,13 +161,54 @@ jobs:
78161
validateReleaseWorkflowRun,
79162
} from "./scripts/release-workflow-validation.mjs";
80163
81-
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
82-
const run = validateReleaseWorkflowRun(event, {
164+
const sourceRun = JSON.parse(
165+
readFileSync(process.env.SOURCE_RUN_FILE, "utf8"),
166+
);
167+
const sourceEvent = {
168+
action: "completed",
169+
repository: { full_name: sourceRun.repository?.full_name },
170+
workflow_run: {
171+
conclusion: sourceRun.conclusion,
172+
event: sourceRun.event,
173+
head_branch: sourceRun.head_branch,
174+
head_repository: sourceRun.head_repository,
175+
head_sha: sourceRun.head_sha,
176+
id: sourceRun.id,
177+
name: sourceRun.name,
178+
path: sourceRun.path,
179+
run_attempt: sourceRun.run_attempt,
180+
},
181+
};
182+
const run = validateReleaseWorkflowRun(sourceEvent, {
83183
checkedOutSha: process.env.WORKFLOW_SHA,
84184
repository: process.env.EXPECTED_REPOSITORY,
85185
workflowName: process.env.EXPECTED_WORKFLOW,
86186
workflowPath: process.env.EXPECTED_WORKFLOW_PATH,
87187
});
188+
if (
189+
run.runId !== Number(process.env.SOURCE_RUN_ID) ||
190+
run.runAttempt !== Number(process.env.SOURCE_RUN_ATTEMPT)
191+
) {
192+
throw new Error(
193+
"Release workflow source run ID or attempt changed before publication.",
194+
);
195+
}
196+
if (process.env.EVENT_NAME === "workflow_run") {
197+
const eventRun = validateReleaseWorkflowRun(
198+
JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")),
199+
{
200+
checkedOutSha: process.env.WORKFLOW_SHA,
201+
repository: process.env.EXPECTED_REPOSITORY,
202+
workflowName: process.env.EXPECTED_WORKFLOW,
203+
workflowPath: process.env.EXPECTED_WORKFLOW_PATH,
204+
},
205+
);
206+
if (JSON.stringify(eventRun) !== JSON.stringify(run)) {
207+
throw new Error(
208+
"Release workflow source run differs from the workflow_run event.",
209+
);
210+
}
211+
}
88212
const manifest = JSON.parse(readFileSync("package.json", "utf8"));
89213
if (
90214
manifest.repository?.type !== "git" ||

RELEASING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,20 @@ The repository maintains four independently auditable workflows:
291291
downloaded artifact, then repeats every bounded registry-state and signature
292292
check.
293293

294+
The first `0.1.1` Publish run
295+
[30469240186](https://github.com/cometapi-dev/cometapi-node/actions/runs/30469240186)
296+
validated the immutable tag, Release, and Release Please result, then failed
297+
before packing, live smoke, OIDC, or npm because the downloaded runtime result
298+
JSON was inside the workspace scanned by Prettier. One reviewed recovery merge
299+
temporarily adds an automatic `publish.yml`-only `main` push path. It accepts
300+
only human actor `tensornull`, exact Release Please run `30469181724` attempt
301+
1, release commit `c98b514227858cd183c781270a7f78f65b577e82`, a direct
302+
first-parent recovery merge, and the five recorded repair files. The workflow
303+
then checks out and rebuilds the immutable release commit, downloads runtime
304+
evidence under `runner.temp`, and uses the unchanged live, OIDC, artifact, and
305+
registry gates. The recovery trigger and constants must be removed in the
306+
post-release evidence PR; the `runner.temp` isolation remains permanent.
307+
294308
Third-party actions are pinned to full commit SHAs. Workflow permissions remain
295309
read-only except where a documented job requires more; `id-token: write` belongs
296310
only to the publish job. Publication cannot run from an arbitrary branch or an

scripts/release-workflow-validation.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ const RELEASE_PR_FOOTER =
1111
const RELEASE_WORKFLOW_JOB =
1212
"Prepare a reviewed release pull request or GitHub release";
1313
const RELEASE_WORKFLOW_STEP = "Run Release Please";
14+
const PUBLISH_RECOVERY = Object.freeze({
15+
actor: "tensornull",
16+
changedFiles: Object.freeze([
17+
".github/workflows/publish.yml",
18+
"RELEASING.md",
19+
"scripts/release-workflow-validation.mjs",
20+
"tests/release-workflow-validation.test.mjs",
21+
"tests/workflow-contract.test.mjs",
22+
]),
23+
releaseCommit: "c98b514227858cd183c781270a7f78f65b577e82",
24+
releaseRunAttempt: 1,
25+
releaseRunId: 30469181724,
26+
});
1427

1528
function fail(message) {
1629
throw new Error(message);
@@ -62,6 +75,79 @@ function stablePatch(version, label) {
6275
return Number(match[1]);
6376
}
6477

78+
export function validatePublishRecoveryTrigger({
79+
actor,
80+
changedFiles,
81+
eventAfter,
82+
eventBefore,
83+
eventName,
84+
eventRef,
85+
mainCommit,
86+
mainFirstParent,
87+
sourceReleaseCommit,
88+
sourceRunAttempt,
89+
sourceRunId,
90+
workflowRunAttempt,
91+
}) {
92+
requireEqual(actor, PUBLISH_RECOVERY.actor, "publish recovery actor");
93+
requireEqual(eventName, "push", "publish recovery event");
94+
requireEqual(eventRef, "refs/heads/main", "publish recovery ref");
95+
requireCommit(eventAfter, "publish recovery event after SHA");
96+
requireCommit(eventBefore, "publish recovery event before SHA");
97+
requireCommit(mainCommit, "publish recovery main commit");
98+
requireCommit(mainFirstParent, "publish recovery main first parent");
99+
requireCommit(sourceReleaseCommit, "publish recovery source release commit");
100+
requireEqual(
101+
eventAfter,
102+
mainCommit,
103+
"publish recovery event and main commit agreement",
104+
);
105+
requireEqual(
106+
eventBefore,
107+
PUBLISH_RECOVERY.releaseCommit,
108+
"publish recovery event before SHA",
109+
);
110+
requireEqual(
111+
mainFirstParent,
112+
PUBLISH_RECOVERY.releaseCommit,
113+
"publish recovery main first parent",
114+
);
115+
requireEqual(
116+
sourceReleaseCommit,
117+
PUBLISH_RECOVERY.releaseCommit,
118+
"publish recovery source release commit",
119+
);
120+
requirePositiveInteger(sourceRunId, "publish recovery source run ID");
121+
requireEqual(
122+
sourceRunId,
123+
PUBLISH_RECOVERY.releaseRunId,
124+
"publish recovery source run ID",
125+
);
126+
requirePositiveInteger(
127+
sourceRunAttempt,
128+
"publish recovery source run attempt",
129+
);
130+
requireEqual(
131+
sourceRunAttempt,
132+
PUBLISH_RECOVERY.releaseRunAttempt,
133+
"publish recovery source run attempt",
134+
);
135+
requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt");
136+
if (!Array.isArray(changedFiles)) {
137+
fail("Release workflow publish recovery changed files must be an array.");
138+
}
139+
requireEqual(
140+
JSON.stringify([...changedFiles].sort()),
141+
JSON.stringify([...PUBLISH_RECOVERY.changedFiles].sort()),
142+
"publish recovery changed files",
143+
);
144+
return {
145+
releaseCommit: PUBLISH_RECOVERY.releaseCommit,
146+
releaseRunAttempt: PUBLISH_RECOVERY.releaseRunAttempt,
147+
releaseRunId: PUBLISH_RECOVERY.releaseRunId,
148+
};
149+
}
150+
65151
function releaseTitle(version) {
66152
return `chore(main): release ${version}`;
67153
}

0 commit comments

Comments
 (0)