Skip to content

Commit dc61bd9

Browse files
committed
Add a Maven-only release workflow
1 parent a376e0c commit dc61bd9

7 files changed

Lines changed: 334 additions & 0 deletions

File tree

‎.github/maven-release/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/.gradle/
2+
/__pycache__/

‎.github/maven-release/README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# MMD Maven releases
2+
3+
Use **Actions > Publish to MMD Maven** on the MMD repository's default branch.
4+
Select a version listed in `releases.json` and run **check-only** first. Once it
5+
passes, run **publish** for that same version.
6+
7+
This workflow publishes the exact main, sources and Javadoc jars retained by the
8+
successful CI run, plus a POM. It checks the archive and jar checksums and
9+
compares the main jar with the existing CurseForge download. It does not build
10+
the mod, move tags, change versions or upload to CurseForge. An identical Maven
11+
release is a no-op; different bytes or a partial upload need investigation.
12+
13+
To add a release, record its CurseForge file ID, CI run, source commit, artifact
14+
ID and archive checksum in `releases.json` after checking the public download.
15+
Include its tag when one exists. The CI archive must still be available.

‎.github/maven-release/build.gradle‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import groovy.json.JsonSlurper
2+
3+
plugins {
4+
id 'maven-publish'
5+
}
6+
7+
def manifest = new JsonSlurper().parse(file('releases.json'))
8+
def releaseVersion = providers.gradleProperty('releaseVersion').get()
9+
if (!manifest.releases.containsKey(releaseVersion)) {
10+
throw new GradleException("Version is not approved for Maven publication: $releaseVersion")
11+
}
12+
def work = file(providers.gradleProperty('releaseWork').get())
13+
def stem = manifest.artifactId + '-' + releaseVersion
14+
layout.buildDirectory.set(new File(work, 'gradle'))
15+
16+
def destination = 'https://maven.moddev.zone/releases'
17+
def uploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL').orElse('').get()
18+
def uploadUser = providers.environmentVariable('MAVEN_UPLOAD_USERNAME').orElse('').get()
19+
def uploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD').orElse('').get()
20+
21+
publishing {
22+
publications {
23+
mavenJava(MavenPublication) {
24+
groupId = manifest.groupId
25+
artifactId = manifest.artifactId
26+
version = releaseVersion
27+
artifact new File(work, stem + '.jar')
28+
artifact(new File(work, stem + '-sources.jar')) { classifier = 'sources' }
29+
artifact(new File(work, stem + '-javadoc.jar')) { classifier = 'javadoc' }
30+
pom {
31+
name = manifest.artifactId
32+
description = manifest.description
33+
url = "https://github.com/" + manifest.repository
34+
licenses {
35+
license {
36+
name = manifest.license
37+
url = manifest.licenseUrl
38+
}
39+
}
40+
scm {
41+
url = "https://github.com/" + manifest.repository
42+
connection = "scm:git:https://github.com/" + manifest.repository + ".git"
43+
}
44+
}
45+
}
46+
}
47+
repositories {
48+
maven {
49+
name = 'release'
50+
url = uri(destination)
51+
credentials {
52+
username = uploadUser
53+
password = uploadPassword
54+
}
55+
authentication { basic(BasicAuthentication) }
56+
}
57+
}
58+
}
59+
60+
tasks.withType(PublishToMavenRepository).configureEach {
61+
doFirst {
62+
if (!(uploadUrl in [destination, destination + '/']) || !uploadUser || !uploadPassword) {
63+
throw new GradleException('MMD Maven destination and credentials must match the release repository')
64+
}
65+
}
66+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"repository": "MinecraftModDevelopmentMods/basePower",
3+
"groupId": "zone.moddev.mc.basepower",
4+
"artifactId": "basePower",
5+
"description": "Shared power, fluid and item transport systems for baseSteam and baseElectric.",
6+
"license": "GNU Lesser General Public License, Version 3",
7+
"licenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt",
8+
"releases": {
9+
"2.4.4.110021": {
10+
"branch": "master-1.10.2",
11+
"commit": "a376e0c0de11cf5e6ffdbb2a87a8cf0d6b55a708",
12+
"runId": 41615398501,
13+
"artifactId": 13221291519,
14+
"archiveSha256": "c3d0011c312abb9bdecf2c38e6adc6fc63393edd2bd58f4466825e2cef6ab119",
15+
"curseFileId": 9408450,
16+
"mainSha256": "164693e756a935a8c51085cf95aecccaf3506390a6f0a3888e46765f29896f31"
17+
},
18+
"2.4.4.112021": {
19+
"branch": "master-1.12.2",
20+
"commit": "1fc2fb3a409a5cf86a4133ee4d313002a5b8611e",
21+
"tag": "2.4.4.112021",
22+
"runId": 42005297779,
23+
"artifactId": 13404931286,
24+
"archiveSha256": "604ef22c74ab7835ddf63babac19b54abea3659e632a2712a0601a03d25d68c0",
25+
"curseFileId": 9452029,
26+
"mainSha256": "126e9d0969ce6146f45a59ee4d6ca2b5fdf29ecf559ba595dcc1a9cf29a743db"
27+
}
28+
}
29+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rootProject.name = 'mmd-maven-release'

‎.github/maven-release/verify.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Check an approved release without rebuilding it or uploading to CurseForge."""
2+
3+
import hashlib
4+
import json
5+
import os
6+
from pathlib import Path
7+
import re
8+
import subprocess
9+
import sys
10+
import time
11+
from urllib.error import HTTPError
12+
from urllib.request import Request, urlopen
13+
from xml.etree import ElementTree
14+
from zipfile import ZipFile
15+
16+
17+
MAVEN = "https://maven.moddev.zone/releases"
18+
MANIFEST = Path(__file__).with_name("releases.json")
19+
20+
21+
def require(condition, message):
22+
if not condition:
23+
raise ValueError(message)
24+
25+
26+
def digest(data):
27+
return hashlib.sha256(data).hexdigest()
28+
29+
30+
def read_url(url, allow_absent=False):
31+
try:
32+
request = Request(url, headers={"User-Agent": "MMD-Maven-Release/1.0"})
33+
with urlopen(request, timeout=30) as response:
34+
require(response.status == 200, f"Unexpected HTTP status for {url}")
35+
return response.read()
36+
except HTTPError as error:
37+
if error.code == 404 and allow_absent:
38+
return None
39+
raise
40+
41+
42+
def github(path):
43+
return subprocess.check_output(["gh", "api", path])
44+
45+
46+
def stage(manifest, version, release, work):
47+
repo = manifest["repository"]
48+
require(os.environ.get("GITHUB_REPOSITORY", repo) == repo, "Wrong source repository")
49+
artifact_id = release["artifactId"]
50+
ci = json.loads(github(f"repos/{repo}/actions/runs/{release['runId']}"))
51+
require(ci["head_sha"] == release["commit"] and ci["conclusion"] == "success"
52+
and ci["path"] == ".github/workflows/ci.yml" and ci["event"] == "push"
53+
and ci["head_branch"] == release["branch"], "CI provenance changed")
54+
if release.get("tag"):
55+
ref = json.loads(github(f"repos/{repo}/commits/{release['tag']}"))
56+
require(ref["sha"] == release["commit"], "Release tag moved")
57+
details = json.loads(github(f"repos/{repo}/actions/artifacts/{artifact_id}"))
58+
require(details["workflow_run"]["id"] == release["runId"]
59+
and details["digest"] == "sha256:" + release["archiveSha256"]
60+
and not details["expired"], "Expected unexpired, checksum-sealed CI artifact")
61+
work.mkdir(parents=True, exist_ok=False)
62+
archive = work / "ci.zip"
63+
with archive.open("xb") as out:
64+
subprocess.run(["gh", "api", f"repos/{repo}/actions/artifacts/{artifact_id}/zip"], stdout=out, check=True)
65+
require(digest(archive.read_bytes()) == release["archiveSha256"], "CI archive hash changed")
66+
67+
jars = jar_names(manifest, version)
68+
with ZipFile(archive) as bundle:
69+
sums = {}
70+
for line in bundle.read("build/release/SHA256SUMS").decode("ascii").splitlines():
71+
sha, name = line.split(maxsplit=1)
72+
require(name not in sums and re.fullmatch(r"[a-fA-F0-9]{64}", sha), "Malformed CI checksums")
73+
sums[name] = sha.lower()
74+
require(set(sums) == set(jars), "CI did not seal exactly the three release jars")
75+
require(sums[jars[0]] == release["mainSha256"], "CI main jar does not match the published release")
76+
for name in jars:
77+
member = "build/libs/" + name
78+
require(bundle.namelist().count(member) == 1, f"Missing or duplicate CI jar: {name}")
79+
data = bundle.read(member)
80+
require(digest(data) == sums[name], f"Bad CI checksum: {name}")
81+
(work / name).write_bytes(data)
82+
file_id = release["curseFileId"]
83+
public = read_url(f"https://edge.forgecdn.net/files/{file_id // 1000}/{file_id % 1000}/{jars[0]}")
84+
require(digest(public) == release["mainSha256"], "CurseForge bytes changed")
85+
(work / "verified.json").write_text(json.dumps(sums), encoding="ascii")
86+
print(f"CurseForge file {file_id} matches {jars[0]}: {release['mainSha256']}")
87+
88+
89+
def jar_names(manifest, version):
90+
stem = manifest["artifactId"] + "-" + version
91+
return [stem + suffix for suffix in (".jar", "-sources.jar", "-javadoc.jar")]
92+
93+
94+
def destination_state(manifest, version, release, work, verify_metadata=False):
95+
jars = jar_names(manifest, version)
96+
sums = json.loads((work / "verified.json").read_text(encoding="ascii"))
97+
require(sums[jars[0]] == release["mainSha256"], "Prepared release does not match the manifest")
98+
pom = jars[0].removesuffix(".jar") + ".pom"
99+
sums[pom] = digest((work / "gradle/publications/mavenJava/pom-default.xml").read_bytes())
100+
directory = f"{MAVEN}/{manifest['groupId'].replace('.', '/')}/{manifest['artifactId']}"
101+
present = 0
102+
for name, sha in sums.items():
103+
if name in jars:
104+
require(digest((work / name).read_bytes()) == sha, f"Prepared file changed: {name}")
105+
remote = read_url(f"{directory}/{version}/{name}", allow_absent=True)
106+
if remote is not None:
107+
require(digest(remote) == sha, f"Maven already contains different bytes: {name}")
108+
present += 1
109+
require(present in (0, len(sums)), "Maven version is partially populated; refusing automatic overwrite")
110+
if verify_metadata and present:
111+
metadata = ElementTree.fromstring(read_url(f"{directory}/maven-metadata.xml"))
112+
require(version in [node.text for node in metadata.findall("versioning/versions/version")],
113+
"Published version is missing from Maven metadata")
114+
return "present" if present else "absent"
115+
116+
117+
def main():
118+
mode, version, folder = sys.argv[1:]
119+
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
120+
require(version in manifest["releases"], f"Version is not approved: {version}")
121+
release, work = manifest["releases"][version], Path(folder)
122+
if mode == "stage":
123+
stage(manifest, version, release, work)
124+
elif mode in ("check", "require-absent"):
125+
state = destination_state(manifest, version, release, work)
126+
require(mode == "check" or state == "absent", "Destination is not empty")
127+
print(state)
128+
elif mode == "verify":
129+
for delay in (0, 2, 5, 10):
130+
time.sleep(delay)
131+
if destination_state(manifest, version, release, work, verify_metadata=True) == "present":
132+
print(f"MMD Maven verified: {manifest['groupId']}:{manifest['artifactId']}:{version}")
133+
print(f"Main jar SHA-256 matches CurseForge: {release['mainSha256']}")
134+
return
135+
raise ValueError("Publication is not publicly readable")
136+
else:
137+
raise ValueError(f"Unknown mode: {mode}")
138+
139+
140+
if __name__ == "__main__":
141+
main()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Publish to MMD Maven
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: Approved version from .github/maven-release/releases.json
8+
required: true
9+
type: string
10+
mode:
11+
description: Verify the release, or publish it to MMD Maven
12+
default: check-only
13+
required: true
14+
type: choice
15+
options:
16+
- check-only
17+
- publish
18+
19+
permissions:
20+
actions: read
21+
contents: read
22+
23+
defaults:
24+
run:
25+
shell: bash
26+
27+
concurrency:
28+
group: mmd-maven-publication
29+
cancel-in-progress: false
30+
31+
jobs:
32+
maven:
33+
name: Verify and publish Maven artifacts
34+
if: github.repository == 'MinecraftModDevelopmentMods/basePower' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 15
37+
env:
38+
RELEASE_VERSION: ${{ inputs.version }}
39+
RELEASE_WORK: ${{ runner.temp }}/maven-release
40+
steps:
41+
- name: Check out the publication workflow
42+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
43+
with:
44+
persist-credentials: false
45+
46+
- name: Install Java for the standalone Gradle publisher
47+
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
48+
with:
49+
distribution: temurin
50+
java-version: '17'
51+
52+
- name: Verify CurseForge and the sealed CI artifacts
53+
env:
54+
GH_TOKEN: ${{ github.token }}
55+
run: python3 .github/maven-release/verify.py stage "$RELEASE_VERSION" "$RELEASE_WORK"
56+
57+
- name: Generate the POM and check the public destination
58+
id: preflight
59+
run: |
60+
bash gradlew -p .github/maven-release generatePomFileForMavenJavaPublication \
61+
"-PreleaseVersion=$RELEASE_VERSION" "-PreleaseWork=$RELEASE_WORK" --no-daemon
62+
state=$(python3 .github/maven-release/verify.py check "$RELEASE_VERSION" "$RELEASE_WORK")
63+
echo "state=$state" >> "$GITHUB_OUTPUT"
64+
echo "Verified the CI and CurseForge bytes for $RELEASE_VERSION. Maven destination: $state." >> "$GITHUB_STEP_SUMMARY"
65+
66+
- name: Publish only to MMD Maven
67+
if: inputs.mode == 'publish' && steps.preflight.outputs.state == 'absent'
68+
env:
69+
MAVEN_UPLOAD_URL: ${{ secrets.MAVEN_UPLOAD_URL }}
70+
MAVEN_UPLOAD_USERNAME: ${{ secrets.MAVEN_UPLOAD_USERNAME }}
71+
MAVEN_UPLOAD_PASSWORD: ${{ secrets.MAVEN_UPLOAD_PASSWORD }}
72+
run: |
73+
python3 .github/maven-release/verify.py require-absent "$RELEASE_VERSION" "$RELEASE_WORK"
74+
bash gradlew -p .github/maven-release publishMavenJavaPublicationToReleaseRepository \
75+
"-PreleaseVersion=$RELEASE_VERSION" "-PreleaseWork=$RELEASE_WORK" --no-daemon
76+
77+
- name: Verify public Maven downloads
78+
if: inputs.mode == 'publish'
79+
run: |
80+
python3 .github/maven-release/verify.py verify "$RELEASE_VERSION" "$RELEASE_WORK" | tee -a "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)