From 8d52fda880b043305f96fa03fb9ecea4abb909f8 Mon Sep 17 00:00:00 2001 From: Gu-ZT Date: Tue, 10 Nov 2026 02:51:17 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20=E7=BB=9F=E4=B8=80=E5=A4=9A?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=91=E5=B8=83=E4=B8=8E=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将总控统一为 Create Multi-Version Releases 并自动上传全部渠道 - 两站失败不重传,Central 和 ReleaseFile 失败阻止整体发布 - 按版本后缀识别发布类型,选择祖先 Release tag 并冻结原生说明 - 统一 Cjsah Maven 依赖仓库格式,内部 Maven 上传沿用机密配置 --- .github/release/common.py | 13 +- .github/release/mod_sites.py | 16 +- .github/release/release.py | 29 +++- .github/release/release_notes.py | 40 +++++ .github/release/test_release.py | 13 +- .github/release/test_release_notes.py | 70 ++++++++ .github/release/test_release_options.py | 204 ++++++++++++++++++++++++ .github/workflows/build_and_test.yml | 3 + .github/workflows/mod_site.yml | 48 ------ .github/workflows/pull_request.yml | 3 + .github/workflows/release.yml | 38 +++-- RELEASING.md | 43 +++-- module.gradle | 2 +- settings.gradle | 2 +- 14 files changed, 431 insertions(+), 93 deletions(-) create mode 100644 .github/release/release_notes.py create mode 100644 .github/release/test_release_notes.py create mode 100644 .github/release/test_release_options.py delete mode 100644 .github/workflows/mod_site.yml diff --git a/.github/release/common.py b/.github/release/common.py index b7a21d06d..fb7894fe3 100644 --- a/.github/release/common.py +++ b/.github/release/common.py @@ -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?$)") @@ -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 diff --git a/.github/release/mod_sites.py b/.github/release/mod_sites.py index 5d5d2c638..5a29d1dbf 100644 --- a/.github/release/mod_sites.py +++ b/.github/release/mod_sites.py @@ -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" @@ -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()) @@ -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"]} @@ -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" @@ -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"]) @@ -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 diff --git a/.github/release/release.py b/.github/release/release.py index 613daf3bf..f2fba3df7 100644 --- a/.github/release/release.py +++ b/.github/release/release.py @@ -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: @@ -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"]) @@ -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)) @@ -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" @@ -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) @@ -124,12 +134,16 @@ 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']}).") @@ -137,6 +151,9 @@ 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 diff --git a/.github/release/release_notes.py b/.github/release/release_notes.py new file mode 100644 index 000000000..e75ea3e4d --- /dev/null +++ b/.github/release/release_notes.py @@ -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} diff --git a/.github/release/test_release.py b/.github/release/test_release.py index 12d356d18..9013e0274 100644 --- a/.github/release/test_release.py +++ b/.github/release/test_release.py @@ -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:]]]: @@ -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} @@ -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() diff --git a/.github/release/test_release_notes.py b/.github/release/test_release_notes.py new file mode 100644 index 000000000..80498d86f --- /dev/null +++ b/.github/release/test_release_notes.py @@ -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) diff --git a/.github/release/test_release_options.py b/.github/release/test_release_options.py new file mode 100644 index 000000000..f22a4ce21 --- /dev/null +++ b/.github/release/test_release_options.py @@ -0,0 +1,204 @@ +"""Release classification, generated notes and retry coverage without publishing.""" + +import io +import json +import os +import tempfile +import unittest +from contextlib import chdir +from pathlib import Path +from unittest.mock import patch + +import common +import mod_sites +import release +from test_release import DRAFT, PLAN + +# Version, mod-site type and whether the plain patch should be advanced. +CASES = [ + ("2.0.0", "release", True), + ("2.0.0+alpha.1", "alpha", False), + ("2.0.0+beta.2", "beta", False), + ("2.0.0+pre-release.3", "beta", False), + ("2.0.0+rc.4", "beta", False), + ("2.0.0+build.5", "release", False), + ("2.0.0+hotfix.6", "release", False), +] + + +@patch.dict(os.environ, { + "GITHUB_REPOSITORY": "example/AnvilLib", "GITHUB_RUN_ID": "123", + "GITHUB_REF": "refs/heads/dev/26.1", "GITHUB_SHA": "sha-26.1", + "DRY_RUN": "false", "GENERATE_RELEASE_NOTES": "false", + "MODRINTH_TOKEN": "test", "CURSEFORGE_TOKEN": "test", +}) +class ReleaseOptionsTests(unittest.TestCase): + def setUp(self): + self.enterContext(patch.object(release, "summary")) + self.enterContext(patch.object(mod_sites, "summary")) + + def test_exact_version_formats_and_types(self): + for version, kind, _ in CASES: + with self.subTest(version=version): + self.assertEqual(common.mod_version(f"mod_version={version}\r\n".encode()), version) + self.assertEqual(common.release_type(version), kind) + for version in ("2.0.0-beta.1", "2.0.0+alpha", "2.0.0+rc.01", "2.0.0+beta.-1", + "2.0.0+snapshot.5", "2.0.0+unknown.1", "2.0.0+build.1+rc.2", "2.0.0+hotfix.1/other"): + with self.subTest(invalid=version), self.assertRaises(ValueError): + common.mod_version(f"mod_version={version}".encode()) + + def test_prepare_freezes_native_notes_and_bump_policy(self): + refs = [{"ref": f"refs/heads/dev/{line}", "object": {"sha": f"sha-{line}"}} for line in common.LINES] + notes = "## What's Changed\n* Main branch PR #42\n\n## New Contributors\n* @contributor\n" + for version, _, bump in CASES: + for enabled in (False, True): + def content(path, _sha): + return f"mod_version={version}\njava_version=25\n".encode() if path == "gradle.properties" else b"" + def api(path, data=None): + if path == "git/matching-refs/heads/dev/": + return refs + self.fail(path) + with self.subTest(version=version, notes=enabled), tempfile.TemporaryDirectory() as tmp, chdir(tmp), \ + patch.dict(os.environ, {"GENERATE_RELEASE_NOTES": str(enabled).lower(), "DRY_RUN": "true"}), \ + patch.object(release, "artifact", side_effect=[None, {"id": 1}]), \ + patch.object(release, "contents", side_effect=content), \ + patch.object(release, "github", side_effect=api) as github, \ + patch.object(release, "generate_notes", return_value={"previous_tag_name": "v1.9.0", "release_notes": notes}) as generate, \ + patch.object(release, "gh"), patch.object(release, "output") as output: + release.prepare() + plan = json.loads(Path("release-plan.json").read_text()) + self.assertEqual(plan.get("release_notes"), notes if enabled else None) + self.assertEqual(dict(c.args for c in output.call_args_list)["bump_patch"], str(bump).lower()) + # Re-running uses the original plan even if the branch or inputs have changed. + with patch.dict(os.environ, {"GENERATE_RELEASE_NOTES": str(not enabled).lower()}): + release.prepare() + self.assertEqual(github.call_count, 1) + self.assertEqual(generate.call_count, 1 if enabled else 0) + self.assertEqual(plan.get("previous_tag_name"), "v1.9.0" if enabled else None) + self.assertEqual(json.loads(Path("release-plan.json").read_text()), plan) + + def test_qualified_tags_preserve_version_and_original_notes(self): + for version, _, _ in CASES: + tag = "v" + version + targets = {} + for line in common.LINES: + stem = f"anvillib-neoforge-{line}-{version}" + targets[line] = { + "sha": "a" * 40, "game": line, "jar": stem + ".jar", + "files": {stem + suffix: "b" * 64 for suffix in (".jar", "-sources.jar", "-javadoc.jar")}, + } + plan = {"tag": tag, "version": version, "run": "123", "repo": "example/AnvilLib", + "targets": targets, "release_notes": "## What's Changed\n* PR #42"} + annotation = {"object": {"sha": "a" * 40}, "message": f"AnvilLib {tag}\n\n" + json.dumps(plan)} + with self.subTest(version=version), patch.object(common, "github", side_effect=[ + {"object": {"type": "tag", "sha": "c" * 40}}, annotation, + ]): + self.assertEqual(common.tag_manifest(tag), plan) + + def test_seal_preserves_generated_notes_when_reusing_a_draft(self): + native = "## What's Changed\n* PR #42 by @contributor\n" + plan = dict(PLAN, tag="v2.0.0+beta.1", version="2.0.0+beta.1", release_notes=native) + drafts, tags = [], [] + def api(path, data=None, **kwargs): + if path == "git/tags": + tags.append(json.loads(data["message"].split("\n\n", 1)[1])) + return {"sha": "tag-object"} + if path == "git/refs": + return {} + if path == "releases": + drafts.append(dict(data, id=42, assets=DRAFT["assets"])) + return drafts[0] + with tempfile.TemporaryDirectory() as tmp, chdir(tmp), \ + patch.object(release, "tag_manifest", side_effect=lambda _: tags[0] if tags else None), \ + patch.object(release, "release_by_tag", side_effect=lambda _: drafts[0] if drafts else None), \ + patch.object(release, "github", side_effect=api), patch.object(release, "gh") as upload, \ + patch.object(release, "digest", side_effect=lambda p: Path(p).stem): + Path("release-plan.json").write_text(json.dumps(plan)) + for line, target in plan["targets"].items(): + folder = Path("bundles") / f"build-{line}" / "dist" + folder.mkdir(parents=True) + (folder / "entry.json").write_text(json.dumps(target)) + release.seal() + release.seal() + self.assertEqual((len(tags), len(drafts)), (1, 1)) + self.assertTrue(drafts[0]["prerelease"]) + self.assertEqual(drafts[0]["target_commitish"], "sha-26.1") + self.assertEqual(drafts[0]["body"].count(native), 1) + self.assertIn("| Minecraft | Source | JAR | SHA-256 |", drafts[0]["body"]) + upload.assert_not_called() + + def test_publication_type_and_latest_follow_the_version(self): + for version, kind, _ in CASES: + prerelease = kind != "release" + plan = dict(PLAN, version=version) + draft = dict(DRAFT, prerelease=prerelease) + with self.subTest(version=version), patch.object(release, "tag_manifest", return_value=plan), \ + patch.object(release, "release_by_tag", return_value=draft), \ + patch.object(release, "commit_status", return_value={"state": "success"}), \ + patch.object(release, "github", return_value={"html_url": "example"}) as github: + release.publish("v" + version) + github.assert_called_once_with("releases/42", { + "draft": False, "prerelease": prerelease, "make_latest": "false" if prerelease else "true", + }, method="PATCH") + + def test_prerelease_waits_for_central_and_rejects_type_drift(self): + plan = dict(PLAN, version="2.0.0+rc.1") + with patch.object(release, "tag_manifest", return_value=plan), \ + patch.object(release, "release_by_tag", return_value=dict(DRAFT, prerelease=True)) as lookup, \ + patch.object(release, "commit_status", return_value={"state": "pending"}), \ + patch.object(release, "github") as github: + with self.assertRaisesRegex(ValueError, "Central has not published"): + release.publish("v" + plan["version"]) + lookup.return_value = dict(DRAFT, prerelease=False) + with self.assertRaisesRegex(ValueError, "Conflicting release type"): + release.publish("v" + plan["version"]) + github.assert_not_called() + + def test_all_qualified_versions_skip_bump_without_writing(self): + for version, _, bump in CASES: + if bump: + continue # The existing plain-version test checks the one-commit [skip ci] PR. + with self.subTest(version=version), patch.object(release, "tag_manifest", return_value=dict(PLAN, version=version)), \ + patch.object(release, "release_by_tag", return_value={"draft": False}), \ + patch.object(release, "github") as github, patch.object(release, "contents") as contents: + release.bump("v" + version, "26.1") + github.assert_not_called() + contents.assert_not_called() + + def test_mod_sites_use_matching_types_for_primary_and_attachments(self): + target = {"sha": "sha", "game": "26.1.2", "modrinth": "mod", "curseforge": "123", "jar": "x.jar", + "files": {n: "hash" for n in ("x.jar", "x-sources.jar", "x-javadoc.jar")}} + for version, kind, _ in CASES: + plan = dict(PLAN, version=version, targets={"26.1": target}) + def send(_sha, _context, upload, reconcile=None): + return str(upload()) + with self.subTest(version=version), patch.object(mod_sites, "send", side_effect=send), \ + patch.object(mod_sites, "digest", return_value="hash"), \ + patch.object(mod_sites, "multipart", return_value={"id": 12}) as multipart: + mod_sites.publish_modrinth(plan, "26.1", Path("."), "mr") + fields = multipart.call_args.args[2]["data"] + self.assertEqual((fields["version_number"], fields["version_type"]), (version, kind)) + multipart.reset_mock() + mod_sites.publish_curseforge(plan, "26.1", Path("."), "cf") + self.assertEqual(len(multipart.call_args_list), 3) + for call in multipart.call_args_list: + self.assertEqual(call.args[2]["metadata"]["releaseType"], kind) + + def test_modrinth_reconciles_prerelease_without_reupload(self): + files = {n: "hash" for n in ("x.jar", "x-sources.jar", "x-javadoc.jar")} + target = {"sha": "sha", "game": "26.1.2", "modrinth": "mod", "jar": "x.jar", "files": files} + plan = dict(PLAN, version="2.0.0+rc.1", targets={"26.1": target}) + for kind in ("beta", "release"): + remote = {"id": "accepted", "project_id": "mod", "version_number": plan["version"], + "version_type": kind, "game_versions": ["26.1.2"], "loaders": ["neoforge"], + "files": [{"filename": n, "hashes": {"sha512": "hash"}} for n in files]} + with self.subTest(remote_type=kind), patch.object(mod_sites, "digest", return_value="hash"), \ + patch.object(mod_sites, "send", side_effect=lambda _s, _c, _u, reconcile: reconcile()), \ + patch.object(mod_sites, "urlopen", return_value=io.BytesIO(json.dumps(remote).encode())), \ + patch.object(mod_sites, "multipart") as upload: + if kind == "beta": + self.assertEqual(mod_sites.publish_modrinth(plan, "26.1", Path("."), "mr"), "accepted") + else: + with self.assertRaisesRegex(ValueError, "different release metadata"): + mod_sites.publish_modrinth(plan, "26.1", Path("."), "mr") + upload.assert_not_called() diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 5c38518bd..f6fafec03 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -42,6 +42,9 @@ jobs: - name: Check Maven publication ordering run: python3 -B -m unittest discover -s .github/ci + - name: Check coordinated release and retry behavior + run: python3 -B -m unittest discover -s .github/release + - name: Build all CI publications id: build run: | diff --git a/.github/workflows/mod_site.yml b/.github/workflows/mod_site.yml deleted file mode 100644 index 9ea4554f2..000000000 --- a/.github/workflows/mod_site.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Upload release to a mod site -on: - workflow_call: - inputs: - tag: { type: string, required: true } - line: { type: string, required: true } - platform: { type: string, required: true } - secrets: - MODRINTH_TOKEN: { required: false } - CURSEFORGE_TOKEN: { required: false } - workflow_dispatch: - inputs: - tag: - description: Existing release tag (vX.Y.Z) - type: string - required: true - line: - description: Minecraft development branch - type: choice - options: ['1.21.1', '1.21.4', '1.21.8', '1.21.11', '26.1', '26.2', '26.3'] - platform: - description: Platform to retry - type: choice - options: [modrinth, curseforge] - -permissions: { contents: read, actions: read, statuses: write } -jobs: - upload: - name: ${{ inputs.platform }} / ${{ inputs.line }} - if: github.ref == 'refs/heads/dev/26.1' - runs-on: ubuntu-latest - timeout-minutes: 15 - continue-on-error: true - concurrency: - group: mod-site-${{ inputs.tag }}-${{ inputs.line }}-${{ inputs.platform }} - cancel-in-progress: false - steps: - - uses: actions/checkout@v6 - with: { persist-credentials: false } - - name: Upload verified files - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} - LINE: ${{ inputs.line }} - PLATFORM: ${{ inputs.platform }} - MODRINTH_TOKEN: ${{ inputs.platform == 'modrinth' && secrets.MODRINTH_TOKEN || '' }} - CURSEFORGE_TOKEN: ${{ inputs.platform == 'curseforge' && secrets.CURSEFORGE_TOKEN || '' }} - run: python3 .github/release/mod_sites.py "$TAG" "$LINE" "$PLATFORM" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 483a4d2d3..6e05f06d5 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -5,6 +5,9 @@ on: paths: - .github/modules.json - .github/ci/** + - .github/release/** + - .github/workflows/release.yml + - .github/workflows/mod_site.yml - .github/workflows/build_and_test.yml - .github/workflows/pull_request.yml - roseau.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 000b4e0f0..d1d4b6399 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,11 @@ -name: Release AnvilLib +name: Create Multi-Version Releases on: workflow_dispatch: inputs: + generate_release_notes: + description: Include GitHub-generated release notes from dev/26.1 + type: boolean + default: false dry_run: description: Build and verify all seven releases without publishing type: boolean @@ -18,16 +22,19 @@ env: PR_BUILD: 'false' NOT_DEV: 'false' DRY_RUN: ${{ inputs.dry_run }} + GENERATE_RELEASE_NOTES: ${{ inputs.generate_release_notes }} jobs: prepare: if: github.ref == 'refs/heads/dev/26.1' runs-on: ubuntu-latest - permissions: { contents: read, actions: read } + # GitHub requires contents: write even for the generate-notes preview API. + permissions: { contents: write, actions: read } outputs: matrix: ${{ steps.plan.outputs.matrix }} version: ${{ steps.plan.outputs.version }} tag: ${{ steps.plan.outputs.tag }} + bump_patch: ${{ steps.plan.outputs.bump_patch }} steps: - uses: actions/checkout@v6 with: { persist-credentials: false } @@ -86,6 +93,7 @@ jobs: if-no-files-found: error seal: + name: Upload ReleaseFile needs: [prepare, build] runs-on: ubuntu-latest permissions: { contents: write, actions: read } @@ -146,19 +154,26 @@ jobs: matrix: target: ${{ fromJSON(needs.prepare.outputs.matrix) }} platform: [modrinth, curseforge] - uses: ./.github/workflows/mod_site.yml - with: - tag: ${{ needs.prepare.outputs.tag }} - line: ${{ matrix.target.line }} - platform: ${{ matrix.platform }} - secrets: - MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} - CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + name: ${{ matrix.platform }} / ${{ matrix.target.line }} + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: { persist-credentials: false } + - name: Upload verified files once + env: + TAG: ${{ needs.prepare.outputs.tag }} + LINE: ${{ matrix.target.line }} + PLATFORM: ${{ matrix.platform }} + MODRINTH_TOKEN: ${{ matrix.platform == 'modrinth' && secrets.MODRINTH_TOKEN || '' }} + CURSEFORGE_TOKEN: ${{ matrix.platform == 'curseforge' && secrets.CURSEFORGE_TOKEN || '' }} + run: python3 .github/release/mod_sites.py "$TAG" "$LINE" "$PLATFORM" publish: needs: [prepare, seal, central] runs-on: ubuntu-latest - permissions: { contents: write } + permissions: { contents: write, statuses: read } env: TAG: ${{ needs.prepare.outputs.tag }} steps: @@ -168,6 +183,7 @@ jobs: bump: needs: [prepare, publish] + if: needs.prepare.outputs.bump_patch == 'true' runs-on: ubuntu-latest permissions: { contents: write, pull-requests: write } strategy: diff --git a/RELEASING.md b/RELEASING.md index 029d34b14..144820ff9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,20 +1,40 @@ -# AnvilLib 正式发布 +# AnvilLib 发布 -在默认分支 `dev/26.1` 打开 **Actions → Release AnvilLib → Run workflow**。勾选 `dry_run` 可先构建七个版本并查看文件校验表。正式发布时取消勾选即可。同一时间只运行一次正式发布或演练。 +在默认分支 `dev/26.1` 打开 **Actions → Create Multi-Version Releases → Run workflow**。勾选 `dry_run` 可先构建七个版本并查看文件校验表,取消勾选后执行发布。同一时间只运行一次发布或演练。 + +可选的 `generate_release_notes` 默认关闭。开启后直接调用 GitHub 的 Generate release notes API,以本次锁定的 `dev/26.1` SHA 为目标,显式指定上一 tag 生成合并 PR、贡献者及完整变更链接。正文采用 GitHub 的返回内容,并附上七份构件的源码和校验表。仓库的 `.github/release.yml` 或 `.github/release.yaml` 配置同样生效。结果保存在原运行的 `source-plan` 和 tag 注释中,重跑复用该内容,`dry_run` 摘要也会展示它。 + +上一 tag 从已公开的 Release 中按发布时间由近到远选择,必须位于锁定提交的祖先历史中,允许继承自旧分支的 tag。Pre-release 可以接上一 Release 或 Pre-release,Release 只接上一 Release。草稿、当前 tag 和其他分支的后继或分叉提交均跳过。选择结果和原生说明一起冻结,重跑沿用。仓库首次发布使用 GitHub 原生初次发布说明;已有发布但找不到合格祖先时,摘要说明原因并跳过增量日志,避免 GitHub 自动选错范围。 ## 版本与构件 -七条开发线为 `1.21.1`、`1.21.4`、`1.21.8`、`1.21.11`、`26.1`、`26.2`、`26.3`。预检读取所有分支头,要求根 `mod_version` 同为 `X.Y.Z`。每条分支用自己的 Gradle Wrapper 和 JDK 从锁定的 SHA 构建。`stageRelease` 收集该分支模块清单中的全部发布单元、sync 注解处理器和主模块。校验覆盖 POM 的内部依赖、主 JAR 的模组版本、每个内嵌 slim JAR 的字节及 Maven 附件。 +七条开发线为 `1.21.1`、`1.21.4`、`1.21.8`、`1.21.11`、`26.1`、`26.2`、`26.3`。预检读取所有分支头,要求根 `mod_version` 完全相同。版本类型按以下项目约定自动识别,后缀使用 `+`,`N` 为无前导零的非负整数。 + +| mod_version | GitHub 类型 | Modrinth / CurseForge 类型 | 发布成功后升 patch | +| --- | --- | --- | --- | +| `X.Y.Z+alpha.N` | Pre-release | Alpha | 否 | +| `X.Y.Z+beta.N` | Pre-release | Beta | 否 | +| `X.Y.Z+pre-release.N` | Pre-release | Beta | 否 | +| `X.Y.Z+rc.N` | Pre-release | Beta | 否 | +| `X.Y.Z+build.N` | Release | Release | 否 | +| `X.Y.Z+hotfix.N` | Release | Release | 否 | +| `X.Y.Z` | Release | Release | 是 | + +例如发布 `2.0.0+beta.1` 前先将七条分支的版本同步到该值,生成的 tag 为 `v2.0.0+beta.1`,Maven 坐标和 JAR 内版本保留完整后缀。后续发 `2.0.0+rc.1` 或 `2.0.0` 时,再将七条分支同步到对应版本。所有类型都等待七个 Maven Central 部署达到 `PUBLISHED` 后公开 GitHub Release。Pre-release 使用独立 tag,GitHub 的 Latest 标记由正式版维护。 -带注解 tag `vX.Y.Z` 指向锁定的默认分支提交。运行编号、七个源码 SHA、平台项目 ID 和供下载文件的 SHA-256 都在 tag 注释中。GitHub Release 展示七个主 JAR 及对应的源码链接。Minecraft 文件名取自实际游戏版本,例如 `dev/26.1` 构建的文件名中使用 `26.1.2`。 +每条分支用自己的 Gradle Wrapper 和 JDK 从锁定的 SHA 构建。`stageRelease` 收集该分支模块清单中的全部发布单元、sync 注解处理器和主模块。校验覆盖 POM 的内部依赖、主 JAR 的模组版本、每个内嵌 slim JAR 的字节及 Maven 附件。 + +带注解 tag `v` 指向锁定的默认分支提交。运行编号、七个源码 SHA、平台项目 ID 和供下载文件的 SHA-256 都在 tag 注释中。GitHub Release 展示七个主 JAR 及对应的源码链接。Minecraft 文件名取自实际游戏版本,例如 `dev/26.1` 构建的文件名中使用 `26.1.2`。 + +总控自动运行 Modrinth、CurseForge、Maven 和 ReleaseFile 上传,不提供平台选择。Maven 或 ReleaseFile 失败会使整体运行失败并保留草稿;两站独立运行,失败只记录警告和回执,公开 Release 与升版任务不等待两站。 | 渠道 | 完成条件 | 失败处理 | | --- | --- | --- | | Maven Central | 每个 Minecraft 版本一个 JReleaser 签名部署,七个部署均为 `PUBLISHED` | 保留 GitHub 草稿,在原运行使用 **Re-run failed jobs** | | GitHub | 七个附件的摘要与 tag 清单一致,七个 Central 成功回执已记录 | 在原运行续传缺失附件或重试公开草稿 | -| Modrinth、CurseForge | 各自接收各游戏版本的主包和 sources、javadoc 附件 | 对应任务给出警告,GitHub 公开和升版 PR 独立执行 | +| Modrinth、CurseForge | 各自接收各游戏版本的主包和 sources、javadoc 附件 | 对应任务给出警告,不重试;GitHub 公开和升版 PR 独立执行 | -Release 公开后,工作流对七条开发分支分别创建一个升至下一 patch 的 PR。标题和单一提交消息都包含 `[skip ci]`。按仓库现有规则审核并保留默认 squash 标题后合并。重跑会复用升版分支与 PR。待七条分支版本一致后再发下一版。普通开发推送继续发布内部 Maven 快照以及两站的 Beta 构建。 +纯 `X.Y.Z` 的 Release 公开后,工作流对七条开发分支分别创建一个升至下一 patch 的 PR。标题和单一提交消息都包含 `[skip ci]`。按仓库现有规则审核并保留默认 squash 标题后合并。重跑会复用升版分支与 PR。待七条分支版本一致后再发下一版。普通开发推送继续发布内部 Maven 快照以及两站的 Beta 构建。 ## 开发 CI @@ -22,20 +42,21 @@ Release 公开后,工作流对七条开发分支分别创建一个升至下一 PR 使用 `+pr.N` 版本,运行服务端启动与关闭测试,并为子模块和主包生成 Roseau 报告。报告汇总在 `roseau-reports` 附件和现有 PR 评论中。开发推送使用 `+snapshot.N`。服务端测试成功后,CI 把已构建的文件上传到内部 Maven,所有子模块完成后才上传主包,再使用本次主包及 sources、javadoc 附件上架 Beta 快照。 -## 续跑和补发 +## 续跑 - 正式流程中断时优先重跑原运行。已完成的构建通过原运行的 Actions artifact 复用。已存在的 tag、草稿附件和远端回执须与本次版本吻合。可在 tag 的注释中找到原运行编号。 -- 单独补发模组平台时,从默认分支运行 **Upload release to a mod site**,选择原 tag、开发线和平台。它核验原文件的 SHA-256。Actions 构件过期后,主包取自 GitHub Release,sources 和 javadoc 取自同版 Maven Central。 +- 两站上传各执行一次。原运行重跑时复用成功回执,已有失败或未决记录不会再次上传。Modrinth 可核对远端文件后补全成功回执。 - 发布进度记录在每条开发线的源码提交状态中。状态名形如 `release/v2.0.0/26.1/central` 或 `release/v2.0.0/26.1/curseforge/sources`。每条记录链接到产生它的 Actions 运行。CurseForge 为主文件和两个子文件分别记录回执,Modrinth 可从主文件的 SHA-512 找回已接收的版本。 - Central 日志输出 deployment ID 时会立即记录,重跑继续查询同一 ID。Central 明确拒绝部署时,摘要或错误日志会给出原因。若上传请求已发出却没有回执,先在 Central Portal 或 CurseForge 作者后台核对项目、版本、文件名和文件哈希。确认后可用下方命令补录状态,再重跑原任务。 ```sh -gh api --method POST "repos/Anvil-Dev/AnvilLib/statuses/$SOURCE_SHA" \ - -f context="$CONTEXT" -f state="$STATE" -f description="$RECEIPT" +gh api --method POST "repos/Anvil-Dev/AnvilLib/statuses/$SOURCE_SHA" -f context="$CONTEXT" -f state="$STATE" -f description="$RECEIPT" ``` -Central 已接收时使用 `pending` 和 `deployment `,由工作流确认最终状态。CurseForge 已接收时使用 `success` 和该文件的数字 ID。仅在后台确认未接收时,使用 `failure` 和 `confirmed absent` 允许重新上传。首次发布前应完成七条分支的构建适配,默认分支的总控最后启用。 +Central 已接收时使用 `pending` 和 `deployment `,由工作流确认最终状态。CurseForge 已接收时使用 `success` 和该文件的数字 ID。Central 在后台确认未接收后可使用 `failure` 和 `confirmed absent` 允许重新部署;两站已有失败记录时仍不重传。首次发布前应完成七条分支的构建适配,默认分支的总控最后启用。 ## 凭据 Central 任务使用 `MAVEN_CENTRAL_USERNAME`、`MAVEN_CENTRAL_PASSWORD`、`SIGNING_KEY`、`SIGNING_PUBLIC_KEY` 和 `SIGNING_PASSWORD`。两站各使用自己的 `MODRINTH_TOKEN` 或 `CURSEFORGE_TOKEN`。仓库需允许 Actions 创建 PR。各任务按需使用 `GITHUB_TOKEN` 写入 tag、Release、提交状态和普通升版分支。 + +Gradle 的 Cjsah Maven 依赖仓库统一使用 `https://server.cjsah.net:1002/maven/releases/`;内部 Maven 上传继续读取机密 `MAVEN_URL`,两者分别配置。 diff --git a/module.gradle b/module.gradle index 5506f9fb1..8775fbbc7 100644 --- a/module.gradle +++ b/module.gradle @@ -18,7 +18,7 @@ repositories { forRepositories( maven { // Anvil Lib name = "Cjsah Maven" - url = "https://server.cjsah.net:1002/maven/releases" + url = "https://server.cjsah.net:1002/maven/releases/" } ) filter { diff --git a/settings.gradle b/settings.gradle index d1243677f..6f66df7ab 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,7 +3,7 @@ pluginManagement { mavenLocal() gradlePluginPortal() maven { url = 'https://maven.neoforged.net/releases' } - maven { url = 'https://server.cjsah.net:1002/maven/releases' } + maven { url = 'https://server.cjsah.net:1002/maven/releases/' } } }