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
13 changes: 11 additions & 2 deletions .github/release/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

LINES = ("1.21.1", "1.21.4", "1.21.8", "1.21.11", "26.1", "26.2", "26.3")
ANCHOR = "26.1"
VERSION = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
VERSION_CORE = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
VERSION = rf"{VERSION_CORE}(?:\+(?:alpha|beta|pre-release|rc|build|hotfix)\.(?:0|[1-9][0-9]*))?"
VERSION_LINE = re.compile(rb"(?m)^(mod_version[ \t]*=[ \t]*)([^\s#]+)([ \t]*)(?=\r?$)")


Expand Down Expand Up @@ -52,11 +53,19 @@ def contents(path, ref):
return base64.b64decode(data["content"])


def release_type(version):
require(re.fullmatch(VERSION, version), f"Invalid release version: {version}")
qualifier = version.partition("+")[2].partition(".")[0]
if qualifier == "alpha":
return "alpha"
return "beta" if qualifier in {"beta", "pre-release", "rc"} else "release"


def mod_version(properties):
matches = VERSION_LINE.findall(properties)
require(len(matches) == 1, "Expected one mod_version property")
version = matches[0][1].decode()
require(re.fullmatch(VERSION, version), f"Invalid release version: {version}")
release_type(version)
return version


Expand Down
16 changes: 8 additions & 8 deletions .github/release/mod_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from urllib.error import HTTPError
from urllib.request import Request, urlopen

from common import (commit_status, digest, gh, github, record, repo, require, summary,
from common import (commit_status, digest, gh, github, record, release_type, repo, require, summary,
tag_manifest)

MODRINTH = "https://api.modrinth.com/v2"
Expand Down Expand Up @@ -67,8 +67,7 @@ def send(sha, context, upload, reconcile=None):
return prior["description"]
receipt = reconcile() if reconcile else None
if not receipt:
require(not prior or prior["state"] != "pending",
f"Check the remote file before retrying {context}; see RELEASING.md")
require(not prior, f"Upload already attempted; no retry for {context}: {prior}")
record(sha, context, "pending", "upload in progress")
try:
receipt = str(upload())
Expand All @@ -81,6 +80,7 @@ def send(sha, context, upload, reconcile=None):

def publish_modrinth(plan, line, folder, context):
t = plan["targets"][line]
kind = release_type(plan["version"])
token = os.environ["MODRINTH_TOKEN"]
require(token, "Missing MODRINTH_TOKEN")
files = {name: folder / name for name in t["files"]}
Expand All @@ -97,14 +97,14 @@ def reconcile():
return None
raise
require(remote["project_id"] == t["modrinth"] and remote["version_number"] == plan["version"]
and remote["version_type"] == "release" and t["game"] in remote["game_versions"]
and remote["version_type"] == kind and t["game"] in remote["game_versions"]
and "neoforge" in remote["loaders"]
and {f["filename"]: f["hashes"]["sha512"] for f in remote["files"]} == hashes,
"Modrinth has the JAR with different release metadata")
return remote["id"]

metadata = {"project_id": t["modrinth"], "version_number": plan["version"],
"name": f"AnvilLib {plan['version']} · NeoForge {t['game']}", "version_type": "release",
"name": f"AnvilLib {plan['version']} · NeoForge {t['game']}", "version_type": kind,
"changelog": release_url(plan), "dependencies": [], "game_versions": [t["game"]],
"loaders": ["neoforge"], "featured": False, "primary_file": t["jar"], "file_parts": list(files),
"file_types": {n: "sources-jar" if n.endswith("-sources.jar") else "javadoc-jar"
Expand All @@ -122,7 +122,7 @@ def publish_curseforge(plan, line, folder, context):
token = os.environ["CURSEFORGE_TOKEN"]
require(token, "Missing CURSEFORGE_TOKEN")
url = f"{CURSEFORGE}/projects/{t['curseforge']}/upload-file"
base = {"changelog": release_url(plan), "changelogType": "markdown", "releaseType": "release"}
base = {"changelog": release_url(plan), "changelogType": "markdown", "releaseType": release_type(plan["version"])}
primary = dict(base, displayName=f"AnvilLib {plan['version']} · NeoForge {t['game']}",
gameVersionNames=[t["game"], "NeoForge", "Client", "Server"])

Expand Down Expand Up @@ -153,6 +153,6 @@ def run(tag, line, platform):
try:
run(*sys.argv[1:])
except Exception as error:
summary(f"**Pending distribution** — {' / '.join(sys.argv[1:])}\n\n{error}")
print("::warning::Mod-site upload incomplete. See the job summary for the receipt or retry target.")
summary(f"**Distribution incomplete (not retried)** — {' / '.join(sys.argv[1:])}\n\n{error}")
print("::warning::Mod-site upload incomplete. See the job summary for the receipt and failure reason. No automatic retry.")
raise
29 changes: 23 additions & 6 deletions .github/release/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
from urllib.parse import quote

from common import (ANCHOR, LINES, VERSION_LINE, artifact, commit_status, contents, digest, gh, github,
mod_version, output, repo, require, summary, tag_manifest)
mod_version, output, release_type, repo, require, summary, tag_manifest)

from release_notes import generate as generate_notes


def prepare():
require(os.environ["GITHUB_REF"] == f"refs/heads/dev/{ANCHOR}", "Run Release AnvilLib from dev/26.1")
require(os.environ["GITHUB_REF"] == f"refs/heads/dev/{ANCHOR}", "Run Create Multi-Version Releases from dev/26.1")
plan_file = Path("release-plan.json")
saved = artifact("source-plan")
if saved:
Expand All @@ -37,6 +39,8 @@ def prepare():
version = versions[ANCHOR]
plan = {"repo": repo(), "tag": "v" + version, "version": version,
"run": os.environ["GITHUB_RUN_ID"], "targets": targets}
if os.environ.get("GENERATE_RELEASE_NOTES") == "true":
plan.update(generate_notes(plan, ANCHOR))
plan_file.write_text(json.dumps(plan, indent=2) + "\n")
if os.environ.get("DRY_RUN") != "true":
existing = tag_manifest(plan["tag"])
Expand All @@ -47,6 +51,7 @@ def prepare():
output("matrix", json.dumps(rows))
output("version", plan["version"])
output("tag", plan["tag"])
output("bump_patch", str("+" not in plan["version"]).lower())
output("new", str(not saved).lower())
summary(f"**{plan['tag']}**\n\n" + "\n".join(f"- dev/{r['line']} @ {r['sha']}" for r in rows))

Expand Down Expand Up @@ -77,8 +82,11 @@ def verify_assets(release, targets):
def seal():
plan = json.loads(Path("release-plan.json").read_text())
tag = plan["tag"]
notes = [f"AnvilLib {plan['version']} · NeoForge", "",
"| Minecraft | Source | JAR | SHA-256 |", "|---|---|---|---|"]
prerelease = release_type(plan["version"]) != "release"
notes = [f"AnvilLib {plan['version']} · NeoForge", ""]
if plan.get("release_notes"):
notes.extend([plan["release_notes"], ""])
notes.extend(["| Minecraft | Source | JAR | SHA-256 |", "|---|---|---|---|"])
paths = {}
for line, target in plan["targets"].items():
folder = Path("bundles") / f"build-{line}" / "dist"
Expand Down Expand Up @@ -106,7 +114,9 @@ def seal():
release = release_by_tag(tag)
if not release:
release = github("releases", {"tag_name": tag, "name": f"AnvilLib {plan['version']}",
"body": "\n".join(notes), "draft": True})
"target_commitish": plan["targets"][ANCHOR]["sha"],
"body": "\n".join(notes), "draft": True, "prerelease": prerelease})
require(release.get("prerelease", False) == prerelease, f"Conflicting release type for {tag}")
assets = {a["name"]: a for a in release["assets"]}
for name, path in paths.items():
old = assets.get(name)
Expand All @@ -124,19 +134,26 @@ def seal():
def publish(tag):
plan = tag_manifest(tag)
release = release_by_tag(tag)
prerelease = release_type(plan["version"]) != "release"
require(release.get("prerelease", False) == prerelease, f"Conflicting release type for {tag}")
verify_assets(release, plan["targets"])
for line, target in plan["targets"].items():
status = commit_status(target["sha"], f"release/{tag}/{line}/central")
require(status and status["state"] == "success", f"Central has not published {line}")
if release["draft"]:
release = github(f"releases/{release['id']}", {"draft": False}, method="PATCH")
release = github(f"releases/{release['id']}", {
"draft": False, "prerelease": prerelease, "make_latest": "false" if prerelease else "true",
}, method="PATCH")
summary(f"Published [{tag}]({release['html_url']}).")


def bump(tag, line):
plan = tag_manifest(tag)
require(line in LINES and not release_by_tag(tag)["draft"], "Bump requires a public release")
version = plan["version"]
if "+" in version:
summary(f"{tag} has a version qualifier; skipping the patch bump for dev/{line}.")
return
major, minor, patch = version.split(".")
next_version = f"{major}.{minor}.{int(patch) + 1}"
base = "dev/" + line
Expand Down
40 changes: 40 additions & 0 deletions .github/release/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""从发布分支的历史中选择日志起点,并使用 GitHub 原生发布说明。"""

from urllib.parse import quote

from common import github, release_type, summary


def generate(plan, anchor):
releases = []
page = 1
while True:
batch = github(f"releases?per_page=100&page={page}")
releases.extend(item for item in batch if not item["draft"] and item.get("published_at"))
if len(batch) < 100:
break
page += 1

prerelease = release_type(plan["version"]) != "release"
sha = plan["targets"][anchor]["sha"]
previous = None
for item in sorted(releases, key=lambda r: (r["published_at"], r["id"]), reverse=True):
tag = item["tag_name"]
if tag == plan["tag"] or (item["prerelease"] and not prerelease):
continue
comparison = github(f"compare/{quote(tag, safe='')}...{sha}?per_page=1", missing=True)
if comparison and comparison["status"] in {"ahead", "identical"}:
previous = tag
break

if previous is None and releases:
# 省略 previous_tag_name 会让 GitHub 自动选到其他分支或不匹配的类型。
summary("未找到符合类型的祖先 Release tag,跳过增量发布说明。")
return {"previous_tag_name": None, "release_notes": ""}

payload = {"tag_name": plan["tag"], "target_commitish": sha}
if previous:
payload["previous_tag_name"] = previous
notes = github("releases/generate-notes", payload)["body"]
summary(f"Generate release notes: Previous tag = {previous or '(首次发布)'}")
return {"previous_tag_name": previous, "release_notes": notes}
13 changes: 8 additions & 5 deletions .github/release/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ def status(sha, context):
github.assert_not_called()
else:
release.publish(TAG)
github.assert_called_once_with("releases/42", {"draft": False}, method="PATCH")
github.assert_called_once_with("releases/42", {
"draft": False, "prerelease": False, "make_latest": "true",
}, method="PATCH")

def test_missing_or_changed_asset_blocks_publication(self):
for assets in [DRAFT["assets"][:-1], [dict(DRAFT["assets"][0], digest="changed"), *DRAFT["assets"][1:]]]:
Expand Down Expand Up @@ -129,7 +131,7 @@ def portal(path):
self.assertEqual(sum(call.args[0] == "deployment/" + receipt for call in remote.call_args_list), 1)
record.assert_called_once_with("sha-26.1", f"release/{TAG}/26.1/central", "success", "deployment " + receipt)

def test_partial_curseforge_upload_resumes_each_file(self):
def test_partial_curseforge_failure_is_not_retried(self):
ledger, uploads = {}, []
def record(_, context, state, description):
ledger[context] = {"state": state, "description": description}
Expand All @@ -147,14 +149,15 @@ def upload(url, auth, fields):
with self.assertRaises(mod_sites.Rejected):
mod_sites.publish_curseforge(*args)
for _ in range(2):
self.assertEqual(mod_sites.publish_curseforge(*args), "1, 3, 4")
self.assertEqual(uploads, ["x.jar", "x-sources.jar", "x-sources.jar", "x-javadoc.jar"])
with self.assertRaisesRegex(ValueError, "no retry"):
mod_sites.publish_curseforge(*args)
self.assertEqual(uploads, ["x.jar", "x-sources.jar"])

def test_uncertain_upload_requires_reconciliation(self):
with patch.object(mod_sites, "commit_status", return_value={"state": "pending"}), \
patch.object(mod_sites, "record"):
upload = Mock()
with self.assertRaisesRegex(ValueError, "remote file"):
with self.assertRaisesRegex(ValueError, "no retry"):
mod_sites.send("sha", "context", upload)
self.assertEqual(mod_sites.send("sha", "context", upload, lambda: "verified-id"), "verified-id")
upload.assert_not_called()
Expand Down
70 changes: 70 additions & 0 deletions .github/release/test_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""验证日志只比较符合版本类型的祖先 Release tag。"""

import unittest
from unittest.mock import patch

import release_notes


PLAN = {"tag": "v2.0.0", "version": "2.0.0", "targets": {"example": {"sha": "head"}}}


def release(tag, day, *, prerelease=False, draft=False):
return {"tag_name": tag, "id": day, "published_at": f"2026-01-{day:02}T00:00:00Z",
"prerelease": prerelease, "draft": draft}


class ReleaseNotesTests(unittest.TestCase):
def setUp(self):
self.enterContext(patch.object(release_notes, "summary"))

def test_latest_skips_prereleases_drafts_and_other_branch_tags(self):
items = [release("v1.0.0", 1), release("v1.1.0", 2),
release("v1.2.0+rc.1", 3, prerelease=True),
release("other/branch", 4), release("v2.0.0", 5), release("draft", 6, draft=True)]
def api(path, data=None, **kwargs):
if path.startswith("releases?"):
return items
if path.startswith("compare/"):
return {"status": "diverged" if "other%2Fbranch" in path else "ahead"}
self.assertEqual(data, {"tag_name": "v2.0.0", "target_commitish": "head",
"previous_tag_name": "v1.1.0"})
return {"body": "## What's Changed\n* PR #42"}
with patch.object(release_notes, "github", side_effect=api) as github:
result = release_notes.generate(PLAN, "example")
self.assertEqual(result, {"previous_tag_name": "v1.1.0", "release_notes": "## What's Changed\n* PR #42"})
comparisons = [c.args[0] for c in github.call_args_list if c.args[0].startswith("compare/")]
self.assertEqual(comparisons, ["compare/other%2Fbranch...head?per_page=1", "compare/v1.1.0...head?per_page=1"])

def test_prerelease_can_follow_either_type_including_same_commit(self):
plan = dict(PLAN, tag="v2.0.0+rc.2", version="2.0.0+rc.2")
for pre in (False, True):
tag = "v2.0.0+rc.1" if pre else "v1.0.0"
with self.subTest(prerelease=pre), patch.object(release_notes, "github", side_effect=[
[release(tag, 1, prerelease=pre)], {"status": "identical"}, {"body": "native"},
]) as github:
self.assertEqual(release_notes.generate(plan, "example")["previous_tag_name"], tag)
self.assertEqual(github.call_args.args[1]["previous_tag_name"], tag)

def test_reads_all_pages_before_choosing_by_publication_date(self):
first_page = [release(f"draft-{i}", 1, draft=True) for i in range(100)]
with patch.object(release_notes, "github", side_effect=[first_page, [release("v1.0.0", 2)],
{"status": "ahead"}, {"body": "native"}]) as github:
self.assertEqual(release_notes.generate(PLAN, "example")["previous_tag_name"], "v1.0.0")
self.assertEqual(github.call_args_list[1].args[0], "releases?per_page=100&page=2")

def test_first_repository_release_uses_native_initial_notes(self):
with patch.object(release_notes, "github", side_effect=[[], {"body": "initial"}]) as github:
self.assertEqual(release_notes.generate(PLAN, "example"),
{"previous_tag_name": None, "release_notes": "initial"})
self.assertEqual(github.call_args.args[1], {"tag_name": "v2.0.0", "target_commitish": "head"})

def test_no_eligible_ancestor_does_not_let_github_choose_another_type_or_branch(self):
for status in ("behind", "diverged", None):
with self.subTest(status=status), patch.object(release_notes, "github", side_effect=[
[release("v1.0.0+rc.1", 1, prerelease=True), release("other", 2)],
{"status": status} if status else None,
]) as github:
self.assertEqual(release_notes.generate(PLAN, "example"),
{"previous_tag_name": None, "release_notes": ""})
self.assertEqual(github.call_count, 2)
Loading
Loading