Skip to content

Commit d02b87d

Browse files
authored
Merge pull request #46 from cometapi-dev/agent/fix-attestation-convergence
fix: harden post-publication convergence
2 parents e348f78 + 05041c4 commit d02b87d

11 files changed

Lines changed: 1432 additions & 40 deletions

.github/workflows/publish.yml

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
if: >-
4848
vars.RELEASE_PLEASE_ENABLED == 'true' &&
4949
github.event_name == 'workflow_run' &&
50+
github.run_attempt == 1 &&
5051
github.event.workflow_run.conclusion == 'success' &&
5152
github.event.workflow_run.event == 'push' &&
5253
github.event.workflow_run.head_branch == 'main'
@@ -243,7 +244,17 @@ jobs:
243244
after_runs="$RUNNER_TEMP/tag-dispatch-runs-after.json"
244245
gh api --paginate --slurp \
245246
"repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100" \
246-
| jq '[.[].workflow_runs[].id]' > "$before_runs"
247+
| jq '[.[].workflow_runs[]]' > "$before_runs"
248+
prior_count="$(jq \
249+
--arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \
250+
'[.[] | select(.actor.login == "github-actions[bot]" and
251+
.triggering_actor.login == "github-actions[bot]" and
252+
.event == "workflow_dispatch" and .head_branch == $tag and
253+
.head_sha == $commit)] | length' "$before_runs")"
254+
if [[ "$prior_count" != "0" ]]; then
255+
echo "An exact Publish run already exists for this immutable tag and commit." >&2
256+
exit 1
257+
fi
247258
gh api --method POST \
248259
"repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/dispatches" \
249260
-f ref="$RELEASE_TAG" \
@@ -262,7 +273,7 @@ jobs:
262273
candidate_count="$(jq \
263274
--arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \
264275
--slurpfile before "$before_runs" \
265-
'[.[] | select(.id as $id | ($before[0] | index($id) | not)) |
276+
'[.[] | select(.id as $id | ($before[0] | map(.id) | index($id) | not)) |
266277
select(.actor.login == "github-actions[bot]" and
267278
.triggering_actor.login == "github-actions[bot]" and
268279
.event == "workflow_dispatch" and .head_branch == $tag and
@@ -275,7 +286,7 @@ jobs:
275286
publish_run_id="$(jq -r \
276287
--arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \
277288
--slurpfile before "$before_runs" \
278-
'[.[] | select(.id as $id | ($before[0] | index($id) | not)) |
289+
'[.[] | select(.id as $id | ($before[0] | map(.id) | index($id) | not)) |
279290
select(.actor.login == "github-actions[bot]" and
280291
.triggering_actor.login == "github-actions[bot]" and
281292
.event == "workflow_dispatch" and .head_branch == $tag and
@@ -296,6 +307,7 @@ jobs:
296307
if: >-
297308
vars.RELEASE_PLEASE_ENABLED == 'true' &&
298309
github.event_name == 'workflow_dispatch' &&
310+
github.run_attempt == 1 &&
299311
inputs.publish_operation == 'release' &&
300312
startsWith(github.ref, 'refs/tags/v0.1.') &&
301313
github.ref == format('refs/tags/{0}', inputs.release_tag) &&
@@ -312,11 +324,21 @@ jobs:
312324
artifact-name: ${{ steps.artifact-name.outputs.name }}
313325
control-commit: ${{ inputs.control_commit }}
314326
dist-tag: ${{ steps.version.outputs.dist-tag }}
327+
expected-next-version: ${{ steps.registry-baseline.outputs.next-version }}
315328
release-commit: ${{ steps.trust.outputs.release-commit }}
316329
release-tag: ${{ steps.trust.outputs.release-tag }}
317330
release-please-snapshot: ${{ steps.release-please-snapshot.outputs.digest }}
318331
version: ${{ steps.version.outputs.version }}
319332
steps:
333+
- name: Reject a repeated verification attempt
334+
env:
335+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
336+
run: |
337+
set -euo pipefail
338+
if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then
339+
echo "Exact-artifact verification is restricted to the initial run attempt." >&2
340+
exit 1
341+
fi
320342
- name: Check out the workflow control commit
321343
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
322344
with:
@@ -576,6 +598,18 @@ jobs:
576598
EOF
577599
- name: Use a Trusted Publishing-capable npm CLI
578600
run: npm install --global npm@11.12.1
601+
- name: Freeze the prerelease dist-tag
602+
id: registry-baseline
603+
shell: bash
604+
run: |
605+
set -euo pipefail
606+
next_version="$(npm view cometapi@next version)"
607+
NEXT_VERSION="$next_version" node --input-type=module <<'EOF'
608+
import { validatePrereleaseDistTagBaseline } from "./scripts/release-workflow-validation.mjs";
609+
610+
validatePrereleaseDistTagBaseline(process.env.NEXT_VERSION);
611+
EOF
612+
echo "next-version=${next_version}" >> "$GITHUB_OUTPUT"
579613
- name: Install locked dependencies
580614
run: npm ci
581615
- name: Run release checks
@@ -630,6 +664,9 @@ jobs:
630664

631665
live-smoke:
632666
name: Verify the release tag against CometAPI
667+
if: >-
668+
github.run_attempt == 1 &&
669+
needs.verify.result == 'success'
633670
needs:
634671
- verify
635672
concurrency:
@@ -641,6 +678,15 @@ jobs:
641678
# without required reviewers and add COMETAPI_KEY before publishing a release.
642679
environment: live-smoke
643680
steps:
681+
- name: Reject a repeated live-smoke attempt
682+
env:
683+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
684+
run: |
685+
set -euo pipefail
686+
if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then
687+
echo "The bounded live smoke is restricted to the initial run attempt." >&2
688+
exit 1
689+
fi
644690
- name: Check out the verified release tag
645691
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
646692
with:
@@ -668,6 +714,11 @@ jobs:
668714

669715
publish:
670716
name: Publish with npm Trusted Publishing
717+
if: >-
718+
always() &&
719+
(github.run_attempt == 1 || github.run_attempt == 2) &&
720+
needs.live-smoke.result == 'success' &&
721+
needs.verify.result == 'success'
671722
needs:
672723
- live-smoke
673724
- verify
@@ -684,6 +735,16 @@ jobs:
684735
deployments: read
685736
id-token: write
686737
steps:
738+
- name: Reject an out-of-bounds publication attempt
739+
env:
740+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
741+
run: |
742+
set -euo pipefail
743+
if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" &&
744+
"$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then
745+
echo "Publication permits only the initial attempt and one failed-job replay." >&2
746+
exit 1
747+
fi
687748
- name: Check out the verified release commit
688749
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
689750
with:
@@ -704,12 +765,14 @@ jobs:
704765
name: ${{ needs.verify.outputs.artifact-name }}
705766
path: release-artifacts
706767
- name: Reconfirm protected state immediately before publication
768+
id: pre-publish
707769
env:
708770
CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }}
709771
CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs
710772
ENVIRONMENT_FILE: ${{ runner.temp }}/npm-environment.json
711773
EVENT_REF: ${{ github.ref }}
712774
EVENT_SHA: ${{ github.sha }}
775+
EXPECTED_NEXT_VERSION: ${{ needs.verify.outputs.expected-next-version }}
713776
GH_TOKEN: ${{ github.token }}
714777
POLICIES_FILE: ${{ runner.temp }}/npm-deployment-policies.json
715778
RELEASE_COMMIT: ${{ needs.verify.outputs.release-commit }}
@@ -722,6 +785,7 @@ jobs:
722785
RELEASE_PLEASE_RUNS: ${{ runner.temp }}/release-please-runs-before-publish.json
723786
RELEASE_PLEASE_SNAPSHOT: ${{ needs.verify.outputs.release-please-snapshot }}
724787
VERSION: ${{ needs.verify.outputs.version }}
788+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
725789
WORKFLOW_SHA: ${{ github.workflow_sha }}
726790
shell: bash
727791
run: |
@@ -793,6 +857,10 @@ jobs:
793857
exit 1
794858
fi
795859
fi
860+
if [[ "$WORKFLOW_RUN_ATTEMPT" == "2" && -z "$exact_version" ]]; then
861+
echo "The failed-job replay requires the exact registry version to exist." >&2
862+
exit 1
863+
fi
796864
latest_version="$(npm view cometapi@latest version)"
797865
next_version="$(npm view cometapi@next version)"
798866
gh api --paginate --slurp \
@@ -885,21 +953,29 @@ jobs:
885953
});
886954
validateRegistryStateBeforePublish({
887955
exactVersion: process.env.EXACT_VERSION || null,
956+
expectedNextVersion: process.env.EXPECTED_NEXT_VERSION,
888957
latestVersion: process.env.LATEST_VERSION,
889958
nextVersion: process.env.NEXT_VERSION,
890959
version,
891960
});
892961
EOF
962+
if [[ -n "$exact_version" ]]; then
963+
echo "expect-existing=true" >> "$GITHUB_OUTPUT"
964+
else
965+
echo "expect-existing=false" >> "$GITHUB_OUTPUT"
966+
fi
893967
- name: Publish the exact artifact with provenance
894968
env:
895969
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
970+
EXPECT_EXISTING: ${{ steps.pre-publish.outputs.expect-existing }}
896971
VERSION: ${{ needs.verify.outputs.version }}
897972
run: bash scripts/publish-artifact.sh
898973
- name: Verify the public registry artifact
899974
env:
900975
CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }}
901976
CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs
902977
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
978+
EXPECTED_NEXT_VERSION: ${{ needs.verify.outputs.expected-next-version }}
903979
GH_TOKEN: ${{ github.token }}
904980
WORKFLOW_REF: ${{ github.ref }}
905981
VERSION: ${{ needs.verify.outputs.version }}
@@ -942,14 +1018,57 @@ jobs:
9421018
echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2
9431019
exit 1
9441020
fi
945-
if [[ "$(npm view cometapi@next version)" != "0.1.0-alpha.3" ]]; then
1021+
if [[ "$(npm view cometapi@next version)" != "$EXPECTED_NEXT_VERSION" ]]; then
9461022
echo "The next dist-tag changed during publication." >&2
9471023
exit 1
9481024
fi
9491025
9501026
attestations_url="$(REGISTRY_DIST="$registry_dist" node -e 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')"
9511027
attestations_file="$RUNNER_TEMP/npm-attestations.json"
952-
curl --fail --silent --show-error "$attestations_url" > "$attestations_file"
1028+
ATTESTATIONS_URL="$attestations_url" \
1029+
node --input-type=module <<'EOF'
1030+
import { pathToFileURL } from "node:url";
1031+
1032+
const { validateRegistryAttestationUrl } = await import(
1033+
pathToFileURL(process.env.CONTROL_VALIDATOR)
1034+
);
1035+
validateRegistryAttestationUrl({
1036+
url: process.env.ATTESTATIONS_URL,
1037+
version: process.env.VERSION,
1038+
});
1039+
EOF
1040+
ATTESTATIONS_FILE="$attestations_file" \
1041+
ATTESTATIONS_URL="$attestations_url" \
1042+
bash scripts/fetch-attestations.sh
1043+
1044+
post_exact_version="$(npm view "cometapi@${VERSION}" version)"
1045+
post_tagged_version="$(npm view "cometapi@${DIST_TAG}" version)"
1046+
post_next_version="$(npm view cometapi@next version)"
1047+
post_registry_dist="$(npm view "cometapi@${VERSION}" dist --json)"
1048+
ATTESTATIONS_URL="$attestations_url" \
1049+
EXACT_VERSION="$post_exact_version" \
1050+
EXPECTED_INTEGRITY="$local_integrity" \
1051+
NEXT_VERSION="$post_next_version" \
1052+
REGISTRY_DIST="$post_registry_dist" \
1053+
TAGGED_VERSION="$post_tagged_version" \
1054+
node --input-type=module <<'EOF'
1055+
import { pathToFileURL } from "node:url";
1056+
1057+
const { validatePublishedRegistryState } = await import(
1058+
pathToFileURL(process.env.CONTROL_VALIDATOR)
1059+
);
1060+
validatePublishedRegistryState({
1061+
attestationUrl: process.env.ATTESTATIONS_URL,
1062+
dist: JSON.parse(process.env.REGISTRY_DIST),
1063+
exactVersion: process.env.EXACT_VERSION,
1064+
expectedNextVersion: process.env.EXPECTED_NEXT_VERSION,
1065+
expectedIntegrity: process.env.EXPECTED_INTEGRITY,
1066+
nextVersion: process.env.NEXT_VERSION,
1067+
taggedVersion: process.env.TAGGED_VERSION,
1068+
version: process.env.VERSION,
1069+
});
1070+
EOF
1071+
registry_dist="$post_registry_dist"
9531072
local_sha512="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write(createHash("sha512").update(readFileSync(process.argv[1])).digest("hex"))' "${tarballs[0]}")"
9541073
provenance_identity="$(ATTESTATIONS_FILE="$attestations_file" \
9551074
LOCAL_SHA512="$local_sha512" node --input-type=module <<'EOF'
@@ -1176,3 +1295,36 @@ jobs:
11761295
process.exitCode = 1;
11771296
});
11781297
EOF
1298+
1299+
result:
1300+
name: Enforce the bounded publication result
1301+
if: >-
1302+
always() &&
1303+
github.event_name == 'workflow_dispatch' &&
1304+
inputs.publish_operation == 'release'
1305+
needs:
1306+
- live-smoke
1307+
- publish
1308+
- verify
1309+
runs-on: ubuntu-latest
1310+
permissions: {}
1311+
steps:
1312+
- name: Reject skipped or out-of-bounds publication
1313+
env:
1314+
LIVE_SMOKE_RESULT: ${{ needs.live-smoke.result }}
1315+
PUBLISH_RESULT: ${{ needs.publish.result }}
1316+
VERIFY_RESULT: ${{ needs.verify.result }}
1317+
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
1318+
run: |
1319+
set -euo pipefail
1320+
if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" &&
1321+
"$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then
1322+
echo "Publication permits only the initial attempt and one failed-job replay." >&2
1323+
exit 1
1324+
fi
1325+
if [[ "$VERIFY_RESULT" != "success" ||
1326+
"$LIVE_SMOKE_RESULT" != "success" ||
1327+
"$PUBLISH_RESULT" != "success" ]]; then
1328+
echo "The bounded publication job set did not complete successfully." >&2
1329+
exit 1
1330+
fi

AGENTS.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,32 @@ repository root.
283283
- Prepare or refresh a release PR with a new first-attempt manual Release Please
284284
dispatch. Do not rerun a preparation dispatch. Release Please same-run Release
285285
reconciliation is allowed only under the exact conditions in `RELEASING.md`.
286+
- The successful Release Please `workflow_run` handoff is attempt-1-only and
287+
must refuse to dispatch when any exact Publish child already exists for the
288+
immutable tag and commit. Never rerun a handoff to create a second child run.
286289
- Never bypass a failed stable release with a manual or auxiliary tag, a
287-
branch-context publish, a temporary `main` npm Environment policy, reused
288-
artifact or live evidence, an arbitrary rerun, or a different patch version.
290+
branch-context publish, a temporary `main` npm Environment policy, cross-run
291+
artifact or live-evidence reuse, an arbitrary rerun, or a different patch
292+
version.
293+
- If `npm publish` succeeds but post-publication verification fails, first read
294+
the exact public version, `latest`, `next`, integrity, and every readable
295+
attestation, signature, and provenance field. When the exact version is
296+
absent, `latest` must equal the previous patch. When the exact version equals
297+
the candidate, `latest` may equal the previous patch or candidate. Final state
298+
requires both exact and `latest` to equal the candidate, while `next` must
299+
equal the prerelease value frozen by the initial verification job. A transient
300+
early attestation-endpoint `404` is the known registry convergence condition;
301+
all transport failures receive one wall-clock-bounded wait, and exhaustion is
302+
terminal. Do not rerun to extend it. Only when attestations are readable and
303+
independent signature and provenance checks have passed may exactly one
304+
`rerun failed jobs` on the same immutable-tag run resume a different failed
305+
post-publication gate. Confirm that GitHub preserves the successful
306+
exact-artifact and live-smoke jobs; rerun-all must fail before live API access.
307+
The attempt-2 publish job requires the exact version to exist, matching
308+
integrity, and a fresh Environment approval; it must skip `npm publish`.
309+
Attempt 3 or later, an exact-version metadata `E404`, a second replay request,
310+
non-convergence, or any identity, integrity, provenance, dist-tag, or
311+
configuration mismatch is a hard stop.
289312
- After registry verification, immediately restore
290313
`RELEASE_PLEASE_ENABLED=false`, keep `LIVE_SMOKE_ENABLED=true`, and require the
291314
npm Environment deployment-policy set to contain only `tag:v*`.

ARCHITECTURE.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ the exact Release-producing attempt, SHA, tag, version, URL, repository,
139139
workflow identity, run ID, and attempt in a schema-v2 exact-run artifact. An
140140
unprivileged `workflow_run` handoff validates only that attempt's artifact,
141141
exact tag, immutable Release, and current `main`, then dispatches `publish.yml`
142-
with `ref=v<version>`. Only that tag-bound `workflow_dispatch` can reach fresh
142+
with `ref=v<version>`. The handoff is attempt-1-only and refuses to dispatch if
143+
an exact child run already exists for the tag and commit. Only that tag-bound
144+
`workflow_dispatch` can reach fresh
143145
artifact verification, bounded live smoke, the npm Environment, or OIDC. A
144146
first-attempt manual run is explicitly release-inert and must succeed only after
145147
independently validating one canonical action-created patch PR; its event cannot
@@ -153,6 +155,23 @@ Release Please and publication remain separate trust domains. Release Please
153155
does not receive npm OIDC permission; `id-token: write` remains limited to the
154156
protected publish job. Repository variables gate both flows, and reruns remain
155157
fail-closed on exact tag, artifact, dist-tag, integrity, and provenance state.
158+
Registry package metadata and its attestation endpoint can converge at
159+
different times. Post-publication verification therefore gives attestation HTTP
160+
failures one strict wall-clock-bounded retry window before failing; an early
161+
`404` is the known convergence case, while persistent URL, authentication,
162+
authorization, server, and transport failures remain terminal. The initial
163+
verification job freezes the prerelease dist-tag value, and every later registry
164+
gate requires it to remain unchanged rather than encoding a current package
165+
version in durable workflow guidance. If publication succeeded before a later
166+
gate failed, only one attempt-2 failed-job replay of that same immutable-tag run
167+
may continue, and only after attestations, signature, and provenance are already
168+
valid. Verification and bounded live smoke are attempt-1-only, so rerun-all
169+
fails before live API access; attempt 3 or later also fails. The protected-state
170+
step requires the exact version to exist on replay; `publish-artifact.sh` then
171+
skips registry mutation only after its integrity matches the verified tarball
172+
and refuses a later `E404` instead of republishing. The failed-job replay
173+
preserves the same run's successful artifact and bounded live-smoke jobs rather
174+
than borrowing evidence from another run.
156175
Manual preparation rejects attempt 2 or later; restart uses a new dispatch with
157176
Release creation disabled. A `push` rerun is bounded to the same run ID, SHA,
158177
candidate, and final-head review. It may retry while the tag and Release remain

0 commit comments

Comments
 (0)