Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 137 additions & 13 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != '')
Expand All @@ -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"]
Expand Down Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/identity/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions e2e-tests/tests/config/scaffold.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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/);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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)/
Expand Down
Loading