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
16 changes: 16 additions & 0 deletions .github/maven-release/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
47 changes: 38 additions & 9 deletions .github/maven-release/verify.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -46,14 +46,16 @@ 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"]
ci = json.loads(github(f"repos/{repo}/actions/runs/{release['runId']}"))
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")
Expand All @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
151 changes: 151 additions & 0 deletions .github/workflows/deploy-release.yml
Original file line number Diff line number Diff line change
@@ -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-(?<mc>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
Loading