diff --git a/.github/maven-release/.gitignore b/.github/maven-release/.gitignore new file mode 100644 index 0000000..0d997af --- /dev/null +++ b/.github/maven-release/.gitignore @@ -0,0 +1,2 @@ +/.gradle/ +/__pycache__/ diff --git a/.github/maven-release/README.md b/.github/maven-release/README.md new file mode 100644 index 0000000..5e8d417 --- /dev/null +++ b/.github/maven-release/README.md @@ -0,0 +1,15 @@ +# MMD Maven 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. + +This workflow publishes the exact main, sources and Javadoc jars retained by the +successful CI run, plus a POM. It checks the archive and jar checksums and +compares the main jar with the existing CurseForge download. It does not build +the mod, move tags, change versions or upload to CurseForge. An identical Maven +release is a no-op; different bytes or a partial upload need investigation. + +To add a release, record its CurseForge file ID, CI run, source commit, artifact +ID and archive checksum in `releases.json` after checking the public download. +Include its tag when one exists. The CI archive must still be available. diff --git a/.github/maven-release/build.gradle b/.github/maven-release/build.gradle new file mode 100644 index 0000000..b919f12 --- /dev/null +++ b/.github/maven-release/build.gradle @@ -0,0 +1,66 @@ +import groovy.json.JsonSlurper + +plugins { + id 'maven-publish' +} + +def manifest = new JsonSlurper().parse(file('releases.json')) +def releaseVersion = providers.gradleProperty('releaseVersion').get() +if (!manifest.releases.containsKey(releaseVersion)) { + throw new GradleException("Version is not approved for Maven publication: $releaseVersion") +} +def work = file(providers.gradleProperty('releaseWork').get()) +def stem = manifest.artifactId + '-' + releaseVersion +layout.buildDirectory.set(new File(work, 'gradle')) + +def destination = 'https://maven.moddev.zone/releases' +def uploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL').orElse('').get() +def uploadUser = providers.environmentVariable('MAVEN_UPLOAD_USERNAME').orElse('').get() +def uploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD').orElse('').get() + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = manifest.groupId + artifactId = manifest.artifactId + version = releaseVersion + artifact new File(work, stem + '.jar') + artifact(new File(work, stem + '-sources.jar')) { classifier = 'sources' } + artifact(new File(work, stem + '-javadoc.jar')) { classifier = 'javadoc' } + pom { + name = manifest.artifactId + description = manifest.description + url = "https://github.com/" + manifest.repository + licenses { + license { + name = manifest.license + url = manifest.licenseUrl + } + } + scm { + url = "https://github.com/" + manifest.repository + connection = "scm:git:https://github.com/" + manifest.repository + ".git" + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(destination) + credentials { + username = uploadUser + password = uploadPassword + } + authentication { basic(BasicAuthentication) } + } + } +} + +tasks.withType(PublishToMavenRepository).configureEach { + doFirst { + if (!(uploadUrl in [destination, destination + '/']) || !uploadUser || !uploadPassword) { + throw new GradleException('MMD Maven destination and credentials must match the release repository') + } + } +} diff --git a/.github/maven-release/releases.json b/.github/maven-release/releases.json new file mode 100644 index 0000000..a770f16 --- /dev/null +++ b/.github/maven-release/releases.json @@ -0,0 +1,29 @@ +{ + "repository": "MinecraftModDevelopmentMods/baseSteam", + "groupId": "zone.moddev.mc.basesteam", + "artifactId": "baseSteam", + "description": "Steam generation, pipework and machinery for basePower.", + "license": "GNU General Public License, Version 3", + "licenseUrl": "https://www.gnu.org/licenses/gpl-3.0.txt", + "releases": { + "2.2.6.110021": { + "branch": "master-1.10.2", + "commit": "0dfbdcc9ed1069c5037dba1a59b8bbb1f496a787", + "runId": 41803894182, + "artifactId": 13309535752, + "archiveSha256": "d3b87634afedc4e7528445d33e70790618a9c7024e45a76b5b902d19d5cac99f", + "curseFileId": 9420177, + "mainSha256": "728e37a1a0551596d8167976a7022e31ef075a383640bb385470160dd9a8f07d" + }, + "2.2.6.112021": { + "branch": "master-1.12.2", + "commit": "0556cb4156ce27ab59c6f9bab09b5d0b5141bfaa", + "tag": "2.2.6.112021", + "runId": 42009983263, + "artifactId": 13405624215, + "archiveSha256": "70a34d540f1a3b9398754029bd2254472a0e4ffdb9de264e536d8150a955f005", + "curseFileId": 9452305, + "mainSha256": "fffed5310a2fe795e6ab0495705d28df16f18e05fc4c7d9efaa63d481f16f39f" + } + } +} diff --git a/.github/maven-release/settings.gradle b/.github/maven-release/settings.gradle new file mode 100644 index 0000000..ec783f8 --- /dev/null +++ b/.github/maven-release/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'mmd-maven-release' diff --git a/.github/maven-release/verify.py b/.github/maven-release/verify.py new file mode 100644 index 0000000..269569d --- /dev/null +++ b/.github/maven-release/verify.py @@ -0,0 +1,141 @@ +"""Check an approved release without rebuilding it or uploading to CurseForge.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen +from xml.etree import ElementTree +from zipfile import ZipFile + + +MAVEN = "https://maven.moddev.zone/releases" +MANIFEST = Path(__file__).with_name("releases.json") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def read_url(url, allow_absent=False): + try: + request = Request(url, headers={"User-Agent": "MMD-Maven-Release/1.0"}) + with urlopen(request, timeout=30) as response: + require(response.status == 200, f"Unexpected HTTP status for {url}") + return response.read() + except HTTPError as error: + if error.code == 404 and allow_absent: + return None + raise + + +def github(path): + return subprocess.check_output(["gh", "api", path]) + + +def stage(manifest, version, release, work): + 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 release.get("tag"): + ref = json.loads(github(f"repos/{repo}/commits/{release['tag']}")) + require(ref["sha"] == release["commit"], "Release tag moved") + details = json.loads(github(f"repos/{repo}/actions/artifacts/{artifact_id}")) + require(details["workflow_run"]["id"] == release["runId"] + and details["digest"] == "sha256:" + release["archiveSha256"] + and not details["expired"], "Expected unexpired, checksum-sealed CI artifact") + work.mkdir(parents=True, exist_ok=False) + archive = work / "ci.zip" + with archive.open("xb") as out: + subprocess.run(["gh", "api", f"repos/{repo}/actions/artifacts/{artifact_id}/zip"], stdout=out, check=True) + require(digest(archive.read_bytes()) == release["archiveSha256"], "CI archive hash changed") + + jars = jar_names(manifest, version) + with ZipFile(archive) as bundle: + sums = {} + for line in bundle.read("build/release/SHA256SUMS").decode("ascii").splitlines(): + sha, name = line.split(maxsplit=1) + 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") + 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") + (work / "verified.json").write_text(json.dumps(sums), encoding="ascii") + print(f"CurseForge file {file_id} matches {jars[0]}: {release['mainSha256']}") + + +def jar_names(manifest, version): + stem = manifest["artifactId"] + "-" + version + return [stem + suffix for suffix in (".jar", "-sources.jar", "-javadoc.jar")] + + +def destination_state(manifest, version, release, work, verify_metadata=False): + jars = jar_names(manifest, version) + sums = json.loads((work / "verified.json").read_text(encoding="ascii")) + require(sums[jars[0]] == release["mainSha256"], "Prepared release does not match the manifest") + pom = jars[0].removesuffix(".jar") + ".pom" + sums[pom] = digest((work / "gradle/publications/mavenJava/pom-default.xml").read_bytes()) + directory = f"{MAVEN}/{manifest['groupId'].replace('.', '/')}/{manifest['artifactId']}" + present = 0 + for name, sha in sums.items(): + if name in jars: + require(digest((work / name).read_bytes()) == sha, f"Prepared file changed: {name}") + remote = read_url(f"{directory}/{version}/{name}", allow_absent=True) + if remote is not None: + require(digest(remote) == sha, f"Maven already contains different bytes: {name}") + present += 1 + require(present in (0, len(sums)), "Maven version is partially populated; refusing automatic overwrite") + if verify_metadata and present: + metadata = ElementTree.fromstring(read_url(f"{directory}/maven-metadata.xml")) + require(version in [node.text for node in metadata.findall("versioning/versions/version")], + "Published version is missing from Maven metadata") + return "present" if present else "absent" + + +def main(): + mode, version, folder = sys.argv[1:] + 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) + elif mode in ("check", "require-absent"): + state = destination_state(manifest, version, release, work) + require(mode == "check" or state == "absent", "Destination is not empty") + print(state) + elif mode == "verify": + for delay in (0, 2, 5, 10): + time.sleep(delay) + if destination_state(manifest, version, release, work, verify_metadata=True) == "present": + print(f"MMD Maven verified: {manifest['groupId']}:{manifest['artifactId']}:{version}") + print(f"Main jar SHA-256 matches CurseForge: {release['mainSha256']}") + return + raise ValueError("Publication is not publicly readable") + else: + raise ValueError(f"Unknown mode: {mode}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml new file mode 100644 index 0000000..358d56a --- /dev/null +++ b/.github/workflows/publish-maven.yml @@ -0,0 +1,80 @@ +name: Publish to MMD Maven + +on: + workflow_dispatch: + inputs: + version: + description: Approved version from .github/maven-release/releases.json + required: true + type: string + mode: + description: Verify the release, or publish it to MMD Maven + default: check-only + required: true + type: choice + options: + - check-only + - publish + +permissions: + actions: read + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: mmd-maven-publication + cancel-in-progress: false + +jobs: + maven: + name: Verify and publish Maven artifacts + if: github.repository == 'MinecraftModDevelopmentMods/baseSteam' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_WORK: ${{ runner.temp }}/maven-release + steps: + - name: Check out the publication workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install Java for the standalone Gradle publisher + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17' + + - name: Verify CurseForge and the sealed CI artifacts + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/maven-release/verify.py stage "$RELEASE_VERSION" "$RELEASE_WORK" + + - name: Generate the POM and check the public destination + id: preflight + run: | + 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") + echo "state=$state" >> "$GITHUB_OUTPUT" + echo "Verified the CI and CurseForge bytes for $RELEASE_VERSION. Maven destination: $state." >> "$GITHUB_STEP_SUMMARY" + + - name: Publish only 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: | + 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 public Maven downloads + if: inputs.mode == 'publish' + run: | + python3 .github/maven-release/verify.py verify "$RELEASE_VERSION" "$RELEASE_WORK" | tee -a "$GITHUB_STEP_SUMMARY"