From 45227e15d3d28a2be30fcc7d3eff6bd604d7f94e Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 6 Nov 2026 13:21:55 +0000 Subject: [PATCH] Publish the same release to CurseForge and Maven --- .github/maven-release/README.md | 16 +++ .github/maven-release/verify.py | 47 +++++++-- .github/workflows/deploy-release.yml | 151 +++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/deploy-release.yml diff --git a/.github/maven-release/README.md b/.github/maven-release/README.md index 5e8d417..a27fab5 100644 --- a/.github/maven-release/README.md +++ b/.github/maven-release/README.md @@ -1,5 +1,21 @@ # MMD Maven releases +## New Forge releases + +Use **Release baseSteam** on the MMD repository's default branch. After the +version branch passes CI, tag the exact tested commit and add the CI run, artifact +ID, archive SHA-256 and tested main-jar SHA-256 to `releases.json`. Run +**check-only** first, then **publish**. The workflow uploads the checked player +jar to CurseForge, verifies the public download, and publishes those same bytes +to MMD Maven before creating the GitHub release. + +If CurseForge has accepted the file but it isn't public yet, wait for approval. +Start a new run with the recorded CurseForge file ID so it verifies that upload +without submitting the jar again. The release workflow currently covers Java 8 +Forge targets only. + +## Existing CurseForge releases + Use **Actions > Publish to MMD Maven** on the MMD repository's default branch. Select a version listed in `releases.json` and run **check-only** first. Once it passes, run **publish** for that same version. diff --git a/.github/maven-release/verify.py b/.github/maven-release/verify.py index fd57ec1..b7b5eeb 100644 --- a/.github/maven-release/verify.py +++ b/.github/maven-release/verify.py @@ -1,4 +1,4 @@ -"""Check an approved release without rebuilding it or uploading to CurseForge.""" +"""Stage checksum-sealed CI files and verify the public release destinations.""" import hashlib import json @@ -46,7 +46,7 @@ def github(path): return subprocess.check_output(["gh", "api", path]) -def stage(manifest, version, release, work): +def stage(manifest, version, release, work, candidate=False): repo = manifest["repository"] require(os.environ.get("GITHUB_REPOSITORY", repo) == repo, "Wrong source repository") artifact_id = release["artifactId"] @@ -54,6 +54,8 @@ def stage(manifest, version, release, work): require(ci["head_sha"] == release["commit"] and ci["conclusion"] == "success" and ci["path"] == ".github/workflows/ci.yml" and ci["event"] == "push" and ci["head_branch"] == release["branch"], "CI provenance changed") + if candidate: + require(release.get("tag") == version, "New publication requires the exact release tag") if release.get("tag"): ref = json.loads(github(f"repos/{repo}/commits/{release['tag']}")) require(ref["sha"] == release["commit"], "Release tag moved") @@ -75,18 +77,42 @@ def stage(manifest, version, release, work): require(name not in sums and re.fullmatch(r"[a-fA-F0-9]{64}", sha), "Malformed CI checksums") sums[name] = sha.lower() require(set(sums) == set(jars), "CI did not seal exactly the three release jars") - require(sums[jars[0]] == release["mainSha256"], "CI main jar does not match the published release") + require(sums[jars[0]] == release["mainSha256"], "CI main jar does not match the approved release") for name in jars: member = "build/libs/" + name require(bundle.namelist().count(member) == 1, f"Missing or duplicate CI jar: {name}") data = bundle.read(member) require(digest(data) == sums[name], f"Bad CI checksum: {name}") (work / name).write_bytes(data) - file_id = release["curseFileId"] - public = read_url(f"https://edge.forgecdn.net/files/{file_id // 1000}/{file_id % 1000}/{jars[0]}") - require(digest(public) == release["mainSha256"], "CurseForge bytes changed") + if candidate: + changelog = bundle.read("CHANGELOG.txt").decode("utf-8-sig").strip() + require(version in changelog.splitlines()[0], "Changelog does not start with the release version") + notes = "\n\n".join(changelog.split("\n\n", 2)[:2]) + (work / "release-notes.txt").write_text(notes + "\n", encoding="utf-8") (work / "verified.json").write_text(json.dumps(sums), encoding="ascii") - print(f"CurseForge file {file_id} matches {jars[0]}: {release['mainSha256']}") + if not candidate: + verify_curseforge(manifest, version, release, release["curseFileId"]) + + +def verify_curseforge(manifest, version, release, file_id, project_id=None): + require(type(file_id) is int and file_id > 0, "Invalid CurseForge file ID") + jar = jar_names(manifest, version)[0] + if project_id is not None: + url = f"https://www.curseforge.com/api/v1/mods/{project_id}/files/{file_id}/download" + else: + url = f"https://edge.forgecdn.net/files/{file_id // 1000}/{file_id % 1000}/{jar}" + for delay in (0, 5, 15, 30, 60): + time.sleep(delay if project_id is not None else 0) + try: + public = read_url(url) + except HTTPError as error: + if error.code == 404 and project_id is not None: + continue + raise + require(digest(public) == release["mainSha256"], "CurseForge bytes changed") + print(f"CurseForge file {file_id} matches {jar}: {release['mainSha256']}") + return + raise ValueError(f"CurseForge file {file_id} is not public yet; resume with that file ID") def jar_names(manifest, version): @@ -135,8 +161,11 @@ def main(): manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) require(version in manifest["releases"], f"Version is not approved: {version}") release, work = manifest["releases"][version], Path(folder) - if mode == "stage": - stage(manifest, version, release, work) + if mode in ("stage", "stage-candidate"): + stage(manifest, version, release, work, candidate=mode == "stage-candidate") + elif mode == "verify-curseforge": + verify_curseforge(manifest, version, release, int(os.environ["CURSEFORGE_FILE_ID"]), + int(os.environ["CURSEFORGE_PROJECT_ID"])) elif mode in ("check", "require-absent"): state = destination_state(manifest, version, release, work) require(mode == "check" or state == "absent", "Destination is not empty") diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 0000000..0816847 --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,151 @@ +name: Release baseSteam +run-name: ${{ inputs.mode }} baseSteam ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: Approved version from .github/maven-release/releases.json + required: true + type: string + mode: + description: Check the exact release, or publish it + required: true + default: check-only + type: choice + options: [check-only, publish] + existing_curseforge_file_id: + description: Resume with an already uploaded CurseForge file (never upload it again) + required: false + type: string + +permissions: + actions: read + contents: write + +defaults: + run: + shell: bash + +concurrency: + group: mmd-maven-publication + cancel-in-progress: false + +jobs: + release: + name: Publish one verified release to CurseForge, Maven, and GitHub + if: github.repository == 'MinecraftModDevelopmentMods/baseSteam' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + RELEASE_VERSION: ${{ inputs.version }} + EXISTING_CF_FILE: ${{ inputs.existing_curseforge_file_id }} + CURSEFORGE_PROJECT_ID: '1781373' + GH_TOKEN: ${{ github.token }} + steps: + - name: Check out the publication workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install Java for the Maven publisher + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17' + + - name: Verify the tag, CI artifact, and Maven destination + id: preflight + run: | + set -euo pipefail + [[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ -z "$EXISTING_CF_FILE" || "$EXISTING_CF_FILE" =~ ^[1-9][0-9]*$ ]] + release_work="$RUNNER_TEMP/release" + python3 .github/maven-release/verify.py stage-candidate "$RELEASE_VERSION" "$release_work" + bash gradlew -p .github/maven-release generatePomFileForMavenJavaPublication \ + "-PreleaseVersion=$RELEASE_VERSION" "-PreleaseWork=$release_work" --no-daemon + state=$(python3 .github/maven-release/verify.py check "$RELEASE_VERSION" "$release_work") + if [[ "$state" == present && -z "$EXISTING_CF_FILE" ]]; then + echo "Maven already has this release. Supply its existing CurseForge file ID." >&2 + exit 1 + fi + mc=$(jq -er --arg v "$RELEASE_VERSION" '.releases[$v].branch | capture("^master-(?1\\.[0-9]+\\.[0-9]+)$").mc' .github/maven-release/releases.json) + case "$mc" in + 1.10.2|1.12.2|1.13.2|1.14.4|1.15.2|1.16.5) ;; + *) echo "This publisher only handles the Java 8 Forge releases" >&2; exit 1 ;; + esac + echo "minecraft=$mc" >> "$GITHUB_OUTPUT" + echo "state=$state" >> "$GITHUB_OUTPUT" + echo "RELEASE_WORK=$release_work" >> "$GITHUB_ENV" + echo "Verified $RELEASE_VERSION for Minecraft $mc. Maven: $state." >> "$GITHUB_STEP_SUMMARY" + + - name: Check publication credentials + if: inputs.mode == 'publish' + env: + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }} + MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }} + MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }} + run: | + set -euo pipefail + test -n "$EXISTING_CF_FILE" || test -n "$CURSEFORGE_TOKEN" + test -n "$MAVEN_UPLOAD_USERNAME" && test -n "$MAVEN_UPLOAD_PASSWORD" + [[ "$MAVEN_UPLOAD_URL" == https://maven.moddev.zone/releases || "$MAVEN_UPLOAD_URL" == https://maven.moddev.zone/releases/ ]] + if [[ -z "$EXISTING_CF_FILE" && "$GITHUB_RUN_ATTEMPT" != 1 ]]; then + echo "Do not retry an upload. Start a new dispatch with the existing file ID." >&2 + exit 1 + fi + + - name: Upload the player jar to CurseForge + if: inputs.mode == 'publish' && inputs.existing_curseforge_file_id == '' + id: curseforge + uses: Kira-NT/mc-publish@52307b03863581dec6b652b83e597aec02ebb075 # v3.3.1 + with: + curseforge-id: '1781373' + curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} + name: baseSteam ${{ inputs.version }} for Minecraft ${{ steps.preflight.outputs.minecraft }} (Forge) + version: ${{ inputs.version }} + version-type: release + changelog-file: ${{ env.RELEASE_WORK }}/release-notes.txt + files: ${{ env.RELEASE_WORK }}/baseSteam-${{ inputs.version }}.jar + game-versions: ${{ steps.preflight.outputs.minecraft }} + game-version-filter: none + loaders: forge + environment: client | server + java: Java 8 + curseforge-dependencies: | + 1780140(required) + 240967(required) + fail-mode: fail + + - name: Verify the public CurseForge download + if: inputs.mode == 'publish' || inputs.existing_curseforge_file_id != '' + env: + UPLOADED_CF_FILE: ${{ steps.curseforge.outputs.curseforge-version }} + run: | + set -euo pipefail + export CURSEFORGE_FILE_ID="${EXISTING_CF_FILE:-$UPLOADED_CF_FILE}" + [[ "$CURSEFORGE_FILE_ID" =~ ^[1-9][0-9]*$ ]] + echo "CurseForge file ID: $CURSEFORGE_FILE_ID. Use this ID to resume if approval is pending." >> "$GITHUB_STEP_SUMMARY" + python3 .github/maven-release/verify.py verify-curseforge "$RELEASE_VERSION" "$RELEASE_WORK" + + - name: Publish the same release to MMD Maven + if: inputs.mode == 'publish' && steps.preflight.outputs.state == 'absent' + env: + MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }} + MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }} + MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }} + run: | + set -euo pipefail + python3 .github/maven-release/verify.py require-absent "$RELEASE_VERSION" "$RELEASE_WORK" + bash gradlew -p .github/maven-release publishMavenJavaPublicationToReleaseRepository \ + "-PreleaseVersion=$RELEASE_VERSION" "-PreleaseWork=$RELEASE_WORK" --no-daemon + + - name: Verify Maven and publish the GitHub release + if: inputs.mode == 'publish' + run: | + set -euo pipefail + python3 .github/maven-release/verify.py verify "$RELEASE_VERSION" "$RELEASE_WORK" | tee -a "$GITHUB_STEP_SUMMARY" + gh release create "$RELEASE_VERSION" --verify-tag \ + --title "baseSteam $RELEASE_VERSION" --notes-file "$RELEASE_WORK/release-notes.txt" \ + "$RELEASE_WORK/"*.jar