diff --git a/.github/workflows/release-repair.yml b/.github/workflows/release-repair.yml new file mode 100644 index 00000000..8caef243 --- /dev/null +++ b/.github/workflows/release-repair.yml @@ -0,0 +1,586 @@ +name: release-repair + +# Repairs the assets of an EXISTING published release, from that release's +# tagged source, on CI runners. +# +# Why this is a separate workflow on the default branch, and not the +# `release_tag` input on release.yml: +# +# 1. GitHub compiles a workflow_dispatch run from the workflow file AT THE +# DISPATCHED REF. An input added to release.yml would simply not exist +# when release.yml is dispatched at an old tag - the old file is the one +# that runs, and for every tag before 0.7.1 that file has no +# workflow_dispatch trigger at all. Dispatching THIS workflow (which lives +# on the default branch) and checking out the tag is the only shape that +# works for every tag, including tags cut before this file existed. +# +# 2. release.yml rewrites the live `latest` release - the stable download URLs +# https://github.com/minekube/connect-java/releases/download/latest/*.jar +# that download sites and server owners point at. Re-running it at an OLD +# tag would drag `latest` backwards and silently downgrade everyone using +# those URLs. This workflow never WRITES a release other than the one being +# repaired; it reads the pointers for immutability checks and refuses them +# as repair targets outright. +# +# WHY TWO JOBS, and why that is the whole security model: +# +# A repair necessarily EXECUTES OLD TAGGED SOURCE - a Gradle build, its +# plugins, and whatever those resolve. That source is not reviewed as part of +# the repair, and for the tags this exists to fix it is years old. So the +# question is not "is the build trustworthy" but "what can it reach". +# +# Scoping the token to individual steps does NOT answer that. Within one job, +# a build step can append to $GITHUB_PATH or $GITHUB_ENV and have a LATER, +# token-bearing step in that same job execute a tool it planted. Step-level +# `env:` is not a sandbox; the job is the boundary. +# +# So the write capability lives in a job that never sees the tag: +# +# build - permissions: contents: read. Checks the tag out and runs its +# Gradle build. Holds NO write capability, so nothing it plants can +# be escalated into a release write, a ref update, or a pointer +# move. Hands its output on as an artifact. +# publish - permissions: contents: write. Checks out NOTHING, runs NO tag +# code, and only downloads that artifact and uploads it to the one +# release named by the dispatch input. +# +# Top-level permissions are `{}` so neither job inherits anything it was not +# explicitly granted. No registry scope exists anywhere in this file, so +# publishing connect's jars to a package registry is not "guarded against" +# here - it is unrepresentable. ReleaseRepairCapabilityTest asserts the split +# and the boundary; a token on the build job, or a checkout in the publish job, +# turns that suite red. +# +# This workflow publishes GitHub Release assets only. + +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing release tag to repair (e.g. 0.6.0). + required: true + type: string + +# Nothing is inherited. Each job is granted exactly what it needs, below. +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + ASSET_ARTIFACT: release-assets + +jobs: + # Runs the untrusted tagged source. Read-only by construction: there is no + # write capability in this job for a planted tool to reach. + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # Read the tag and its release. NEVER write here. + env: + RELEASE_TAG: ${{ inputs.release_tag }} + steps: + # GUARD 0, before anything is even checked out: `latest` and + # `latest-prerelease` are not versions, they are the live pointers whose + # download URLs are published to users. Repairing them is never what + # anybody means, and rebuilding an old tag into them is exactly the + # silent downgrade this workflow exists to make impossible. + - name: Refuse to repair a pointer release + run: | + set -euo pipefail + + case "$RELEASE_TAG" in + latest | latest-prerelease) + echo "::error::$RELEASE_TAG is a live pointer release, not a version." + echo "::error::Repairing it would republish old jars at the stable download URLs" + echo "::error::and downgrade every consumer of them. Refusing." + exit 1 + ;; + esac + + if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::$RELEASE_TAG is not a connect-java version tag (expected N.N.N)." + exit 1 + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.release_tag }} + persist-credentials: false + + # Provenance: a repair must build the tag, not a branch that merely + # happens to be named like one. A moving ref would produce assets that do + # not correspond to the release they are attached to. + - name: Confirm the checkout is the tagged commit + run: | + set -euo pipefail + + TAG_SHA=$(git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}" || true) + HEAD_SHA=$(git rev-parse HEAD) + + if [ -z "$TAG_SHA" ]; then + echo "::error::${RELEASE_TAG} is not a tag in this repository." + echo "::error::Repair rebuilds tagged source; it does not build branches." + exit 1 + fi + if [ "$TAG_SHA" != "$HEAD_SHA" ]; then + echo "::error::Checked-out HEAD ($HEAD_SHA) is not tag ${RELEASE_TAG} ($TAG_SHA)." + exit 1 + fi + + echo "Repairing ${RELEASE_TAG} from tagged commit ${TAG_SHA}." + + # GUARD 1, before the build rather than after it: repair fills holes. A + # release that already publishes a real plugin jar is complete, and + # re-uploading over it would replace exactly the bytes server owners + # already run. Failing here costs seconds instead of a full Gradle build. + # + # Classified by name/type, not counted: a release carrying only LICENSE + # has a positive asset count and still offers nothing anyone can run, + # which is precisely the state 0.6.0/0.7.0-era holes leave behind. + # + # This is a READ, and it runs before any tag code executes. + - name: Refuse to repair a complete release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::No published release exists for $RELEASE_TAG to repair." + echo "::error::Repair fills holes in an existing release; it does not create one." + exit 1 + fi + + RELEASE_JSON=$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG") + + echo "$RELEASE_TAG currently publishes:" + printf '%s' "$RELEASE_JSON" | jq -r '.assets[] | " \(.name)\t\(.size)\t\(.state)"' + + EXISTING_BUILDS=$(printf '%s' "$RELEASE_JSON" | jq ' + [.assets[] + | select(.state == "uploaded") + | select(.size > 0) + | select(.name | test("^connect-(spigot|velocity|bungee).*\\.jar$"))] | length') + + if [ "$EXISTING_BUILDS" -gt 0 ]; then + echo "::error::Release $RELEASE_TAG already publishes $EXISTING_BUILDS plugin jar(s)." + echo "::error::Refusing to modify a complete release. Repair fills holes only." + exit 1 + fi + + # The tag's OWN toolchain, never the runner default. A newer JDK that + # fails to compile old tagged source produces a false red: it would + # condemn a tag that its own release path builds fine. Missing pin => + # hard failure, because silently falling back to a default is the same + # false result wearing a green hat. + - name: Resolve the tag's pinned Java toolchain + id: toolchain + run: | + set -euo pipefail + + TAG_WORKFLOW=.github/workflows/release.yml + if [ ! -f "$TAG_WORKFLOW" ]; then + echo "::error::$RELEASE_TAG has no $TAG_WORKFLOW; its release toolchain is unknown." + exit 1 + fi + + JAVA_VERSION=$(grep -Eo "java-version:[[:space:]]*['\"]?[0-9]+" "$TAG_WORKFLOW" \ + | grep -Eo '[0-9]+$' | head -n1) + + if [ -z "$JAVA_VERSION" ]; then + echo "::error::Could not read the Java version pinned by $RELEASE_TAG's $TAG_WORKFLOW." + echo "::error::Refusing to guess a toolchain: a wrong JDK produces a false result." + exit 1 + fi + + echo "java-version=$JAVA_VERSION" >> "$GITHUB_OUTPUT" + echo "$RELEASE_TAG pins JDK $JAVA_VERSION; Gradle comes from the tag's own wrapper:" + grep distributionUrl gradle/wrapper/gradle-wrapper.properties + + - name: Set up the tag's JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ steps.toolchain.outputs.java-version }} + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + # --------------------------------------------------------------------- + # Everything from here on runs, or runs after, UNTRUSTED TAGGED SOURCE. + # No step below holds a write capability, and no step below may be given + # one: the tagged build can plant a tool on $GITHUB_PATH that a later + # step in this job would execute. That is why the release write lives in + # a separate job rather than in a later step of this one. + # --------------------------------------------------------------------- + + # The same build the tag's own release path ran. A repaired release must + # not carry weaker provenance than one published on the normal path. + - name: Build + run: ./gradlew build + + # Asset names follow the convention of the tag's OWN release workflow: + # tags up to 0.7.0 published version-suffixed jars, 0.7.1 onwards publish + # bare names. A repair that renamed them would not be a repair. + - name: Stage release assets + run: | + set -euo pipefail + + if grep -q "connect-spigot-\${REF}\.jar" .github/workflows/release.yml; then + SUFFIX="-${RELEASE_TAG}" + else + SUFFIX="" + fi + echo "Asset naming for $RELEASE_TAG: connect-${SUFFIX}.jar" + + mkdir -p build/release + cp spigot/build/libs/connect-spigot.jar "build/release/connect-spigot${SUFFIX}.jar" + cp velocity/build/libs/connect-velocity.jar "build/release/connect-velocity${SUFFIX}.jar" + cp bungee/build/libs/connect-bungee.jar "build/release/connect-bungee${SUFFIX}.jar" + cp LICENSE build/release/LICENSE + + ls -la build/release/ + + - name: Hand the assets to the publish job + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ASSET_ARTIFACT }} + path: build/release/ + if-no-files-found: error + retention-days: 1 + + # Holds the ONLY write capability in this workflow. It checks nothing out and + # runs no tag code, so there is no tagged source in this job to escalate it. + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # GitHub Release asset upload. The ONLY write in this file. + # `packages` and every other registry scope are deliberately absent and + # must stay absent. Without them, pushing a build anywhere but this + # release's own asset list is not forbidden here, it is unrepresentable. + env: + RELEASE_TAG: ${{ inputs.release_tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET_DIR: release-assets + steps: + # Repeated here on purpose rather than relied upon from the build job: + # this is the job that holds the write token, so the refusal that + # protects the pointer releases belongs where the capability is. + - name: Refuse to repair a pointer release + run: | + set -euo pipefail + + case "$RELEASE_TAG" in + latest | latest-prerelease) + echo "::error::$RELEASE_TAG is a live pointer release, not a version." + echo "::error::Repairing it would republish old jars at the stable download URLs" + echo "::error::and downgrade every consumer of them. Refusing." + exit 1 + ;; + esac + + if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::$RELEASE_TAG is not a connect-java version tag (expected N.N.N)." + exit 1 + fi + + - name: Collect the built assets + uses: actions/download-artifact@v4 + with: + name: ${{ env.ASSET_ARTIFACT }} + path: ${{ env.ASSET_DIR }} + + # The artifact was produced by a job that ran untrusted tagged source, so + # its FILE NAMES are untrusted input to this job: they become the names of + # public release assets. Only the jars a connect release is supposed to + # carry, plus LICENSE, may pass. Regular files only - a symlink or a + # nested path is not something a repair produces. + - name: Validate the downloaded assets + run: | + set -euo pipefail + + if [ ! -d "$ASSET_DIR" ]; then + echo "::error::The build job produced no asset directory." + exit 1 + fi + + REJECTED="" + ACCEPTED="" + JARS=0 + while IFS= read -r path; do + base="$(basename "$path")" + if [ -L "$path" ] || [ ! -f "$path" ] || [ ! -s "$path" ]; then + REJECTED="$REJECTED $base" + continue + fi + if [ "$path" != "$ASSET_DIR/$base" ]; then + REJECTED="$REJECTED $path" + continue + fi + case "$base" in + LICENSE) ACCEPTED="$ACCEPTED $base" ;; + *) + if printf '%s' "$base" \ + | grep -Eq '^connect-(spigot|velocity|bungee)[A-Za-z0-9._-]*\.jar$'; then + ACCEPTED="$ACCEPTED $base" + JARS=$((JARS + 1)) + else + REJECTED="$REJECTED $base" + fi + ;; + esac + done < <(find "$ASSET_DIR" -mindepth 1) + + echo "Accepted:$ACCEPTED" + + if [ -n "$REJECTED" ]; then + echo "::error::The build job produced asset(s) a repair must not publish:$REJECTED" + echo "::error::Only connect-*.jar and LICENSE may become release assets." + exit 1 + fi + + if [ "$JARS" -eq 0 ]; then + echo "::error::The build job produced no plugin jar; there is nothing to repair with." + exit 1 + fi + + # Recorded before this job writes anything, and the build job cannot + # write at all, so this baseline predates every write the run performs. + - name: Record the live pointer releases + run: | + set -euo pipefail + + for pointer in latest latest-prerelease; do + if ! gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$pointer" \ + | jq -S '{tag_name, name, target_commitish, created_at, published_at, + assets: [.assets[] | {name, size, updated_at}] | sort_by(.name)}' \ + > "/tmp/pointer-$pointer.before.json"; then + echo "::error::Could not read the $pointer release; refusing to run blind." + exit 1 + fi + echo "--- $pointer (before) ---" + cat "/tmp/pointer-$pointer.before.json" + done + + # Repairing an existing release must not go through the `softprops` + # gh-release action that release.yml uses for fresh publishes: it PATCHes + # /releases/{id} to sync release metadata BEFORE uploading anything, which + # rewrites historical release notes. `gh release upload` only POSTs to + # /releases/{id}/assets, and only for the tag named here. + - name: Upload the missing assets + run: | + set -euo pipefail + + INITIAL_RELEASE_JSON=$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG") + + FILES=() + for f in "$ASSET_DIR"/*; do + [ -f "$f" ] || continue + FILES+=("$f") + done + + if [ "${#FILES[@]}" -eq 0 ]; then + echo "::error::Nothing to upload for $RELEASE_TAG: no staged assets exist." + exit 1 + fi + + # GUARD 2: never clobber an existing GOOD asset. A name that is + # absent is uploaded WITHOUT --clobber, so GitHub itself rejects the + # POST if a concurrent writer published that name in the meantime - + # that rejection is the atomic conditional the REST API does not + # otherwise offer. --clobber is reserved for names that are present + # but BROKEN, where replacing already-unusable bytes is the point. + RELEASE_JSON=$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG") + ABSENT_LIST=() + BROKEN_LIST=() + SKIPPED="" + PUBLISHED_DURING_REPAIR="" + for f in "${FILES[@]}"; do + base="$(basename "$f")" + initial_asset_count=$(printf '%s' "$INITIAL_RELEASE_JSON" | jq -r --arg n "$base" \ + '[.assets[] | select(.name == $n)] | length') + current_asset_count=$(printf '%s' "$RELEASE_JSON" | jq -r --arg n "$base" \ + '[.assets[] | select(.name == $n)] | length') + good=$(printf '%s' "$RELEASE_JSON" | jq -r --arg n "$base" \ + '[.assets[] | select(.name == $n and .state == "uploaded" and .size > 0)] | length') + if [ "$good" -gt 0 ]; then + SKIPPED="$SKIPPED $base" + if [ "$initial_asset_count" -eq 0 ]; then + PUBLISHED_DURING_REPAIR="$PUBLISHED_DURING_REPAIR $base" + fi + elif [ "$current_asset_count" -eq 0 ]; then + ABSENT_LIST+=("$f") + else + BROKEN_LIST+=("$f") + fi + done + + if [ -n "$PUBLISHED_DURING_REPAIR" ]; then + echo "Skipping assets published since the initial release snapshot (not clobbered):$PUBLISHED_DURING_REPAIR" + fi + + if [ -n "$SKIPPED" ]; then + echo "Keeping existing good assets (not clobbered):$SKIPPED" + fi + + if [ "${#ABSENT_LIST[@]}" -eq 0 ] && [ "${#BROKEN_LIST[@]}" -eq 0 ]; then + echo "::error::Nothing to upload for $RELEASE_TAG: every built file is already published." + exit 1 + fi + + if [ "${#ABSENT_LIST[@]}" -gt 0 ]; then + echo "Uploading newly staged assets without clobber" + if ! gh release upload "$RELEASE_TAG" "${ABSENT_LIST[@]}" --repo "$GITHUB_REPOSITORY"; then + echo "::error::An asset classified as absent was rejected during upload; a concurrent release writer may have published one of ${ABSENT_LIST[*]}." + echo "::error::Refusing to retry with --clobber." + exit 1 + fi + fi + + if [ "${#BROKEN_LIST[@]}" -gt 0 ]; then + echo "Replacing broken release assets with clobber" + # A writer can replace a broken asset with a good one after this read and before + # --clobber. The prior bytes were already unusable; landed-asset and pointer checks + # below surface the result. + gh release upload "$RELEASE_TAG" "${BROKEN_LIST[@]}" --clobber --repo "$GITHUB_REPOSITORY" + fi + + # A release that publishes nothing downloadable is indistinguishable from + # a healthy one: green run, release page exists, and the hole only + # surfaces when a server owner's download link 404s. Trusting the upload + # step we just ran would rebuild that same defect one layer up, so + # re-read the PUBLISHED release from the API and assert on the landed + # fact. Never conditional: the guard must not be skippable into silence. + - name: Verify published release assets + run: | + set -euo pipefail + + # A positive asset count is not a release. LICENSE alone has a + # positive count and offers nothing anyone can run, so classify by + # name/type and require a real downloadable plugin jar. + BUILD_FILTER='[.assets[] + | select(.state == "uploaded") + | select(.size > 0) + | select(.name | test("^connect-(spigot|velocity|bungee).*\\.jar$"))]' + + RELEASE_JSON="" + ASSET_COUNT=0 + BUILD_COUNT=0 + BUILD_NAMES="" + PROBE="" + PROBE_URL="" + PROBE_CODE="" + + # Asset visibility is eventually consistent right after upload. + for attempt in $(seq 1 12); do + if ! RELEASE_JSON=$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG"); then + echo "Release $RELEASE_TAG is not readable yet (attempt $attempt); waiting..." + RELEASE_JSON="" + sleep 10 + continue + fi + + ASSET_COUNT=$(printf '%s' "$RELEASE_JSON" | jq '[.assets[] | select(.state == "uploaded")] | length') + BUILD_COUNT=$(printf '%s' "$RELEASE_JSON" | jq "$BUILD_FILTER | length") + BUILD_NAMES=$(printf '%s' "$RELEASE_JSON" | jq -r "$BUILD_FILTER | .[].name") + + PROBE="" + PROBE_URL="" + PROBE_CODE="" + if [ "$BUILD_COUNT" -gt 0 ]; then + PROBE=$(printf '%s' "$BUILD_NAMES" | head -n1) + PROBE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG/$PROBE" + if ! PROBE_CODE=$(curl -sSL -o /dev/null -w '%{http_code}' -r 0-0 "$PROBE_URL"); then + PROBE_CODE="000" + fi + fi + + if [ "$ASSET_COUNT" -gt 0 ] && [ "$BUILD_COUNT" -gt 0 ]; then + case "$PROBE_CODE" in + 200 | 206) break ;; + esac + fi + + echo "Published assets are incomplete on $RELEASE_TAG (attempt $attempt); waiting..." + sleep 10 + done + + echo "--- $RELEASE_TAG ---" + if [ -n "$RELEASE_JSON" ]; then + printf '%s' "$RELEASE_JSON" | jq -r '.assets[] | "\(.name)\t\(.size)\t\(.state)"' + fi + echo "Real build artifacts published ($BUILD_COUNT):" + echo "${BUILD_NAMES:- (none)}" + + if [ -z "$RELEASE_JSON" ]; then + echo "::error::Release $RELEASE_TAG could not be read back from the API; the repair did not land." + exit 1 + fi + + if [ "$ASSET_COUNT" -eq 0 ]; then + echo "::error::Release $RELEASE_TAG still publishes ZERO downloadable assets." + echo "::error::A release with no artifact silently did not happen. Failing loudly." + exit 1 + fi + + if [ "$BUILD_COUNT" -eq 0 ]; then + echo "::error::Release $RELEASE_TAG has $ASSET_COUNT asset(s) but NO real build artifact." + echo "::error::Only LICENSE/checksums/signatures/SBOMs were published - no plugin jar." + exit 1 + fi + + if [ "$PROBE_CODE" != "200" ] && [ "$PROBE_CODE" != "206" ]; then + echo "::error::Build artifact $PROBE is listed on $RELEASE_TAG but not downloadable (HTTP $PROBE_CODE)." + echo "::error::$PROBE_URL" + exit 1 + fi + + echo "OK: $RELEASE_TAG publishes $BUILD_COUNT real build artifact(s) of $ASSET_COUNT assets;" + echo "OK: $PROBE downloads (HTTP $PROBE_CODE)." + + # The steps above only ever name $RELEASE_TAG, so a pointer move is not + # something this workflow can express. That is an argument, not evidence - + # so read the pointers back and assert the landed fact, exactly as the + # asset check above refuses to trust its own upload step. + - name: Verify no pointer release moved + if: always() + run: | + set -euo pipefail + + MOVED="" + for pointer in latest latest-prerelease; do + BEFORE="/tmp/pointer-$pointer.before.json" + if [ ! -f "$BEFORE" ]; then + echo "No recorded state for $pointer (the run stopped before the upload); nothing to compare." + continue + fi + gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$pointer" \ + | jq -S '{tag_name, name, target_commitish, created_at, published_at, + assets: [.assets[] | {name, size, updated_at}] | sort_by(.name)}' \ + > "/tmp/pointer-$pointer.after.json" + + if diff -u "$BEFORE" "/tmp/pointer-$pointer.after.json"; then + echo "OK: the $pointer release is byte-for-byte unchanged." + else + MOVED="$MOVED $pointer" + fi + done + + if [ -n "$MOVED" ]; then + echo "::error::Pointer release(s) changed during this repair:$MOVED" + echo "::error::This workflow never names them, so this is either a concurrent" + echo "::error::release.yml run or something that must be understood before" + echo "::error::trusting the repaired assets. Failing loudly." + exit 1 + fi + + echo "NOTE: no package registry was touched, and the job that ran the tagged" + echo "NOTE: build held no write capability at all. Publishing elsewhere is" + echo "NOTE: unrepresentable in this workflow, not merely disallowed." diff --git a/AGENTS.md b/AGENTS.md index bc5dc530..7ea2bdd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,13 +36,39 @@ gh -R minekube/connect-java release view --json tagName,targetCommitis curl -I -L --fail https://github.com/minekube/connect-java/releases/download//connect-velocity.jar ``` -- `release.yml`'s "Verify published release assets" step now enforces this in CI: - it re-reads each published release from the API (never the upload step's own - output) and fails when no real downloadable build jar landed, so metadata-only - assets like `LICENSE` cannot pass as a release. Pinned by +- `release.yml`'s "Verify published release assets" step re-reads each published + release from the API (never the upload step's own output) and rejects + metadata-only assets such as `LICENSE`. Its broad negative filter is + intentionally weaker than `release-repair.yml`'s positive plugin-jar + allowlist; tightening it is a separate follow-up. Pinned by `core/.../release/ReleaseAssetVerificationTest`; keep that test's step and upload-step names in sync when editing `release.yml`. +### Repairing a release that published no assets + +- Use `release-repair.yml` (default branch, manual dispatch). It builds at the + JDK that tag's own `release.yml` pinned and uploads only missing or broken + assets. Never dispatch `release.yml` at an old tag instead: it rewrites the + live `latest` release, dragging the stable `releases/download/latest/*.jar` + URLs backwards. Boundary and guards pinned by + `core/.../release/ReleaseRepairCapabilityTest`; keep its step names in sync. +- A repair EXECUTES old, unreviewed tagged source. Never collapse the workflow's + read-only `build` / write-only `publish` job boundary or substitute + step-level token scoping; the workflow comments and + `ReleaseRepairCapabilityTest` own the exact boundary, artifact-name + allowlist, race handling, and landed-verification details. Top-level + `permissions: {}` must remain explicit. +- Asset naming is per-era: tags up to 0.7.0 published version-suffixed jars + (`connect-spigot-0.6.2.jar`), 0.7.1 onwards publish bare names. The repair + derives which from the tag's own release workflow, so a repair does not rename. +- `0.6.0` and `0.7.0` are the only zero-asset releases and are **not** + repairable. Their `bungee/build.gradle.kts` requests `bungeecord-proxy` with + transitive deps, and `net.md-5:bungeecord-{api,log,protocol,query}` at + `1.20-R0.3-SNAPSHOT` / `1.21-R0.1-SNAPSHOT` are 404 on every repository those + tags declare - only `bungeecord-proxy` itself survives upstream. `main` avoids + this with `includeTransitiveDeps = false`; back-porting that into a tag would + change what the tag builds, so it is a rewrite, not a repair. + ## Injector Scoping (config availability) - The parent injector binds `ConfigHolder`; `ConnectPlatform.init()` populates it diff --git a/core/build.gradle.kts b/core/build.gradle.kts index cb6b5363..27ff7475 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { testImplementation("io.netty", "netty-transport", Versions.nettyVersion) testImplementation("io.netty", "netty-codec", Versions.nettyVersion) testImplementation("com.squareup.okhttp3:mockwebserver:4.9.3") - // Parses .github/workflows/release.yml in ReleaseAssetVerificationTest. + // Parses the release workflows in the release verification tests. testImplementation("org.yaml:snakeyaml:1.27") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -41,11 +41,14 @@ dependencies { tasks.test { useJUnitPlatform() - // ReleaseAssetVerificationTest asserts on the release workflow, so an edit - // to that workflow must re-run the tests instead of being served from cache. + // Release verification tests assert on the release workflows, so edits to + // those workflows must re-run the tests instead of being served from cache. inputs.file(rootProject.file(".github/workflows/release.yml")) .withPropertyName("releaseWorkflow") .withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootProject.file(".github/workflows/release-repair.yml")) + .withPropertyName("releaseRepairWorkflow") + .withPathSensitivity(PathSensitivity.RELATIVE) } relocate("org.bstats") diff --git a/core/src/test/java/com/minekube/connect/release/ReleaseRepairCapabilityTest.java b/core/src/test/java/com/minekube/connect/release/ReleaseRepairCapabilityTest.java new file mode 100644 index 00000000..2ec5e4af --- /dev/null +++ b/core/src/test/java/com/minekube/connect/release/ReleaseRepairCapabilityTest.java @@ -0,0 +1,584 @@ +/* + * Copyright (c) 2019-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * @author Minekube + * @link https://github.com/minekube/connect-java + */ + +package com.minekube.connect.release; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * {@code release-repair.yml} rebuilds an old tag and fills in the assets its original release never + * published (0.6.0 and 0.7.0 shipped with an empty asset list). + * + *

A repair necessarily executes old tagged source - a Gradle build and whatever its + * plugins resolve - that nobody reviewed as part of the repair. So the security question is not + * whether that build is trustworthy but what it can reach, and the answer cannot be "we scoped the + * token to individual steps": within one job a build step can append to {@code $GITHUB_PATH} or + * {@code $GITHUB_ENV} and have a later token-bearing step in that same job execute a tool it + * planted. Step-level {@code env:} is not a sandbox. The job is the boundary. + * + *

Hence the split these tests pin: a {@code build} job with {@code contents: read} that runs the + * tag, and a {@code publish} job with {@code contents: write} that checks nothing out and runs none + * of it. Without that split, a compromised historical build could reach a write-scoped token and + * move the live {@code latest} release - the stable {@code releases/download/latest/*.jar} URLs + * download sites hand out - silently downgrading every consumer. A comment claiming the boundary is + * an argument; these are the checks that can fail. + */ +class ReleaseRepairCapabilityTest { + + private static final Path WORKFLOW_PATH = + Paths.get("..", ".github", "workflows", "release-repair.yml"); + private static final Path REPOSITORY_GIT_PATH = Paths.get("..", ".git"); + + /** The job that runs untrusted tagged source. It must hold no write capability. */ + private static final String BUILD_JOB = "build"; + + /** The job that holds the only write capability. It must run no tagged source. */ + private static final String PUBLISH_JOB = "publish"; + + /** The live pointer releases whose download URLs are published to users. */ + private static final List POINTER_RELEASES = + Arrays.asList("latest", "latest-prerelease"); + + private static final String POINTER_REFUSAL = "Refuse to repair a pointer release"; + + private static String workflowText() throws Exception { + if (!Files.exists(WORKFLOW_PATH)) { + if (Files.exists(REPOSITORY_GIT_PATH)) { + throw new AssertionError( + WORKFLOW_PATH + " is missing from the repository checkout"); + } + assumeTrue(false, WORKFLOW_PATH + " is unavailable outside a repository checkout"); + return ""; + } + return new String(Files.readAllBytes(WORKFLOW_PATH), StandardCharsets.UTF_8); + } + + @SuppressWarnings("unchecked") + private static Map workflow() throws Exception { + if (workflowText().isEmpty()) { + return new LinkedHashMap<>(); + } + try (InputStream in = Files.newInputStream(WORKFLOW_PATH)) { + return (Map) new Yaml().load(in); + } + } + + @SuppressWarnings("unchecked") + private static Map job(String name) throws Exception { + Map workflow = workflow(); + if (workflow.isEmpty()) { + return new LinkedHashMap<>(); + } + Map jobs = (Map) workflow.get("jobs"); + assertTrue(jobs != null && jobs.containsKey(name), + "the " + name + " job is missing; the untrusted build and the write capability " + + "must live in separate jobs"); + return (Map) jobs.get(name); + } + + @SuppressWarnings("unchecked") + private static List> steps(String jobName) throws Exception { + Map job = job(jobName); + if (job.isEmpty()) { + return List.of(); + } + List> steps = (List>) job.get("steps"); + assertTrue(steps != null && !steps.isEmpty(), jobName + " job has no steps"); + return steps; + } + + private static int stepIndex(List> steps, String name) { + for (int i = 0; i < steps.size(); i++) { + if (name.equals(steps.get(i).get("name"))) { + return i; + } + } + return -1; + } + + private static String stepScript(List> steps, String name) { + int at = stepIndex(steps, name); + assertTrue(at >= 0, "missing the \"" + name + "\" step"); + Object run = steps.get(at).get("run"); + assertTrue(run instanceof String && !((String) run).isEmpty(), + "\"" + name + "\" must be a run step"); + return (String) run; + } + + @SuppressWarnings("unchecked") + private static Map stepEnv(Map step) { + Object env = step.get("env"); + return env instanceof Map ? (Map) env : Map.of(); + } + + private static String stepLabel(Map step) { + Object name = step.get("name"); + return name != null ? String.valueOf(name) : String.valueOf(step.get("uses")); + } + + /** Every `run` script in a job, concatenated - for whole-job content assertions. */ + private static String allScripts(List> steps) { + StringBuilder sb = new StringBuilder(); + for (Map step : steps) { + Object run = step.get("run"); + if (run instanceof String) { + sb.append((String) run).append('\n'); + } + } + return sb.toString(); + } + + /** + * THE split. This is the check that fails if anybody ever "simplifies" the workflow back into a + * single job, or grants the build job a write scope, or teaches the publish job to check + * something out. + */ + @Test + @SuppressWarnings("unchecked") + void repairSplitsUntrustedBuildFromTheWriteCapability() throws Exception { + Map workflow = workflow(); + assumeTrue(!workflow.isEmpty()); + + // Nothing may be inherited: an implicit repository default would hand both jobs whatever + // scopes happen to be enabled org-wide. + Object topLevel = workflow.get("permissions"); + assertTrue(topLevel instanceof Map && ((Map) topLevel).isEmpty(), + "top-level permissions must be {} so neither job inherits anything; found " + + topLevel); + + Map build = job(BUILD_JOB); + Map publish = job(PUBLISH_JOB); + + assertEquals(Map.of("contents", "read"), build.get("permissions"), + "the build job runs untrusted tagged source and must hold contents:read only; " + + "it holds " + build.get("permissions")); + assertEquals(Map.of("contents", "write"), publish.get("permissions"), + "the publish job must hold contents:write and nothing else; it holds " + + publish.get("permissions")); + + // Belt and braces: no job may hold any write scope other than the publish job's contents. + for (Object value : ((Map) build.get("permissions")).values()) { + assertTrue(!"write".equals(value), + "the build job grants a write scope; tagged source could reach it"); + } + + Object needs = publish.get("needs"); + List needsList = needs instanceof List + ? (List) needs : List.of(String.valueOf(needs)); + assertTrue(needsList.contains(BUILD_JOB), + "the publish job must depend on the build job; it needs " + needs); + + // The publish job must never materialise the tag: no checkout, no toolchain, no Gradle. + List> publishSteps = steps(PUBLISH_JOB); + for (Map step : publishSteps) { + Object uses = step.get("uses"); + if (uses instanceof String) { + String action = (String) uses; + assertTrue(!action.startsWith("actions/checkout@"), + "the publish job checks out source; it holds the write token and must " + + "never materialise the tag"); + assertTrue(!action.startsWith("actions/setup-java@") + && !action.startsWith("gradle/"), + "the publish job sets up a build toolchain (" + action + "); it must run " + + "none of the tagged source"); + } + } + String publishScripts = allScripts(publishSteps); + assertTrue(!publishScripts.contains("gradlew"), + "the publish job invokes Gradle; the write capability must never run tag code"); + + // And the write itself must live only there. + assertTrue(!allScripts(steps(BUILD_JOB)).contains("gh release upload"), + "the build job uploads release assets; the write must live in the publish job"); + assertTrue(publishScripts.contains("gh release upload"), + "the publish job never uploads any asset"); + } + + /** + * The {@code $GITHUB_PATH} vector specifically. Even inside the read-only build job, a + * token-bearing step placed after the tagged build could be made to execute a tool the + * build planted. Nothing in that job may carry a credential once tag code has run. + */ + @Test + void buildJobHoldsNoCredentialOnceTaggedCodeHasRun() throws Exception { + List> build = steps(BUILD_JOB); + assumeTrue(!build.isEmpty()); + + // A job-level token would be visible to the tagged build itself. + Object jobEnv = job(BUILD_JOB).get("env"); + if (jobEnv instanceof Map) { + assertTrue(!((Map) jobEnv).containsKey("GH_TOKEN"), + "the build job declares a job-level GH_TOKEN; the tagged build would see it"); + } + + int buildAt = stepIndex(build, "Build"); + assertTrue(buildAt >= 0, "the build job is missing the Build step"); + + List offenders = new ArrayList<>(); + for (int i = buildAt; i < build.size(); i++) { + if (stepEnv(build.get(i)).containsKey("GH_TOKEN")) { + offenders.add(stepLabel(build.get(i))); + } + } + assertTrue(offenders.isEmpty(), + "step(s) " + offenders + " carry GH_TOKEN at or after the tagged build; the build " + + "can plant a tool on $GITHUB_PATH that a later step in the same job " + + "executes, so no credential may follow it"); + } + + /** + * The artifact crossing the split was produced by a job that ran untrusted source, so its file + * names are untrusted input: they become the names of public release assets. Only what + * a connect release is supposed to carry may pass. + */ + @Test + void publishJobValidatesTheHandoverArtifact() throws Exception { + List> publish = steps(PUBLISH_JOB); + assumeTrue(!publish.isEmpty()); + + String validate = stepScript(publish, "Validate the downloaded assets"); + assertTrue(validate.contains("^connect-(spigot|velocity|bungee)"), + "the publish job does not constrain which asset names may be published"); + assertTrue(validate.contains("exit 1"), "the asset-name validation cannot fail the run"); + + int validateAt = stepIndex(publish, "Validate the downloaded assets"); + int uploadAt = stepIndex(publish, "Upload the missing assets"); + assertTrue(uploadAt >= 0, "the publish job is missing the upload step"); + assertTrue(validateAt < uploadAt, + "the handover artifact is validated after it is uploaded; that is not validation"); + } + + /** + * The registry half of the boundary. The permission checks above only prove the file does not + * name a registry scope; this proves it does not reach one another way - a registry + * login action, a docker/gradle publish, or a credential smuggled in through a secret other + * than the job's own GITHUB_TOKEN. + */ + @Test + void repairNeverPublishesToAPackageRegistry() throws Exception { + String text = workflowText(); + assumeTrue(!text.isEmpty()); + String lower = text.toLowerCase(Locale.ROOT); + + List registrySurfaces = Arrays.asList( + "packages: write", // the GitHub Packages scope + "id-token:", // OIDC federation into an external registry + "ghcr.io", // GitHub container registry + "docker/login-action", // registry login + "docker push", + "gradle-publish", + "maven-publish", + "publishtomavenlocal", + "publishallpublications", + "./gradlew publish", + "npm publish", + "sonatype", + "nexus"); + + List found = new ArrayList<>(); + for (String surface : registrySurfaces) { + if (lower.contains(surface)) { + found.add(surface); + } + } + assertTrue(found.isEmpty(), "release-repair.yml references registry publishing surface " + + found + "; the repair must be unable to push a build anywhere but this " + + "release's own assets"); + + // Any secret beyond GITHUB_TOKEN is a credential this workflow must not hold. If a repair + // genuinely needed one, that is a decision to escalate, not to quietly add here. + Matcher secrets = Pattern.compile("secrets\\.([A-Za-z0-9_]+)").matcher(text); + List extra = new ArrayList<>(); + while (secrets.find()) { + if (!"GITHUB_TOKEN".equals(secrets.group(1))) { + extra.add(secrets.group(1)); + } + } + assertTrue(extra.isEmpty(), + "release-repair.yml consumes secret(s) " + extra + " beyond GITHUB_TOKEN"); + } + + /** + * {@code latest} and {@code latest-prerelease} are not versions, they are the live pointers + * whose download URLs the release notes advertise. Repairing one would republish an old build at + * a stable URL and downgrade everyone using it, so both jobs refuse the names outright - the + * publish job most of all, because it is the one holding the write token. + */ + @Test + void repairCannotWriteAPointerRelease() throws Exception { + String text = workflowText(); + assumeTrue(!text.isEmpty()); + + for (String jobName : List.of(BUILD_JOB, PUBLISH_JOB)) { + List> jobSteps = steps(jobName); + String refusal = stepScript(jobSteps, POINTER_REFUSAL); + for (String pointer : POINTER_RELEASES) { + assertTrue(refusal.contains(pointer), + "the " + jobName + " job's pointer refusal does not reject \"" + pointer + + "\""); + } + assertTrue(refusal.contains("exit 1"), + "the " + jobName + " job's pointer refusal does not fail the run"); + assertEquals(0, stepIndex(jobSteps, POINTER_REFUSAL), + "the pointer refusal must be the first step of the " + jobName + " job"); + } + + // release.yml writes `tag_name: latest`. Nothing in a repair may. + assertTrue(!Pattern.compile("(?m)tag_name:\\s*latest").matcher(text).find(), + "release-repair.yml writes a pointer release via tag_name"); + + // Every asset upload must name the tag under repair and no other release. + Matcher uploads = Pattern.compile("gh release upload (\\S+)").matcher(text); + int uploadCount = 0; + while (uploads.find()) { + uploadCount++; + assertEquals("\"$RELEASE_TAG\"", uploads.group(1), + "release-repair.yml uploads to a release other than the tag under repair"); + } + assertTrue(uploadCount > 0, "release-repair.yml never uploads any asset"); + } + + /** + * Guard 1. Repair fills holes: a release that already publishes a plugin jar is complete, and + * {@code --clobber} over it would replace exactly the bytes server owners already run. The check + * has to precede the build, or a needless rebuild happens before it can say no. + */ + @Test + void repairRefusesToClobberACompleteRelease() throws Exception { + List> build = steps(BUILD_JOB); + assumeTrue(!build.isEmpty()); + + String guard = stepScript(build, "Refuse to repair a complete release"); + assertTrue(guard.contains("/releases/tags/"), + "the completeness guard must read the published release, not local state"); + assertTrue(guard.contains("connect-(spigot|velocity|bungee)"), + "the completeness guard must classify plugin jars by name; a release carrying " + + "only LICENSE has a positive asset count and is still a hole"); + assertTrue(guard.contains("exit 1"), "the completeness guard does not fail the run"); + + int guardAt = stepIndex(build, "Refuse to repair a complete release"); + int buildAt = stepIndex(build, "Build"); + assertTrue(buildAt >= 0, "the build job is missing the Build step"); + assertTrue(guardAt < buildAt, + "the completeness guard runs after the build; it must refuse before rebuilding"); + + // Guard 2, in the publish job: an ABSENT name uploads without --clobber, so GitHub's own + // rejection is the atomic conditional; --clobber may only reach a name already broken. + String upload = stepScript(steps(PUBLISH_JOB), "Upload the missing assets"); + assertTrue(upload.contains("--clobber"), "the upload does not use --clobber at all"); + assertTrue(upload.contains("ABSENT_LIST") && upload.contains("BROKEN_LIST"), + "the upload does not separate absent names from broken ones; --clobber would " + + "then be able to reach an asset a concurrent writer just published"); + assertTrue(Pattern.compile("gh release upload \"\\$RELEASE_TAG\" \"\\$\\{ABSENT_LIST\\[@\\]}\"" + + "(?!.*--clobber)").matcher(upload).find(), + "absent assets are uploaded with --clobber; that upload must be unconditional-" + + "free so GitHub rejects a name published concurrently"); + assertTrue(upload.contains("state == \"uploaded\"") && upload.contains("size > 0"), + "the upload does not classify already-good assets out of the clobber set"); + } + + /** + * A repair must build the tag on the toolchain that tag pinned. A newer runner-default JDK that + * fails to compile old tagged source condemns a perfectly buildable release - a false red is as + * wrong as a false green, and here it would be recorded as "unrecoverable". + */ + @Test + @SuppressWarnings("unchecked") + void repairBuildsAtTheTagsOwnPinnedToolchain() throws Exception { + List> build = steps(BUILD_JOB); + assumeTrue(!build.isEmpty()); + + String resolve = stepScript(build, "Resolve the tag's pinned Java toolchain"); + assertTrue(resolve.contains(".github/workflows/release.yml"), + "the toolchain must be read from the tag's own release workflow"); + assertTrue(resolve.contains("java-version"), "the toolchain step reads no Java version"); + assertTrue(resolve.contains("exit 1"), + "the toolchain step falls back to a default instead of failing; guessing a JDK " + + "produces a false result about whether the tag builds"); + + int at = stepIndex(build, "Set up the tag's JDK"); + assertTrue(at >= 0, "the build job is missing the JDK setup step"); + Map with = (Map) build.get(at).get("with"); + assertTrue(with != null, "the JDK setup step has no inputs"); + assertEquals("${{ steps.toolchain.outputs.java-version }}", + String.valueOf(with.get("java-version")), + "the JDK is hard-coded instead of taken from the tag's pin"); + + // The tag's checkout supplies its own Gradle wrapper; a pinned Gradle here would override it. + assertTrue(!workflowText().contains("gradle-version:"), + "release-repair.yml pins a Gradle version, overriding the tag's own wrapper"); + } + + /** + * The whole point of the R4 mechanism: never trust the run, read the artifact. A green upload + * step that published nothing looks exactly like a successful repair, so the guard goes back to + * the API and additionally proves a jar is served, not merely listed. + */ + @Test + void repairVerifiesTheLandedRelease() throws Exception { + List> publish = steps(PUBLISH_JOB); + assumeTrue(!publish.isEmpty()); + + String verify = stepScript(publish, "Verify published release assets"); + + List required = Arrays.asList( + "gh api", // reads the API, not the local build output + "/releases/tags/", // re-reads the published release by tag + "\"uploaded\"", // only fully-uploaded assets count + "releases/download/"); // proves a jar is actually served, not just listed + List missing = new ArrayList<>(); + for (String want : required) { + if (!verify.contains(want)) { + missing.add(want); + } + } + assertTrue(missing.isEmpty(), "the repair verification does not reference " + missing + + "; it must assert on the published release, not on local build output"); + + assertTrue(!verify.contains("build/libs") && !verify.contains("build/release"), + "the repair verification reads local build output; those files exist even when " + + "the upload never happened"); + + // Wrong-artifact-type is its own failure mode and the more dangerous one, because the + // release looks populated. A positive asset count must not be satisfiable by LICENSE, a + // checksum manifest, an SBOM or a source archive: only a real plugin jar counts. + assertTrue(verify.contains("^connect-(spigot|velocity|bungee).*\\\\.jar$"), + "the landed check does not require a real plugin jar by name; an asset such as " + + "source.tar.gz could satisfy it while no build landed"); + assertTrue(Pattern.compile("BUILD_COUNT\"\\s*-eq\\s*0").matcher(verify).find(), + "the repair verification does not fail on a zero build-artifact count"); + + // Unconditional: a guard that can be gated off is not a guard. + assertTrue(publish.get(stepIndex(publish, "Verify published release assets")).get("if") == null, + "the repair verification is conditional; it must always run"); + + assertTrue(stepIndex(publish, "Verify published release assets") + > stepIndex(publish, "Upload the missing assets"), + "the repair verification runs before the upload; it would see the old release"); + } + + /** + * "The workflow only names $RELEASE_TAG, so it cannot move a pointer" is an argument about the + * source. This is the check on the landed fact: record both pointer releases before anything is + * written and assert they are unchanged afterwards, so a move shows up as a red run rather than + * as a server owner's surprise downgrade. + */ + @Test + void repairProvesNoPointerReleaseMoved() throws Exception { + List> publish = steps(PUBLISH_JOB); + assumeTrue(!publish.isEmpty()); + + String before = stepScript(publish, "Record the live pointer releases"); + String after = stepScript(publish, "Verify no pointer release moved"); + for (String pointer : POINTER_RELEASES) { + assertTrue(before.contains(pointer) && after.contains(pointer), + "the pointer-immutability check does not cover \"" + pointer + "\""); + } + + assertTrue(before.contains("gh api") && after.contains("gh api"), + "the pointer-immutability check must read the releases from the API"); + assertTrue(after.contains("diff"), "the pointer check never compares before against after"); + assertTrue(after.contains("exit 1"), "the pointer check cannot fail the run"); + + int recordAt = stepIndex(publish, "Record the live pointer releases"); + int uploadAt = stepIndex(publish, "Upload the missing assets"); + int checkAt = stepIndex(publish, "Verify no pointer release moved"); + assertTrue(recordAt < uploadAt, + "the pointer baseline is recorded after the upload; it must predate every write " + + "this run performs"); + assertTrue(checkAt > uploadAt, + "the pointer check runs before the upload and would never observe a move"); + } + + /** + * Dispatch shape. Tags older than 0.7.1 have no {@code workflow_dispatch} trigger at all, and + * GitHub compiles a dispatched run from the workflow file at the dispatched ref - so the repair + * only works when it is dispatched from the default branch and checks the tag out itself. + */ + @Test + @SuppressWarnings("unchecked") + void repairIsDispatchedByTagAndChecksThatTagOut() throws Exception { + Map workflow = workflow(); + assumeTrue(!workflow.isEmpty()); + + // snakeyaml resolves the bare `on:` key as YAML 1.1 boolean true. + Object triggers = workflow.containsKey("on") ? workflow.get("on") : workflow.get(true); + assertTrue(triggers instanceof Map, "release-repair.yml declares no triggers"); + Map on = (Map) triggers; + + assertEquals(1, on.size(), + "release-repair.yml must be manual-dispatch only; it also triggers on " + on.keySet()); + assertTrue(on.containsKey("workflow_dispatch"), + "release-repair.yml is not a workflow_dispatch workflow"); + + Map dispatch = (Map) on.get("workflow_dispatch"); + Map inputs = (Map) dispatch.get("inputs"); + assertTrue(inputs != null && inputs.containsKey("release_tag"), + "release-repair.yml takes no release_tag input"); + assertEquals(Boolean.TRUE, ((Map) inputs.get("release_tag")).get("required"), + "release_tag must be required; an empty tag would repair the dispatch ref"); + + List> build = steps(BUILD_JOB); + Map checkout = null; + for (Map step : build) { + Object uses = step.get("uses"); + if (uses instanceof String && ((String) uses).startsWith("actions/checkout@")) { + checkout = step; + break; + } + } + assertTrue(checkout != null, "the build job never checks out the repository"); + Map with = (Map) checkout.get("with"); + assertTrue(with != null, "the checkout step has no inputs"); + assertEquals("${{ inputs.release_tag }}", String.valueOf(with.get("ref")), + "the checkout must build the requested tag, not the dispatch ref"); + assertEquals(Boolean.FALSE, with.get("persist-credentials"), + "the checkout persists credentials into .git/config, handing the tagged Gradle " + + "build a usable token"); + + // Provenance: the checked-out HEAD must be proven to be the tag, not a branch of that name. + String provenance = stepScript(build, "Confirm the checkout is the tagged commit"); + assertTrue(provenance.contains("refs/tags/") && provenance.contains("exit 1"), + "the repair does not prove it built the tagged commit"); + } +}