Skip to content

Commit c4b930d

Browse files
committed
fix: harden post-publication convergence
1 parent e348f78 commit c4b930d

11 files changed

Lines changed: 475 additions & 12 deletions

.github/workflows/publish.yml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ jobs:
704704
name: ${{ needs.verify.outputs.artifact-name }}
705705
path: release-artifacts
706706
- name: Reconfirm protected state immediately before publication
707+
id: pre-publish
707708
env:
708709
CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }}
709710
CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs
@@ -890,9 +891,15 @@ jobs:
890891
version,
891892
});
892893
EOF
894+
if [[ -n "$exact_version" ]]; then
895+
echo "expect-existing=true" >> "$GITHUB_OUTPUT"
896+
else
897+
echo "expect-existing=false" >> "$GITHUB_OUTPUT"
898+
fi
893899
- name: Publish the exact artifact with provenance
894900
env:
895901
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
902+
EXPECT_EXISTING: ${{ steps.pre-publish.outputs.expect-existing }}
896903
VERSION: ${{ needs.verify.outputs.version }}
897904
run: bash scripts/publish-artifact.sh
898905
- name: Verify the public registry artifact
@@ -949,7 +956,37 @@ jobs:
949956
950957
attestations_url="$(REGISTRY_DIST="$registry_dist" node -e 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')"
951958
attestations_file="$RUNNER_TEMP/npm-attestations.json"
952-
curl --fail --silent --show-error "$attestations_url" > "$attestations_file"
959+
ATTESTATIONS_FILE="$attestations_file" \
960+
ATTESTATIONS_URL="$attestations_url" \
961+
bash scripts/fetch-attestations.sh
962+
963+
post_exact_version="$(npm view "cometapi@${VERSION}" version)"
964+
post_tagged_version="$(npm view "cometapi@${DIST_TAG}" version)"
965+
post_next_version="$(npm view cometapi@next version)"
966+
post_registry_dist="$(npm view "cometapi@${VERSION}" dist --json)"
967+
ATTESTATIONS_URL="$attestations_url" \
968+
EXACT_VERSION="$post_exact_version" \
969+
EXPECTED_INTEGRITY="$local_integrity" \
970+
NEXT_VERSION="$post_next_version" \
971+
REGISTRY_DIST="$post_registry_dist" \
972+
TAGGED_VERSION="$post_tagged_version" \
973+
node --input-type=module <<'EOF'
974+
import { pathToFileURL } from "node:url";
975+
976+
const { validatePublishedRegistryState } = await import(
977+
pathToFileURL(process.env.CONTROL_VALIDATOR)
978+
);
979+
validatePublishedRegistryState({
980+
attestationUrl: process.env.ATTESTATIONS_URL,
981+
dist: JSON.parse(process.env.REGISTRY_DIST),
982+
exactVersion: process.env.EXACT_VERSION,
983+
expectedIntegrity: process.env.EXPECTED_INTEGRITY,
984+
nextVersion: process.env.NEXT_VERSION,
985+
taggedVersion: process.env.TAGGED_VERSION,
986+
version: process.env.VERSION,
987+
});
988+
EOF
989+
registry_dist="$post_registry_dist"
953990
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]}")"
954991
provenance_identity="$(ATTESTATIONS_FILE="$attestations_file" \
955992
LOCAL_SHA512="$local_sha512" node --input-type=module <<'EOF'

AGENTS.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,19 @@ repository root.
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`.
286286
- 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.
287+
branch-context publish, a temporary `main` npm Environment policy, cross-run
288+
artifact or live-evidence reuse, an arbitrary rerun, or a different patch
289+
version.
290+
- If `npm publish` succeeds but post-publication verification fails, first read
291+
the exact public version, dist-tags, integrity, attestation URL, signature,
292+
and provenance. A transient attestation-endpoint `404` is a registry
293+
convergence condition and the workflow must retry it with a finite bound. If
294+
exact integrity matches, only `rerun failed jobs` on the same immutable-tag
295+
run may resume verification. Confirm that GitHub preserves the successful
296+
exact-artifact and live-smoke jobs, so the three-request smoke is not repeated;
297+
stop if the replay expands that job set. The replay must remain fail-closed,
298+
requires a fresh Environment approval, and must not publish again. Stop on any
299+
identity, integrity, provenance, dist-tag, or configuration mismatch.
289300
- After registry verification, immediately restore
290301
`RELEASE_PLEASE_ENABLED=false`, keep `LIVE_SMOKE_ENABLED=true`, and require the
291302
npm Environment deployment-policy set to contain only `tag:v*`.

ARCHITECTURE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ Release Please and publication remain separate trust domains. Release Please
153153
does not receive npm OIDC permission; `id-token: write` remains limited to the
154154
protected publish job. Repository variables gate both flows, and reruns remain
155155
fail-closed on exact tag, artifact, dist-tag, integrity, and provenance state.
156+
Registry package metadata and its attestation endpoint can converge at
157+
different times. Post-publication verification therefore retries attestation
158+
HTTP failures within a fixed time bound before failing, while preserving exact
159+
integrity and provenance validation. If publication succeeded before a later
160+
gate failed, only an idempotent replay of that same immutable-tag run may
161+
continue. The protected-state step records whether the exact version already
162+
exists; `publish-artifact.sh` then skips registry mutation only after its
163+
integrity matches the verified tarball and refuses a later `E404` instead of
164+
republishing. A failed-job replay preserves the same run's successful artifact
165+
and bounded live-smoke jobs rather than borrowing evidence from another run.
156166
Manual preparation rejects attempt 2 or later; restart uses a new dispatch with
157167
Release creation disabled. A `push` rerun is bounded to the same run ID, SHA,
158168
candidate, and final-head review. It may retry while the tag and Release remain

RELEASING.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,9 @@ The repository maintains four independently auditable workflows:
345345
stable `v0.1.x` tag and release commit. Replays are integrity-idempotent, not
346346
exactly-once: an existing version is accepted only when its registry integrity
347347
matches the downloaded artifact, after which the bounded registry, signature,
348-
and provenance checks run again.
348+
and provenance checks run again. Package metadata and the attestation endpoint
349+
may converge independently, so attestation HTTP failures, including an early
350+
`404`, receive a bounded ten-minute retry. Exhaustion remains a hard failure.
349351

350352
The one-time `0.1.1` main-context publication exception is historical evidence,
351353
not a reusable release route. Its dispatch inputs, fixed evidence identifiers,
@@ -560,13 +562,14 @@ or any head change, invalidates the approval.
560562
The release-PR merge creates the only `push` run that may call Release Please in
561563
release mode. From that merge through successful registry verification and the
562564
final readback, do not start a new Release Please workflow or rerun any run
563-
except the bounded source-run retry described below; publication freezes the
564-
complete Release Please run set. Through post-action validation, do not edit the
565-
release PR, change its labels or review, or mutate its branch. Keep `main` frozen
566-
at the release commit through successful registry verification and the final
567-
variable and Environment-policy readback; every handoff, tag, and pre-publish
568-
gate requires that exact identity. A rerun may retry the same release candidate
569-
only while no tag or Release exists. If an earlier attempt of that same run
565+
except the bounded source-run retry described below or the exact post-publish
566+
failed-job replay described after the handoff; publication freezes the complete
567+
Release Please run set. Through post-action validation, do not edit the release
568+
PR, change its labels or review, or mutate its branch. Keep `main` frozen at the
569+
release commit through successful registry verification and the final variable
570+
and Environment-policy readback; every handoff, tag, and pre-publish gate
571+
requires that exact identity. A Release Please rerun may retry the same release
572+
candidate only while no tag or Release exists. If an earlier attempt of that run
570573
already created the Release, same-run reconciliation is allowed only after
571574
proving the exact run ID, SHA, tag, bot author, immutable state, target, URL,
572575
notes, and publication time inside one earlier Release Please step. It may only
@@ -586,6 +589,19 @@ or live evidence, arbitrary rerun, or different patch version. An ambiguous npm
586589
result requires exact registry integrity, signature, and provenance inspection;
587590
it never authorizes an automatic retry.
588591
592+
If the exact publish step succeeded but a later registry verification step
593+
failed, query the immutable version and both dist-tags before taking any action.
594+
When the public integrity matches the verified artifact and the tag, Release,
595+
workflow, provenance, and protected configuration remain exact, rerun only the
596+
failed jobs of that same tag-bound run. Confirm before approval that GitHub kept
597+
the successful exact-artifact and live-smoke jobs instead of scheduling them
598+
again; never use rerun-all for this recovery. The protected preflight must record
599+
that the exact version exists, and the idempotent publication script must report
600+
matching integrity and skip `npm publish`. A later `E404` is a hard stop rather
601+
than permission to republish. The failed publish job still requires a fresh npm
602+
Environment approval. Stop instead of replaying on any mismatch. After
603+
successful verification, restore `RELEASE_PLEASE_ENABLED=false` immediately.
604+
589605
The `0.1.1` repair used the immutable `0.1.0` commit as a one-cycle
590606
`last-release-sha` only for its initial preparation, then removed the anchor.
591607
The failed run's exact generated `0.2.0` branch was verified as failure-only

scripts/fetch-attestations.sh

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
: "${ATTESTATIONS_FILE:?ATTESTATIONS_FILE is required}"
6+
: "${ATTESTATIONS_URL:?ATTESTATIONS_URL is required}"
7+
8+
connect_timeout="${ATTESTATION_CONNECT_TIMEOUT_SECONDS:-10}"
9+
max_time="${ATTESTATION_MAX_TIME_SECONDS:-30}"
10+
retry_count="${ATTESTATION_RETRY_COUNT:-59}"
11+
retry_delay="${ATTESTATION_RETRY_DELAY_SECONDS:-10}"
12+
retry_max_time="${ATTESTATION_RETRY_MAX_TIME_SECONDS:-600}"
13+
14+
for value in \
15+
"$connect_timeout" \
16+
"$max_time" \
17+
"$retry_count" \
18+
"$retry_delay" \
19+
"$retry_max_time"
20+
do
21+
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
22+
echo "Attestation retry settings must be non-negative integers." >&2
23+
exit 1
24+
fi
25+
done
26+
27+
candidate="${ATTESTATIONS_FILE}.download"
28+
if ! curl --fail --silent --show-error \
29+
--connect-timeout "$connect_timeout" \
30+
--max-time "$max_time" \
31+
--retry "$retry_count" \
32+
--retry-all-errors \
33+
--retry-delay "$retry_delay" \
34+
--retry-max-time "$retry_max_time" \
35+
--remove-on-error \
36+
--output "$candidate" \
37+
"$ATTESTATIONS_URL"
38+
then
39+
echo "Registry attestations did not converge." >&2
40+
exit 1
41+
fi
42+
43+
mv "$candidate" "$ATTESTATIONS_FILE"

scripts/publish-artifact.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
set -euo pipefail
44

55
: "${DIST_TAG:?DIST_TAG is required}"
6+
: "${EXPECT_EXISTING:?EXPECT_EXISTING is required}"
67
: "${VERSION:?VERSION is required}"
78

9+
if [[ "$EXPECT_EXISTING" != "true" && "$EXPECT_EXISTING" != "false" ]]; then
10+
echo "EXPECT_EXISTING must be true or false." >&2
11+
exit 1
12+
fi
13+
814
artifact_directory="${ARTIFACT_DIRECTORY:-release-artifacts}"
915

1016
if [[ -n "${NPM_TOKEN:-}" ]] || [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then
@@ -37,6 +43,9 @@ if (dist.integrity !== process.env.LOCAL_INTEGRITY) {
3743
}
3844
EOF
3945
echo "cometapi@${VERSION} already matches the verified artifact; resuming checks."
46+
elif grep -q "E404" "$view_error" && [[ "$EXPECT_EXISTING" == "true" ]]; then
47+
echo "The pre-publish check found this version, but the registry now returns E404; refusing to publish again." >&2
48+
exit 1
4049
elif grep -q "E404" "$view_error"; then
4150
npm publish "${tarballs[0]}" --access public --provenance --tag "$DIST_TAG"
4251
else

scripts/release-workflow-validation.mjs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const RELEASE_WORKFLOW_STEP = "Run Release Please";
1616
const PUBLISH_OPERATION = "release";
1717
const NPM_TAG_POLICY_ID = 55718965;
1818
const PUBLISH_WORKFLOW_CONTRACT_SHA256 =
19-
"43f70219c4b8deed5a68a7a369821cc18f891119b373134fda8ca46fc7080e24";
19+
"088366a4c0fdb060b330249f4f8db72c97b10925f7f737e47886dffce4dee9be";
2020
const PUBLISH_HANDOFF_IF =
2121
"vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'";
2222
const PUBLISH_RESULT_IF = "steps.result.outputs.has-result == 'true'";
@@ -504,6 +504,11 @@ export function validatePublishWorkflowContract(workflow) {
504504
"Reconfirm protected state immediately before publication",
505505
"Publish protected-state reconfirmation",
506506
);
507+
requireEqual(
508+
reconfirmStep?.id,
509+
"pre-publish",
510+
"Publish protected-state output identity",
511+
);
507512
requireEqual(
508513
reconfirmStep?.env?.RELEASE_PLEASE_SNAPSHOT,
509514
"${{ needs.verify.outputs.release-please-snapshot }}",
@@ -546,6 +551,23 @@ export function validatePublishWorkflowContract(workflow) {
546551
"${{ github.token }}",
547552
"Publish registry verification token",
548553
);
554+
for (const fragment of [
555+
"bash scripts/fetch-attestations.sh",
556+
'post_exact_version="$(npm view',
557+
'post_tagged_version="$(npm view',
558+
'post_next_version="$(npm view cometapi@next version)"',
559+
'post_registry_dist="$(npm view',
560+
"validatePublishedRegistryState",
561+
]) {
562+
if (
563+
typeof registryStep?.run !== "string" ||
564+
!registryStep.run.includes(fragment)
565+
) {
566+
fail(
567+
`Release workflow public-registry verification must contain ${fragment}.`,
568+
);
569+
}
570+
}
549571
const publishSteps = jobs.publish?.steps ?? [];
550572
if (
551573
publishSteps.indexOf(reconfirmStep) >= publishSteps.indexOf(publishStep) ||
@@ -560,6 +582,11 @@ export function validatePublishWorkflowContract(workflow) {
560582
"bash scripts/publish-artifact.sh",
561583
"npm publication command",
562584
);
585+
requireEqual(
586+
publishStep?.env?.EXPECT_EXISTING,
587+
"${{ steps.pre-publish.outputs.expect-existing }}",
588+
"npm publication replay expectation",
589+
);
563590
requireEqual(
564591
createHash("sha256").update(JSON.stringify(workflow)).digest("hex"),
565592
PUBLISH_WORKFLOW_CONTRACT_SHA256,
@@ -865,6 +892,62 @@ export function validateRegistryStateBeforePublish({
865892
return { exactVersion, latestVersion, nextVersion, previousVersion, version };
866893
}
867894

895+
export function validatePublishedRegistryState({
896+
attestationUrl,
897+
dist,
898+
exactVersion,
899+
expectedIntegrity,
900+
nextVersion,
901+
taggedVersion,
902+
version,
903+
}) {
904+
validateRegistryStateBeforePublish({
905+
exactVersion,
906+
latestVersion: taggedVersion,
907+
nextVersion,
908+
version,
909+
});
910+
requireEqual(exactVersion, version, "published registry exact version");
911+
requireEqual(taggedVersion, version, "published registry dist-tag");
912+
requireEqual(
913+
attestationUrl,
914+
`https://registry.npmjs.org/-/npm/v1/attestations/cometapi@${version}`,
915+
"published registry attestation URL identity",
916+
);
917+
if (dist === null || typeof dist !== "object" || Array.isArray(dist)) {
918+
fail("Release workflow published registry dist must be an object.");
919+
}
920+
if (
921+
typeof expectedIntegrity !== "string" ||
922+
!expectedIntegrity.startsWith("sha512-")
923+
) {
924+
fail("Release workflow expected registry integrity must use sha512.");
925+
}
926+
requireEqual(
927+
dist.integrity,
928+
expectedIntegrity,
929+
"published registry integrity",
930+
);
931+
requireEqual(
932+
dist.attestations?.url,
933+
attestationUrl,
934+
"published registry attestation URL",
935+
);
936+
requireEqual(
937+
dist.attestations?.provenance?.predicateType,
938+
"https://slsa.dev/provenance/v1",
939+
"published registry provenance predicate",
940+
);
941+
return {
942+
attestationUrl,
943+
exactVersion,
944+
integrity: dist.integrity,
945+
nextVersion,
946+
taggedVersion,
947+
version,
948+
};
949+
}
950+
868951
export function validateRegistryProvenance({
869952
attestations,
870953
commit,

0 commit comments

Comments
 (0)