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
55 changes: 39 additions & 16 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.resume_commit_sha || github.sha }}
ref: ${{ github.sha }}

- name: Grant execute permissions
run: |
Expand Down Expand Up @@ -221,7 +221,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.resume_commit_sha || github.sha }}
ref: ${{ github.sha }}

- name: Grant execute permissions
run: |
Expand Down Expand Up @@ -253,10 +253,8 @@ jobs:
echo "::error::The first CHANGELOG.md release ($CHANGELOG_VERSION) does not match version.properties ($VERSION)."
exit 1
}
RELEASE_COMMIT=$(git rev-parse HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_commit=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
echo "deployment_name=aether-${VERSION}-${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"
WORKFLOW_COMMIT=$(git rev-parse HEAD)
RELEASE_COMMIT="$WORKFLOW_COMMIT"

if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" && -n "$RESUME_DEPLOYMENT_ID" ]]; then
echo "::error::retry_failed_deployment_id and resume_deployment_id are mutually exclusive"
Expand All @@ -281,15 +279,26 @@ jobs:
echo "::error::resume_commit_sha must be the exact 40-character upload commit"
exit 1
}
[[ "${RESUME_COMMIT_SHA,,}" == "${RELEASE_COMMIT,,}" ]] || {
echo "::error::Checkout commit does not match resume_commit_sha"
git cat-file -e "${RESUME_COMMIT_SHA}^{commit}" || {
echo "::error::resume_commit_sha is not an available commit"
exit 1
}
RESUME_VERSION=$(git show "${RESUME_COMMIT_SHA}:version.properties" | sed -n 's/^VERSION=//p')
[[ "$RESUME_VERSION" == "$VERSION" ]] || {
echo "::error::The current release version does not match the resumed upload commit"
exit 1
}
RELEASE_COMMIT=$(git rev-parse "${RESUME_COMMIT_SHA}^{commit}")
elif [[ -n "$RESUME_COMMIT_SHA" ]]; then
echo "::error::resume_commit_sha is valid only with resume_deployment_id"
exit 1
fi

echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "workflow_commit=$WORKFLOW_COMMIT" >> "$GITHUB_OUTPUT"
echo "release_commit=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
echo "deployment_name=aether-${VERSION}-${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"

if git rev-parse --verify --quiet "refs/tags/v${VERSION}" >/dev/null; then
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
TAG_EXISTS=true
Expand Down Expand Up @@ -334,7 +343,7 @@ jobs:
exit 1
}
if [[ "$TAG_EXISTS" == "true" ]]; then
git merge-base --is-ancestor "v${VERSION}" HEAD || {
git merge-base --is-ancestor "v${VERSION}" "$RELEASE_COMMIT" || {
echo "::error::Existing tag v${VERSION} is not an ancestor of the repair commit."
exit 1
}
Expand Down Expand Up @@ -383,7 +392,7 @@ jobs:
UNEXPECTED_REPAIR_FILES+=("$FILE")
;;
esac
done < <(git diff --name-only "v${VERSION}"...HEAD)
done < <(git diff --name-only "v${VERSION}"..."${RELEASE_COMMIT}")
if (( ${#UNEXPECTED_REPAIR_FILES[@]} > 0 )); then
echo "::error::Same-version recovery is restricted to reviewed publication metadata; unexpected files: ${UNEXPECTED_REPAIR_FILES[*]}"
exit 1
Expand Down Expand Up @@ -452,7 +461,7 @@ jobs:
REPAIR_RELEASE_TAG: ${{ inputs.repair_release_tag || false }}
ACKNOWLEDGE_UNAVAILABLE_FAILED_DEPLOYMENT: ${{ inputs.acknowledge_unavailable_failed_deployment || false }}
run: |
./gradlew writeExpectedCentralPurls --no-daemon --quiet
./gradlew writeExpectedCentralPurls writeExpectedCentralReportedPurls --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')"
Expand Down Expand Up @@ -488,6 +497,8 @@ jobs:
purls = data.get("purls") or []
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:
expected_reported_purls = {line.strip() for line in source if line.strip()}

if data.get("deploymentId") != deployment_id:
sys.exit("Central status response did not match the requested deployment")
Expand All @@ -502,11 +513,16 @@ jobs:

if len(purls) != len(set(purls)):
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")
actual_purls = set(purls)
require_complete_manifest = bool(retry_id) or state == "PUBLISHED" or bool(actual_purls)
if require_complete_manifest and actual_purls != expected_purls:
missing = sorted(expected_purls - actual_purls)
unexpected = sorted(actual_purls - expected_purls)
unexpected_purls = actual_purls - expected_reported_purls
if unexpected_purls:
sys.exit(f"deployment contains unexpected component PURLs: {sorted(unexpected_purls)}")
require_complete_manifest = bool(retry_id) or state == "PUBLISHED"
if require_complete_manifest and actual_purls != expected_reported_purls:
missing = sorted(expected_reported_purls - actual_purls)
unexpected = sorted(actual_purls - expected_reported_purls)
sys.exit(f"deployment component mismatch; missing={missing}, unexpected={unexpected}")
print(f"Central recovery request verified: {data.get('deploymentId')} is {state}")
PY
Expand Down Expand Up @@ -651,10 +667,17 @@ jobs:
- name: Extract latest changelog entry
id: changelog
shell: bash
env:
RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }}
run: |
RELEASE_CHANGELOG="$RUNNER_TEMP/release-changelog.md"
git show "${RELEASE_COMMIT}:CHANGELOG.md" > "$RELEASE_CHANGELOG"
export RELEASE_CHANGELOG
python3 <<'PY'
import os

try:
with open("CHANGELOG.md", encoding="utf-8") as fh:
with open(os.environ["RELEASE_CHANGELOG"], encoding="utf-8") as fh:
lines = fh.readlines()
except FileNotFoundError:
print("No CHANGELOG.md found")
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
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.
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.

### Known limitations

Expand Down
55 changes: 49 additions & 6 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -235,22 +235,65 @@ val centralDeploymentIdPattern = Regex(
)

fun centralExpectedPurls(version: String): Set<String> = buildSet {
check(centralPortalArtifactIds.size == centralPortalArtifactIds.toSet().size) {
"Central publication artifact IDs must be unique"
}
centralPortalArtifactIds.forEach { artifactId ->
add("pkg:maven/codes.yousef.aether/$artifactId@$version")
}
add("pkg:maven/codes.yousef.aether.plugin/$centralPluginMarkerArtifactId@$version")
}.also { purls ->
check(centralPortalArtifactIds.size == 74 && purls.size == 75) {
"Central coordinate manifest must contain exactly 74 artifacts plus the plugin marker"
}
}

fun centralExpectedReportedPurls(version: String): Set<String> {
val basePurls = centralExpectedPurls(version)
val klibArtifactIds = centralPortalArtifactIds
.filter { artifactId -> artifactId.endsWith("-wasm-js") || artifactId.endsWith("-wasm-wasi") }
check(klibArtifactIds.size == 35) { "Central status manifest must contain exactly 35 KLIB variants" }
return buildSet {
addAll(basePurls)
klibArtifactIds.forEach { artifactId ->
add("pkg:maven/codes.yousef.aether/$artifactId@$version?type=klib")
}
add("pkg:maven/codes.yousef.aether.plugin/$centralPluginMarkerArtifactId@$version?type=pom")
}.also { reportedPurls ->
check(reportedPurls.size == 111 && reportedPurls.containsAll(basePurls)) {
"Central status manifest must contain exactly 111 PURLs including all release coordinates"
}
}
}

val writeExpectedCentralPurls by tasks.registering {
group = "publishing"
description = "Writes the exact component set expected in a Central Portal deployment."
val outputFile = layout.buildDirectory.file("central-expected-purls.txt")
val expectedPurls = provider { centralExpectedPurls(project.version.toString()).sorted() }
inputs.property("expectedPurls", expectedPurls)
outputs.file(outputFile)

doLast {
outputFile.get().asFile.apply {
parentFile.mkdirs()
writeText(expectedPurls.get().joinToString("\n", postfix = "\n"))
}
}
}

val writeExpectedCentralReportedPurls by tasks.registering {
group = "publishing"
description = "Writes the exact PURL set reported by Central Portal for the release components."
val outputFile = layout.buildDirectory.file("central-expected-reported-purls.txt")
val expectedPurls = provider { centralExpectedReportedPurls(project.version.toString()).sorted() }
inputs.property("expectedPurls", expectedPurls)
outputs.file(outputFile)

doLast {
outputFile.get().asFile.apply {
parentFile.mkdirs()
writeText(centralExpectedPurls(project.version.toString()).sorted().joinToString("\n", postfix = "\n"))
writeText(expectedPurls.get().joinToString("\n", postfix = "\n"))
}
}
}
Expand Down Expand Up @@ -527,7 +570,7 @@ val uploadCentralPortalBundle by tasks.registering {
val waitForCentralPortalPublication by tasks.registering {
group = "publishing"
description = "Polls one known Central deployment until it reaches PUBLISHED or FAILED."
dependsOn(writeExpectedCentralPurls)
dependsOn(writeExpectedCentralPurls, writeExpectedCentralReportedPurls)

doLast {
val username = localProperties.getProperty("mavenCentralUsername")
Expand All @@ -551,7 +594,7 @@ val waitForCentralPortalPublication by tasks.registering {
?.trim()?.takeIf(String::isNotEmpty)
?: deploymentNameFile.takeIf(File::isFile)?.readText()?.trim()
?: throw GradleException("AETHER_CENTRAL_DEPLOYMENT_NAME or a recorded deployment name is required")
val expectedPurls = centralExpectedPurls(project.version.toString())
val expectedPurls = centralExpectedReportedPurls(project.version.toString())
val timeoutSeconds = System.getenv("AETHER_CENTRAL_STATUS_TIMEOUT_SECONDS")
?.toLongOrNull()?.coerceIn(60L, 7200L) ?: 1800L
val pollSeconds = System.getenv("AETHER_CENTRAL_STATUS_POLL_SECONDS")
Expand Down Expand Up @@ -613,10 +656,10 @@ val waitForCentralPortalPublication by tasks.registering {
throw GradleException("Central status returned unexpected deployment name '$returnedName'")
}
if (purlList.size != purls.size) throw GradleException("Central status contains duplicate PURLs")
if (purls.isNotEmpty() && purls != expectedPurls) {
val unexpectedPurls = purls - expectedPurls
if (unexpectedPurls.isNotEmpty()) {
throw GradleException(
"Central deployment component mismatch; missing=${(expectedPurls - purls).sorted()}, " +
"unexpected=${(purls - expectedPurls).sorted()}"
"Central deployment contains unexpected components: ${unexpectedPurls.sorted()}"
)
}

Expand Down
8 changes: 5 additions & 3 deletions docs/identity/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,11 @@ reaches `PUBLISHED`, and a validation failure prevents release completion. Excep
an already-tagged but unpublished release requires the exact authenticated `FAILED` deployment ID
and explicit authorization to move the GitHub tag to the corrected commit. Upload and polling are
separate steps: the accepted deployment UUID is recorded immediately and an interrupted deployment
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.
is resumed by that UUID plus the exact upload commit encoded in its deterministic deployment name.
Resume executes the current reviewed polling workflow while keeping the deployment and repaired tag
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
Expand Down
42 changes: 38 additions & 4 deletions e2e-tests/tests/config/scaffold.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,19 @@ 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.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\}"\)/);
assert.match(workflow, /The current release version does not match the resumed upload commit/);
assert.doesNotMatch(workflow, /Checkout commit does not match resume_commit_sha/);
assert.match(
workflow,
/elif \[\[ -n "\$RESUME_DEPLOYMENT_ID" \]\]; then[\s\S]*?upload_required=false[\s\S]*?release_mode=resume/
);
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/);
assert.match(workflow, /git diff --name-only "v\$\{VERSION\}"\.\.\."\$\{RELEASE_COMMIT\}"/);
assert.match(workflow, /git merge-base --is-ancestor "v\$\{VERSION\}" HEAD/);
assert.match(
workflow,
Expand Down Expand Up @@ -161,8 +170,21 @@ test('Maven Central publication requires real sources and waits for PUBLISHED',
assert.match(rootBuild, /numericHttpCode !in setOf\(408, 409, 425, 429\)/);
assert.match(rootBuild, /uploadCentralPortalBundle cannot resume an existing deployment/);
assert.match(rootBuild, /centralExpectedPurls/);
assert.match(rootBuild, /centralExpectedReportedPurls/);
assert.match(rootBuild, /writeExpectedCentralReportedPurls by tasks\.registering/);
assert.match(rootBuild, /central-expected-reported-purls\.txt/);
assert.match(rootBuild, /artifactId\.endsWith\("-wasm-js"\)/);
assert.match(rootBuild, /artifactId\.endsWith\("-wasm-wasi"\)/);
assert.match(rootBuild, /\?type=klib/);
assert.match(rootBuild, /\?type=pom/);
assert.match(rootBuild, /centralPortalArtifactIds\.size == 74 && purls\.size == 75/);
assert.match(rootBuild, /klibArtifactIds\.size == 35/);
assert.match(rootBuild, /reportedPurls\.size == 111/);
assert.match(rootBuild, /unexpectedPurls = purls - expectedPurls/);
assert.equal((rootBuild.match(/inputs\.property\("expectedPurls", expectedPurls\)/g) ?? []).length, 2);
assert.match(rootBuild, /reportedPurls\.containsAll\(basePurls\)/);
assert.match(rootBuild, /purlList\.size != purls\.size/);
assert.match(rootBuild, /purls != expectedPurls/);
assert.match(rootBuild, /"PUBLISHED" -> \{[\s\S]*?purls != expectedPurls/);
assert.match(signScript, /AETHER_SIGNING_PASSPHRASE/);
assert.match(signScript, /--passphrase-fd 0/);
assert.doesNotMatch(signScript, /PASSPHRASE="\$1"|--passphrase "\$PASSPHRASE"/);
Expand All @@ -178,11 +200,16 @@ test('Maven Central publication requires real sources and waits for PUBLISHED',
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, /actual_purls != expected_reported_purls/);
assert.match(workflow, /writeExpectedCentralPurls writeExpectedCentralReportedPurls/);
assert.match(workflow, /len\(expected_purls\) != 75 or len\(expected_reported_purls\) != 111/);
assert.match(workflow, /unexpected_purls = actual_purls - expected_reported_purls/);
assert.match(workflow, /require_complete_manifest = bool\(retry_id\) or state == "PUBLISHED"/);
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/);
assert.match(workflow, /git merge-base --is-ancestor "v\$\{VERSION\}" "\$RELEASE_COMMIT"/);
assert.match(workflow, /git show "\$\{RELEASE_COMMIT\}:CHANGELOG\.md"/);

const recoveryValidationStep = workflow.match(
/- name: Validate Central recovery request[\s\S]*?(?=\n\s+- name: Claim the one-time failed-deployment retry)/
Expand Down Expand Up @@ -227,13 +254,20 @@ test('Maven Central publication requires real sources and waits for PUBLISHED',
const uploadStep = workflow.match(
/- name: Upload one Aether bundle to Maven Central[\s\S]*?(?=\n\s+- name: Wait for Maven Central publication)/
)?.[0] ?? '';
const signingStep = workflow.match(
/- name: Configure signing key[\s\S]*?(?=\n\s+- name: Validate Central recovery request)/
)?.[0] ?? '';
const waitStep = workflow.match(
/- name: Wait for Maven Central publication[\s\S]*?(?=\n\s+- name: Delete publishing credentials)/
)?.[0] ?? '';
assert.doesNotMatch(uploadStep, /MAX_RETRIES|for i in|waitForCentralPortalPublication/);
assert.match(signingStep, /if: steps\.version\.outputs\.upload_required == 'true'/);
assert.match(uploadStep, /uploadCentralPortalBundle/);
assert.match(uploadStep, /if: steps\.version\.outputs\.upload_required == 'true'/);
assert.doesNotMatch(waitStep, /uploadCentralPortalBundle|signingPassword|private-key\.asc/);
assert.match(waitStep, /waitForCentralPortalPublication/);
assert.match(waitStep, /AETHER_CENTRAL_DEPLOYMENT_ID: \$\{\{ inputs\.resume_deployment_id \|\| steps\.central_upload\.outputs\.deployment_id \}\}/);
assert.match(waitStep, /AETHER_CENTRAL_DEPLOYMENT_NAME: \$\{\{ steps\.version\.outputs\.deployment_name \}\}/);
});

test('the virtual authenticator models a discoverable user-verified CTAP2 passkey', async () => {
Expand Down
Loading