diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f8639c1..18d4faa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -273,6 +273,10 @@ jobs: echo "::error::Central deployment IDs must be UUIDs" exit 1 fi + if [[ -n "$DEPLOYMENT_ID" && "$DEPLOYMENT_ID" != "${DEPLOYMENT_ID,,}" ]]; then + echo "::error::Central deployment IDs must use canonical lowercase UUIDs" + exit 1 + fi done if [[ -n "$RESUME_DEPLOYMENT_ID" ]]; then [[ "$RESUME_COMMIT_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { @@ -302,12 +306,15 @@ jobs: if git rev-parse --verify --quiet "refs/tags/v${VERSION}" >/dev/null; then echo "tag_exists=true" >> "$GITHUB_OUTPUT" TAG_EXISTS=true + TAG_OBJECT=$(git rev-parse "refs/tags/v${VERSION}") TAG_COMMIT=$(git rev-list -n 1 "v${VERSION}") else echo "tag_exists=false" >> "$GITHUB_OUTPUT" TAG_EXISTS=false + TAG_OBJECT="" TAG_COMMIT="" fi + echo "tag_object=$TAG_OBJECT" >> "$GITHUB_OUTPUT" TAG_NEEDS_REPAIR=false UPLOAD_REQUIRED=false @@ -342,6 +349,10 @@ jobs: echo "::error::The resumed release commit is not contained in origin/main." exit 1 } + git merge-base --is-ancestor "$RELEASE_COMMIT" "$WORKFLOW_COMMIT" || { + echo "::error::The current polling workflow must descend from the resumed release commit." + exit 1 + } if [[ "$TAG_EXISTS" == "true" ]]; then git merge-base --is-ancestor "v${VERSION}" "$RELEASE_COMMIT" || { echo "::error::Existing tag v${VERSION} is not an ancestor of the repair commit." @@ -479,13 +490,12 @@ jobs: echo "::error::Central recovery status request failed before receiving a definitive HTTP response" exit 1 fi - echo "claim_retry_deployment=false" >> "$GITHUB_OUTPUT" - if [[ "$HTTP_CODE" == "200" ]]; then STATUS_JSON=$(<"$STATUS_FILE") export STATUS_JSON python3 <<'PY' import json + import hashlib import os import sys @@ -494,7 +504,13 @@ jobs: retry_id = os.environ.get("RETRY_FAILED_DEPLOYMENT_ID", "") expected_name = os.environ["EXPECTED_DEPLOYMENT_NAME"] state = data.get("deploymentState") - purls = data.get("purls") or [] + raw_purls = data.get("purls") + if raw_purls is None: + purls = [] + elif isinstance(raw_purls, list) and all(type(purl) is str for purl in raw_purls): + purls = raw_purls + else: + sys.exit("Central status purls must be an array containing only strings") with open("build/central-expected-purls.txt", encoding="utf-8") as source: expected_purls = {line.strip() for line in source if line.strip()} with open("build/central-expected-reported-purls.txt", encoding="utf-8") as source: @@ -515,6 +531,15 @@ jobs: sys.exit("deployment contains duplicate component PURLs") if len(expected_purls) != 75 or len(expected_reported_purls) != 111: sys.exit("expected release manifests must contain exactly 75 coordinates and 111 reported PURLs") + known_deployment_id = "7a3ef832-4930-42bb-a102-e64ba6ee5bb7" + if deployment_id == known_deployment_id: + if os.environ["RELEASE_COMMIT"] != "4e867905ca316e13b89e1cf2cd707afb99a1c10e": + sys.exit("the published 0.6.0.0 deployment is bound to an unexpected release commit") + manifest = lambda values: hashlib.sha256(("\n".join(sorted(values)) + "\n").encode()).hexdigest() + if manifest(expected_purls) != "b2534c321438153e16a8a4b19c4acbacae071720e76b754e4b7ab95610cd7960": + sys.exit("the published 0.6.0.0 coordinate manifest changed after upload") + if manifest(expected_reported_purls) != "23593a776b051febe452adc0edffd0fbc5a90730242e6e4dd346641d0f0a6143": + sys.exit("the published 0.6.0.0 status manifest changed after upload") actual_purls = set(purls) unexpected_purls = actual_purls - expected_reported_purls if unexpected_purls: @@ -611,23 +636,26 @@ jobs: echo "::error::Expected to prove 75 coordinates unpublished, checked ${CHECKED_PURLS}" exit 1 } - echo "claim_retry_deployment=true" >> "$GITHUB_OUTPUT" fi - - name: Claim the one-time failed-deployment retry - if: steps.central_recovery.outputs.claim_retry_deployment == 'true' + - name: Claim the one-time Central upload + if: steps.version.outputs.upload_required == 'true' shell: bash env: RELEASE_VERSION: ${{ steps.version.outputs.version }} RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} RETRY_FAILED_DEPLOYMENT_ID: ${{ inputs.retry_failed_deployment_id || '' }} run: | - CLAIM_TAG="central-retry-v${RELEASE_VERSION}-${RETRY_FAILED_DEPLOYMENT_ID}" + if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then + CLAIM_TAG="central-retry-v${RELEASE_VERSION}-${RETRY_FAILED_DEPLOYMENT_ID}" + else + CLAIM_TAG="central-upload-v${RELEASE_VERSION}" + fi CLAIM_REF="refs/tags/${CLAIM_TAG}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag --annotate "$CLAIM_TAG" "$RELEASE_COMMIT" \ - --message "Aether Central retry claim from run ${GITHUB_RUN_ID}, attempt ${GITHUB_RUN_ATTEMPT}" + --message "Aether Central upload claim from run ${GITHUB_RUN_ID}, attempt ${GITHUB_RUN_ATTEMPT}" git push --force-with-lease="${CLAIM_REF}:" origin "$CLAIM_REF" echo "Claimed ${CLAIM_REF}; any interrupted run must resume the newly accepted deployment instead of uploading again." @@ -715,12 +743,23 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" RELEASE_COMMIT="${{ steps.version.outputs.release_commit }}" + TAG_EXISTS="${{ steps.version.outputs.tag_exists }}" + EXPECTED_TAG_OBJECT="${{ steps.version.outputs.tag_object }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + REMOTE_TAG_OBJECT=$(git ls-remote origin "refs/tags/v${VERSION}" | awk 'NR == 1 { print $1 }') + if [[ "$TAG_EXISTS" == "true" ]]; then + [[ -n "$EXPECTED_TAG_OBJECT" && "$REMOTE_TAG_OBJECT" == "$EXPECTED_TAG_OBJECT" ]] || { + echo "::error::The remote release tag changed while publication was in progress" + exit 1 + } + elif [[ -n "$REMOTE_TAG_OBJECT" ]]; then + echo "::error::The remote release tag appeared while publication was in progress" + exit 1 + fi if [[ "${{ steps.version.outputs.tag_needs_repair }}" == "true" ]]; then - OLD_TAG_OBJECT=$(git rev-parse "refs/tags/v${VERSION}") git tag --force --annotate "v${VERSION}" "$RELEASE_COMMIT" --message "Aether ${VERSION}" - git push --force-with-lease="refs/tags/v${VERSION}:${OLD_TAG_OBJECT}" origin "refs/tags/v${VERSION}" + git push --force-with-lease="refs/tags/v${VERSION}:${EXPECTED_TAG_OBJECT}" origin "refs/tags/v${VERSION}" elif git rev-parse "v${VERSION}" >/dev/null 2>&1; then [[ "$(git rev-list -n 1 "v${VERSION}")" == "$RELEASE_COMMIT" ]] || { echo "::error::Existing release tag does not match the published commit" diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc8cc0..8026ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,10 +32,10 @@ deployment to reach `PUBLISHED` before completing the release. Upload IDs are recorded before polling so interrupted releases resume the exact commit without submitting a duplicate bundle. The reviewed `0.6.0.0` failed-deployment recovery also fails closed when the old status endpoint - returns 404 and checks every coordinate is still unpublished. Every failed-deployment retry creates - a single-use claim before upload. Release completion requires the exact Central-reported component - manifest, including its KLIB and Gradle plugin marker PURL type variants, and can run current - polling fixes while keeping a resumed deployment bound to its original upload commit. + returns 404 and checks every coordinate is still unpublished. Every Central upload first creates a + single-use version/deployment claim. Release completion requires the exact Central-reported + component manifest, including its KLIB and Gradle plugin marker PURL type variants, and can run + current polling fixes while keeping a resumed deployment bound to its original upload commit. ### Known limitations diff --git a/build.gradle.kts b/build.gradle.kts index 62a9416..a49c37c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -649,7 +649,16 @@ val waitForCentralPortalPublication by tasks.registering { val returnedName = status["deploymentName"] as? String val state = status["deploymentState"] as? String ?: throw GradleException("Central status has no deploymentState for $deploymentId") - val purlList = (status["purls"] as? List<*>)?.filterIsInstance().orEmpty() + val rawPurls = status["purls"] + val purlValues = when (rawPurls) { + null -> emptyList() + is List<*> -> rawPurls + else -> throw GradleException("Central status purls must be an array") + } + if (purlValues.any { it !is String }) { + throw GradleException("Central status purls must contain only strings") + } + val purlList = purlValues.filterIsInstance() val purls = purlList.toSet() if (returnedId != deploymentId) throw GradleException("Central status returned a different deployment ID") if (returnedName != expectedDeploymentName) { diff --git a/docs/identity/deployment.md b/docs/identity/deployment.md index 8dd778d..fb5df89 100644 --- a/docs/identity/deployment.md +++ b/docs/identity/deployment.md @@ -354,12 +354,13 @@ Resume executes the current reviewed polling workflow while keeping the deployme bound to that original upload commit. Never rerun an ambiguous upload. Recovery requires Central's complete 111-PURL report—75 base coordinates, 35 `type=klib` variants, and the plugin marker's `type=pom` variant—before it can repair a tag or update the GitHub release. -Every failed-deployment retry atomically creates a permanent claim tag immediately before its -replacement upload, making that deployment ID single-use. If Central no longer exposes a reviewed -failed deployment, HTTP 404 alone is not proof that the deployment failed. The `0.6.0.0` exception -is therefore also bound to the recorded deployment, original tag object and commit, reviewed repair -baseline, explicit operator acknowledgement, and a fresh canonical Maven lookup proving that all 75 -coordinates remain unpublished. After an interruption, never submit the old failed-deployment ID +Every Central upload atomically creates a permanent version/deployment claim tag immediately before +the network call, making a new-version upload or failed-deployment retry single-use. If Central no +longer exposes a reviewed failed deployment, HTTP 404 alone is not proof that the deployment failed. +The `0.6.0.0` exception is therefore also bound to the recorded deployment, original tag object and +commit, reviewed repair baseline, explicit operator acknowledgement, and a fresh canonical Maven +lookup proving that all 75 coordinates remain unpublished. After an interruption, never submit the +old failed-deployment ID again: resume the exact replacement ID if Central accepted one, or stop for manual review if no accepted ID can be proven. Do not remove a claim without definitive evidence that no upload began. Automated verification includes JVM, wasmJs and wasmWasi guest protocol/crypto tests, the native diff --git a/e2e-tests/tests/config/scaffold.test.mjs b/e2e-tests/tests/config/scaffold.test.mjs index 9d9a6a5..4c9f2e5 100644 --- a/e2e-tests/tests/config/scaffold.test.mjs +++ b/e2e-tests/tests/config/scaffold.test.mjs @@ -101,6 +101,7 @@ test('successful main verification publishes automatically and only once per ver assert.match(workflow, /acknowledge_unavailable_failed_deployment is valid only with retry_failed_deployment_id/); assert.match(workflow, /Manual publication is recovery-only; provide a failed retry ID or an exact resume ID/); assert.match(workflow, /resume_commit_sha must be the exact 40-character upload commit/); + assert.match(workflow, /Central deployment IDs must use canonical lowercase UUIDs/); assert.equal((workflow.match(/ref: \$\{\{ github\.sha \}\}/g) ?? []).length, 2); assert.doesNotMatch(workflow, /ref: \$\{\{ inputs\.resume_commit_sha/); assert.match(workflow, /RELEASE_COMMIT=\$\(git rev-parse "\$\{RESUME_COMMIT_SHA\}\^\{commit\}"\)/); @@ -127,7 +128,7 @@ test('successful main verification publishes automatically and only once per ver workflow, /- name: Create or repair the release tag\n\s+if: steps\.version\.outputs\.publish_required == 'true'/ ); - assert.match(workflow, /--force-with-lease="refs\/tags\/v\$\{VERSION\}:\$\{OLD_TAG_OBJECT\}"/); + assert.match(workflow, /--force-with-lease="refs\/tags\/v\$\{VERSION\}:\$\{EXPECTED_TAG_OBJECT\}"/); assert.match(workflow, /- name: Extract latest changelog entry\n\s+id: changelog/); assert.match( workflow, @@ -135,7 +136,7 @@ test('successful main verification publishes automatically and only once per ver ); const validateIndex = workflow.indexOf('- name: Validate Central recovery request'); - const claimIndex = workflow.indexOf('- name: Claim the one-time failed-deployment retry'); + const claimIndex = workflow.indexOf('- name: Claim the one-time Central upload'); const uploadIndex = workflow.indexOf('- name: Upload one Aether bundle to Maven Central'); const waitIndex = workflow.indexOf('- name: Wait for Maven Central publication'); const tagIndex = workflow.indexOf('- name: Create or repair the release tag'); @@ -181,6 +182,7 @@ test('Maven Central publication requires real sources and waits for PUBLISHED', assert.match(rootBuild, /klibArtifactIds\.size == 35/); assert.match(rootBuild, /reportedPurls\.size == 111/); assert.match(rootBuild, /unexpectedPurls = purls - expectedPurls/); + assert.match(rootBuild, /Central status purls must contain only strings/); assert.equal((rootBuild.match(/inputs\.property\("expectedPurls", expectedPurls\)/g) ?? []).length, 2); assert.match(rootBuild, /reportedPurls\.containsAll\(basePurls\)/); assert.match(rootBuild, /purlList\.size != purls\.size/); @@ -197,8 +199,10 @@ test('Maven Central publication requires real sources and waits for PUBLISHED', assert.match(workflow, /KNOWN_OLD_TAG_OBJECT="dc46b140797264f8bcd6378df3c00dbd42e7421f"/); assert.match(workflow, /REVIEWED_REPAIR_BASELINE="582adbe30a4791f59547abff2c5e9ed9c8b0fd7e"/); assert.match(workflow, /An unavailable deployment cannot be resumed/); - assert.match(workflow, /claim_retry_deployment=true/); assert.match(workflow, /--force-with-lease="\$\{CLAIM_REF\}:"/); + assert.match(workflow, /Central status purls must be an array containing only strings/); + assert.match(workflow, /b2534c321438153e16a8a4b19c4acbacae071720e76b754e4b7ab95610cd7960/); + assert.match(workflow, /23593a776b051febe452adc0edffd0fbc5a90730242e6e4dd346641d0f0a6143/); assert.match(workflow, /data\.get\("deploymentName"\) != expected_name/); assert.match(workflow, /actual_purls != expected_reported_purls/); assert.match(workflow, /writeExpectedCentralPurls writeExpectedCentralReportedPurls/); @@ -210,14 +214,15 @@ test('Maven Central publication requires real sources and waits for PUBLISHED', assert.match(workflow, /is already public and cannot be replaced/); assert.match(workflow, /git merge-base --is-ancestor "v\$\{VERSION\}" "\$RELEASE_COMMIT"/); assert.match(workflow, /git show "\$\{RELEASE_COMMIT\}:CHANGELOG\.md"/); + assert.match(workflow, /The current polling workflow must descend from the resumed release commit/); + assert.match(workflow, /The remote release tag changed while publication was in progress/); const recoveryValidationStep = workflow.match( - /- name: Validate Central recovery request[\s\S]*?(?=\n\s+- name: Claim the one-time failed-deployment retry)/ + /- name: Validate Central recovery request[\s\S]*?(?=\n\s+- name: Claim the one-time Central upload)/ )?.[0] ?? ''; const recoveryClaimStep = workflow.match( - /- name: Claim the one-time failed-deployment retry[\s\S]*?(?=\n\s+- name: Upload one Aether bundle to Maven Central)/ + /- name: Claim the one-time Central upload[\s\S]*?(?=\n\s+- name: Upload one Aether bundle to Maven Central)/ )?.[0] ?? ''; - assert.match(recoveryValidationStep, /claim_retry_deployment=false/); assert.match( recoveryValidationStep, /ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT" == "true"[\s\S]*?DEPLOYMENT_ID" == "\$KNOWN_DEPLOYMENT_ID" && "\$RELEASE_VERSION" == "\$KNOWN_VERSION"/ @@ -240,15 +245,15 @@ test('Maven Central publication requires real sources and waits for PUBLISHED', ); assert.match( recoveryValidationStep, - /if \[\[ -n "\$RETRY_FAILED_DEPLOYMENT_ID" \]\]; then[\s\S]*?done < build\/central-expected-purls\.txt[\s\S]*?CHECKED_PURLS" == "75"[\s\S]*?claim_retry_deployment=true/ + /if \[\[ -n "\$RETRY_FAILED_DEPLOYMENT_ID" \]\]; then[\s\S]*?done < build\/central-expected-purls\.txt[\s\S]*?CHECKED_PURLS" == "75"/ ); assert.match( recoveryClaimStep, - /if: steps\.central_recovery\.outputs\.claim_retry_deployment == 'true'/ + /if: steps\.version\.outputs\.upload_required == 'true'/ ); assert.match( recoveryClaimStep, - /CLAIM_TAG="central-retry-v\$\{RELEASE_VERSION\}-\$\{RETRY_FAILED_DEPLOYMENT_ID\}"[\s\S]*?git tag --annotate "\$CLAIM_TAG" "\$RELEASE_COMMIT"[\s\S]*?GITHUB_RUN_ID[\s\S]*?GITHUB_RUN_ATTEMPT[\s\S]*?--force-with-lease="\$\{CLAIM_REF\}:"/ + /central-retry-v\$\{RELEASE_VERSION\}-\$\{RETRY_FAILED_DEPLOYMENT_ID\}[\s\S]*?central-upload-v\$\{RELEASE_VERSION\}[\s\S]*?git tag --annotate "\$CLAIM_TAG" "\$RELEASE_COMMIT"[\s\S]*?GITHUB_RUN_ID[\s\S]*?GITHUB_RUN_ATTEMPT[\s\S]*?--force-with-lease="\$\{CLAIM_REF\}:"/ ); const uploadStep = workflow.match(