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
2 changes: 2 additions & 0 deletions .github/maven-release/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.gradle/
/__pycache__/
15 changes: 15 additions & 0 deletions .github/maven-release/README.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions .github/maven-release/build.gradle
Original file line number Diff line number Diff line change
@@ -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')
}
}
}
29 changes: 29 additions & 0 deletions .github/maven-release/releases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"repository": "MinecraftModDevelopmentMods/basePower",
"groupId": "zone.moddev.mc.basepower",
"artifactId": "basePower",
"description": "Shared power, fluid and item transport systems for baseSteam and baseElectric.",
"license": "GNU Lesser General Public License, Version 3",
"licenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt",
"releases": {
"2.4.4.110021": {
"branch": "master-1.10.2",
"commit": "a376e0c0de11cf5e6ffdbb2a87a8cf0d6b55a708",
"runId": 41615398501,
"artifactId": 13221291519,
"archiveSha256": "c3d0011c312abb9bdecf2c38e6adc6fc63393edd2bd58f4466825e2cef6ab119",
"curseFileId": 9408450,
"mainSha256": "164693e756a935a8c51085cf95aecccaf3506390a6f0a3888e46765f29896f31"
},
"2.4.4.112021": {
"branch": "master-1.12.2",
"commit": "1fc2fb3a409a5cf86a4133ee4d313002a5b8611e",
"tag": "2.4.4.112021",
"runId": 42005297779,
"artifactId": 13404931286,
"archiveSha256": "604ef22c74ab7835ddf63babac19b54abea3659e632a2712a0601a03d25d68c0",
"curseFileId": 9452029,
"mainSha256": "126e9d0969ce6146f45a59ee4d6ca2b5fdf29ecf559ba595dcc1a9cf29a743db"
}
}
}
1 change: 1 addition & 0 deletions .github/maven-release/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = 'mmd-maven-release'
141 changes: 141 additions & 0 deletions .github/maven-release/verify.py
Original file line number Diff line number Diff line change
@@ -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()
80 changes: 80 additions & 0 deletions .github/workflows/publish-maven.yml
Original file line number Diff line number Diff line change
@@ -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/basePower' && 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"
Loading