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
59 changes: 49 additions & 10 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}$ ]] || {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>().orEmpty()
val rawPurls = status["purls"]
val purlValues = when (rawPurls) {
null -> emptyList<Any?>()
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<String>()
val purls = purlList.toSet()
if (returnedId != deploymentId) throw GradleException("Central status returned a different deployment ID")
if (returnedName != expectedDeploymentName) {
Expand Down
13 changes: 7 additions & 6 deletions docs/identity/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions e2e-tests/tests/config/scaffold.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\}"\)/);
Expand All @@ -127,15 +128,15 @@ 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,
/- name: Create GitHub release\n\s+uses: softprops\/action-gh-release@v2\n\s+if: steps\.version\.outputs\.publish_required == 'true' && steps\.changelog\.outputs\.notes != ''/
);

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');
Expand Down Expand Up @@ -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/);
Expand All @@ -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/);
Expand All @@ -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"/
Expand All @@ -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(
Expand Down
Loading