From 3912bb59aaa9cdd26c8bc3f8d7f28b7da641bcb4 Mon Sep 17 00:00:00 2001 From: "yousef.code" Date: Thu, 16 Jul 2026 09:06:28 +0300 Subject: [PATCH] fix(FEL-623): accept Central typed PURLs --- .github/workflows/publish.yml | 55 +++++++++++++++++------- CHANGELOG.md | 4 +- build.gradle.kts | 55 +++++++++++++++++++++--- docs/identity/deployment.md | 8 ++-- e2e-tests/tests/config/scaffold.test.mjs | 42 ++++++++++++++++-- 5 files changed, 134 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ba97800..f8639c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: | @@ -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: | @@ -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" @@ -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 @@ -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 } @@ -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 @@ -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')" @@ -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") @@ -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 @@ -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") diff --git a/CHANGELOG.md b/CHANGELOG.md index 382c2b1..fdc8cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index dc9ab8a..62a9416 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -235,22 +235,65 @@ val centralDeploymentIdPattern = Regex( ) fun centralExpectedPurls(version: String): Set = 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 { + 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")) } } } @@ -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") @@ -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") @@ -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()}" ) } diff --git a/docs/identity/deployment.md b/docs/identity/deployment.md index 73aae81..8dd778d 100644 --- a/docs/identity/deployment.md +++ b/docs/identity/deployment.md @@ -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 diff --git a/e2e-tests/tests/config/scaffold.test.mjs b/e2e-tests/tests/config/scaffold.test.mjs index 2249849..9d9a6a5 100644 --- a/e2e-tests/tests/config/scaffold.test.mjs +++ b/e2e-tests/tests/config/scaffold.test.mjs @@ -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, @@ -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"/); @@ -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)/ @@ -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 () => {