diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b555528..ba97800 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,11 @@ on: required: false default: false type: boolean + acknowledge_unavailable_failed_deployment: + description: Acknowledge the reviewed one-time recovery when Central no longer exposes the failed deployment + required: false + default: false + type: boolean permissions: contents: read @@ -239,6 +244,7 @@ jobs: RESUME_DEPLOYMENT_ID: ${{ inputs.resume_deployment_id || '' }} RESUME_COMMIT_SHA: ${{ inputs.resume_commit_sha || '' }} REPAIR_RELEASE_TAG: ${{ inputs.repair_release_tag || false }} + ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT: ${{ inputs.acknowledge_unavailable_failed_deployment || false }} run: | VERSION=$(grep "^VERSION=" version.properties | cut -d'=' -f2) [[ -n "$VERSION" ]] || { echo "::error::version.properties has no VERSION"; exit 1; } @@ -256,6 +262,10 @@ jobs: echo "::error::retry_failed_deployment_id and resume_deployment_id are mutually exclusive" exit 1 fi + if [[ "$ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT" == "true" && -z "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then + echo "::error::acknowledge_unavailable_failed_deployment is valid only with retry_failed_deployment_id" + exit 1 + fi if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -z "$RETRY_FAILED_DEPLOYMENT_ID" && -z "$RESUME_DEPLOYMENT_ID" ]]; then echo "::error::Manual publication is recovery-only; provide a failed retry ID or an exact resume ID." exit 1 @@ -293,6 +303,11 @@ jobs: UPLOAD_REQUIRED=false if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then + git fetch --no-tags origin main + git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main || { + echo "::error::The failed-deployment retry commit is not contained in origin/main." + exit 1 + } [[ "$TAG_EXISTS" == "true" ]] || { echo "::error::A failed-deployment retry is only allowed for an existing release tag." exit 1 @@ -420,6 +435,7 @@ jobs: rm -rf "$TMP_GNUPGHOME" - name: Validate Central recovery request + id: central_recovery if: >- steps.version.outputs.publish_required == 'true' && (inputs.retry_failed_deployment_id != '' || inputs.resume_deployment_id != '') @@ -431,25 +447,40 @@ jobs: EXPECTED_DEPLOYMENT_NAME: ${{ steps.version.outputs.deployment_name }} RETRY_FAILED_DEPLOYMENT_ID: ${{ inputs.retry_failed_deployment_id || '' }} RESUME_DEPLOYMENT_ID: ${{ inputs.resume_deployment_id || '' }} + RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} + TAG_NEEDS_REPAIR: ${{ steps.version.outputs.tag_needs_repair }} + REPAIR_RELEASE_TAG: ${{ inputs.repair_release_tag || false }} + ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT: ${{ inputs.acknowledge_unavailable_failed_deployment || false }} run: | ./gradlew writeExpectedCentralPurls --no-daemon --quiet DEPLOYMENT_ID="${RETRY_FAILED_DEPLOYMENT_ID:-$RESUME_DEPLOYMENT_ID}" export DEPLOYMENT_ID AUTHORIZATION="Bearer $(printf '%s' "$CENTRAL_USERNAME:$CENTRAL_PASSWORD" | base64 | tr -d '\n\r')" - STATUS_JSON=$(curl --request POST \ + STATUS_FILE="$RUNNER_TEMP/central-recovery-status.json" + set +e + HTTP_CODE=$(curl --request POST \ --url "https://central.sonatype.com/api/v1/publisher/status?id=${DEPLOYMENT_ID}" \ --header "Authorization: ${AUTHORIZATION}" \ --connect-timeout 10 --max-time 30 \ - --fail-with-body --silent --show-error) - export STATUS_JSON + --output "$STATUS_FILE" --write-out '%{http_code}' \ + --silent --show-error) + CURL_EXIT=$? + set -e + if [[ "$CURL_EXIT" != "0" ]]; then + echo "::error::Central recovery status request failed before receiving a definitive HTTP response" + exit 1 + fi + echo "claim_retry_deployment=false" >> "$GITHUB_OUTPUT" - python3 <<'PY' + if [[ "$HTTP_CODE" == "200" ]]; then + STATUS_JSON=$(<"$STATUS_FILE") + export STATUS_JSON + python3 <<'PY' import json import os import sys data = json.loads(os.environ["STATUS_JSON"]) - version = os.environ["RELEASE_VERSION"] deployment_id = os.environ["DEPLOYMENT_ID"] retry_id = os.environ.get("RETRY_FAILED_DEPLOYMENT_ID", "") expected_name = os.environ["EXPECTED_DEPLOYMENT_NAME"] @@ -479,18 +510,111 @@ jobs: sys.exit(f"deployment component mismatch; missing={missing}, unexpected={unexpected}") print(f"Central recovery request verified: {data.get('deploymentId')} is {state}") PY + elif [[ "$HTTP_CODE" == "404" ]]; then + KNOWN_DEPLOYMENT_ID="e4df03ff-971d-4b12-b5cb-da68bbefa81a" + KNOWN_VERSION="0.6.0.0" + KNOWN_OLD_TAG_OBJECT="dc46b140797264f8bcd6378df3c00dbd42e7421f" + KNOWN_OLD_TAG_COMMIT="f55c5a2c14dc444ffe09d0c09a857ea8421dd7ad" + REVIEWED_REPAIR_BASELINE="582adbe30a4791f59547abff2c5e9ed9c8b0fd7e" + + [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]] || { + echo "::error::An unavailable deployment cannot be resumed" + exit 1 + } + [[ "$ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT" == "true" ]] || { + echo "::error::The reviewed unavailable-deployment recovery requires explicit acknowledgement" + exit 1 + } + [[ "$DEPLOYMENT_ID" == "$KNOWN_DEPLOYMENT_ID" && "$RELEASE_VERSION" == "$KNOWN_VERSION" ]] || { + echo "::error::HTTP 404 is accepted only for the reviewed 0.6.0.0 failed deployment" + exit 1 + } + [[ "$TAG_NEEDS_REPAIR" == "true" && "$REPAIR_RELEASE_TAG" == "true" ]] || { + echo "::error::The reviewed unavailable-deployment recovery must repair the unpublished release tag" + exit 1 + } + [[ "$(git rev-parse "refs/tags/v${RELEASE_VERSION}")" == "$KNOWN_OLD_TAG_OBJECT" ]] || { + echo "::error::The release tag is no longer the reviewed unpublished tag object" + exit 1 + } + [[ "$(git rev-list -n 1 "v${RELEASE_VERSION}")" == "$KNOWN_OLD_TAG_COMMIT" ]] || { + echo "::error::The release tag no longer targets the reviewed unpublished commit" + exit 1 + } + git merge-base --is-ancestor "$REVIEWED_REPAIR_BASELINE" "$RELEASE_COMMIT" || { + echo "::error::The unavailable-deployment recovery does not contain the reviewed source-archive repair" + exit 1 + } + UNEXPECTED_POST_REVIEW_FILES=() + while IFS= read -r FILE; do + case "$FILE" in + .github/workflows/publish.yml|CHANGELOG.md|docs/identity/deployment.md|e2e-tests/tests/config/scaffold.test.mjs) + ;; + *) + UNEXPECTED_POST_REVIEW_FILES+=("$FILE") + ;; + esac + done < <(git diff --name-only "${REVIEWED_REPAIR_BASELINE}"..."${RELEASE_COMMIT}") + if (( ${#UNEXPECTED_POST_REVIEW_FILES[@]} > 0 )); then + echo "::error::Unreviewed files changed after the source-archive repair: ${UNEXPECTED_POST_REVIEW_FILES[*]}" + exit 1 + fi + echo "::notice::Central no longer exposes the reviewed failed deployment; authorizing one claimed replacement upload" + else + echo "::error::Central recovery status returned unexpected HTTP ${HTTP_CODE}" + exit 1 + fi if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then - HTTP_CODE=$(curl --head --silent --output /dev/null --write-out '%{http_code}' \ - --connect-timeout 10 --max-time 30 \ - "https://repo1.maven.org/maven2/codes/yousef/aether/aether-core/${RELEASE_VERSION}/aether-core-${RELEASE_VERSION}.pom") - case "$HTTP_CODE" in - 404) ;; - 200) echo "::error::Version ${RELEASE_VERSION} is already public and cannot be replaced"; exit 1 ;; - *) echo "::error::Could not prove version ${RELEASE_VERSION} is unpublished (HTTP ${HTTP_CODE})"; exit 1 ;; - esac + CHECKED_PURLS=0 + while IFS= read -r PURL; do + [[ -n "$PURL" ]] || continue + COORDINATE="${PURL#pkg:maven/}" + PURL_VERSION="${COORDINATE##*@}" + GROUP_AND_ARTIFACT="${COORDINATE%@*}" + GROUP_ID="${GROUP_AND_ARTIFACT%/*}" + ARTIFACT_ID="${GROUP_AND_ARTIFACT##*/}" + [[ "$PURL_VERSION" == "$RELEASE_VERSION" && "$GROUP_ID" != "$GROUP_AND_ARTIFACT" ]] || { + echo "::error::Malformed expected release PURL: ${PURL}" + exit 1 + } + GROUP_PATH="${GROUP_ID//./\/}" + PUBLIC_POM="https://repo.maven.apache.org/maven2/${GROUP_PATH}/${ARTIFACT_ID}/${PURL_VERSION}/${ARTIFACT_ID}-${PURL_VERSION}.pom" + PUBLIC_HTTP_CODE=$(curl --head --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header 'Cache-Control: no-cache' \ + --connect-timeout 10 --max-time 30 \ + "${PUBLIC_POM}?aether_recovery=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}") + case "$PUBLIC_HTTP_CODE" in + 404) ;; + 200) echo "::error::${PURL} is already public and cannot be replaced"; exit 1 ;; + *) echo "::error::Could not prove ${PURL} is unpublished (HTTP ${PUBLIC_HTTP_CODE})"; exit 1 ;; + esac + CHECKED_PURLS=$((CHECKED_PURLS + 1)) + done < build/central-expected-purls.txt + [[ "$CHECKED_PURLS" == "75" ]] || { + 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' + 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}" + 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}" + 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." + - name: Upload one Aether bundle to Maven Central id: central_upload if: steps.version.outputs.upload_required == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bce2d9..382c2b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ binary component's sources and Javadoc companions before upload, and waits for the Central 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. ### Known limitations diff --git a/docs/identity/deployment.md b/docs/identity/deployment.md index c13b21c..73aae81 100644 --- a/docs/identity/deployment.md +++ b/docs/identity/deployment.md @@ -352,6 +352,14 @@ separate steps: the accepted deployment UUID is recorded immediately and an inte is resumed by that UUID plus the exact commit encoded in its deterministic deployment name. Never rerun an ambiguous upload. Recovery compares Central's complete component set with the 75-coordinate release manifest 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 +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 OpenSSL host-library tests, PostgreSQL 16 and Firestore conformance/race suites, Summon browser tests, federation adversarial suites, and the complete example build. Production wasmWasi diff --git a/e2e-tests/tests/config/scaffold.test.mjs b/e2e-tests/tests/config/scaffold.test.mjs index ecd7f53..2249849 100644 --- a/e2e-tests/tests/config/scaffold.test.mjs +++ b/e2e-tests/tests/config/scaffold.test.mjs @@ -81,6 +81,7 @@ test('successful main verification publishes automatically and only once per ver assert.match(workflow, /resume_deployment_id:/); assert.match(workflow, /resume_commit_sha:/); assert.match(workflow, /repair_release_tag:/); + assert.match(workflow, /acknowledge_unavailable_failed_deployment:/); assert.doesNotMatch(workflow, /hardware_passkey_smoke_|adversarial_review_/); assert.match( workflow, @@ -97,8 +98,10 @@ test('successful main verification publishes automatically and only once per ver ); assert.match(workflow, /CHANGELOG_VERSION[\s\S]*?does not match version\.properties/); assert.match(workflow, /retry_failed_deployment_id and resume_deployment_id are mutually exclusive/); + 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, /The failed-deployment retry commit is not contained in origin\/main/); assert.match(workflow, /A rerun may duplicate an accepted Central upload/); assert.match(workflow, /Same-version recovery is restricted to reviewed publication metadata/); assert.match(workflow, /git diff --name-only "v\$\{VERSION\}"\.\.\.HEAD/); @@ -123,10 +126,13 @@ 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 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'); - assert.ok(validateIndex < uploadIndex && uploadIndex < waitIndex && waitIndex < tagIndex); + assert.ok( + validateIndex < claimIndex && claimIndex < uploadIndex && uploadIndex < waitIndex && waitIndex < tagIndex + ); }); test('Maven Central publication requires real sources and waits for PUBLISHED', async () => { @@ -162,10 +168,61 @@ test('Maven Central publication requires real sources and waits for PUBLISHED', assert.doesNotMatch(signScript, /PASSPHRASE="\$1"|--passphrase "\$PASSPHRASE"/); assert.match(workflow, /verifyExpectedSourceTasks verifyCentralPublicationArtifacts check/); assert.match(workflow, /retry deployment must be FAILED/); + assert.match(workflow, /HTTP_CODE" == "200"/); + assert.match(workflow, /HTTP_CODE" == "404"/); + assert.match(workflow, /e4df03ff-971d-4b12-b5cb-da68bbefa81a/); + assert.match(workflow, /KNOWN_VERSION="0\.6\.0\.0"/); + 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, /data\.get\("deploymentName"\) != expected_name/); assert.match(workflow, /actual_purls != expected_purls/); assert.match(workflow, /state == "PUBLISHED" or bool\(actual_purls\)/); - assert.match(workflow, /Version \$\{RELEASE_VERSION\} is already public and cannot be replaced/); + assert.match(workflow, /repo\.maven\.apache\.org\/maven2/); + assert.match(workflow, /Expected to prove 75 coordinates unpublished/); + assert.match(workflow, /is already public and cannot be replaced/); + + const recoveryValidationStep = workflow.match( + /- name: Validate Central recovery request[\s\S]*?(?=\n\s+- name: Claim the one-time failed-deployment retry)/ + )?.[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)/ + )?.[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"/ + ); + assert.match( + recoveryValidationStep, + /TAG_NEEDS_REPAIR" == "true" && "\$REPAIR_RELEASE_TAG" == "true"/ + ); + assert.match( + recoveryValidationStep, + /KNOWN_OLD_TAG_COMMIT="f55c5a2c14dc444ffe09d0c09a857ea8421dd7ad"[\s\S]*?git rev-list -n 1[\s\S]*?KNOWN_OLD_TAG_COMMIT/ + ); + assert.match( + recoveryValidationStep, + /git merge-base --is-ancestor "\$REVIEWED_REPAIR_BASELINE" "\$RELEASE_COMMIT"/ + ); + assert.match( + recoveryValidationStep, + /\.github\/workflows\/publish\.yml\|CHANGELOG\.md\|docs\/identity\/deployment\.md\|e2e-tests\/tests\/config\/scaffold\.test\.mjs/ + ); + 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/ + ); + assert.match( + recoveryClaimStep, + /if: steps\.central_recovery\.outputs\.claim_retry_deployment == '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\}:"/ + ); const uploadStep = workflow.match( /- name: Upload one Aether bundle to Maven Central[\s\S]*?(?=\n\s+- name: Wait for Maven Central publication)/