From 046fa4da8f896dee6934cbbde3c1ede354bd3462 Mon Sep 17 00:00:00 2001 From: Gugle Date: Mon, 9 Nov 2026 11:26:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E6=94=AF=E6=8C=81=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=90=8E=E7=BC=80=E4=B8=8E=E5=8F=AF=E9=80=89=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E5=B8=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按加号后缀区分预发布、正式构建和热修复版本,仅纯版本创建 patch 升版 PR - 可选调用 GitHub 原生接口生成默认分支发布说明并冻结结果供续跑复用 - 保持两站类型及补发回执一致,补充发布规则和回归检查 --- .github/release/common.py | 13 +- .github/release/mod_sites.py | 9 +- .github/release/release.py | 27 +++- .github/release/test_release.py | 4 +- .github/release/test_release_options.py | 203 ++++++++++++++++++++++++ .github/workflows/build_and_test.yml | 3 + .github/workflows/mod_site.yml | 2 +- .github/workflows/pull_request.yml | 3 + .github/workflows/release.yml | 10 +- RELEASING.md | 26 ++- 10 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 .github/release/test_release_options.py 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..b2b0266d1 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" @@ -81,6 +81,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 +98,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 +123,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"]) diff --git a/.github/release/release.py b/.github/release/release.py index 613daf3bf..e8dd8933c 100644 --- a/.github/release/release.py +++ b/.github/release/release.py @@ -8,7 +8,7 @@ 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) def prepare(): @@ -37,6 +37,10 @@ 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["release_notes"] = github("releases/generate-notes", { + "tag_name": plan["tag"], "target_commitish": targets[ANCHOR]["sha"], + })["body"] 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/test_release.py b/.github/release/test_release.py index 12d356d18..d81941d21 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:]]]: diff --git a/.github/release/test_release_options.py b/.github/release/test_release_options.py new file mode 100644 index 000000000..fdbf74286 --- /dev/null +++ b/.github/release/test_release_options.py @@ -0,0 +1,203 @@ +"""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.assertEqual(path, "releases/generate-notes") + self.assertEqual(data, {"tag_name": "v" + version, "target_commitish": "sha-26.1"}) + return {"name": "Generated title", "body": notes} + 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, "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, 2 if enabled else 1) + 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 index 9ea4554f2..49cbbd7ed 100644 --- a/.github/workflows/mod_site.yml +++ b/.github/workflows/mod_site.yml @@ -11,7 +11,7 @@ on: workflow_dispatch: inputs: tag: - description: Existing release tag (vX.Y.Z) + description: Existing release tag (e.g. v2.0.0 or v2.0.0+beta.1) type: string required: true line: 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..33fbda1ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,10 @@ name: Release AnvilLib 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 } @@ -168,6 +175,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..5334fec6d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,28 @@ -# AnvilLib 正式发布 +# AnvilLib 发布 -在默认分支 `dev/26.1` 打开 **Actions → Release AnvilLib → Run workflow**。勾选 `dry_run` 可先构建七个版本并查看文件校验表。正式发布时取消勾选即可。同一时间只运行一次正式发布或演练。 +在默认分支 `dev/26.1` 打开 **Actions → Release AnvilLib → Run workflow**。勾选 `dry_run` 可先构建七个版本并查看文件校验表,取消勾选后执行发布。同一时间只运行一次发布或演练。 + +可选的 `generate_release_notes` 默认关闭。开启后直接调用 GitHub 的 Generate release notes API,以本次锁定的 `dev/26.1` SHA 为目标,按 GitHub 自动选择的上一 tag 生成合并 PR、贡献者及完整变更链接。正文采用 GitHub 的返回内容,并附上七份构件的源码和校验表。仓库的 `.github/release.yml` 或 `.github/release.yaml` 配置同样生效。结果保存在原运行的 `source-plan` 和 tag 注释中,重跑复用该内容,`dry_run` 摘要也会展示它。 ## 版本与构件 -七条开发线为 `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 标记由正式版维护。 + +每条分支用自己的 Gradle Wrapper 和 JDK 从锁定的 SHA 构建。`stageRelease` 收集该分支模块清单中的全部发布单元、sync 注解处理器和主模块。校验覆盖 POM 的内部依赖、主 JAR 的模组版本、每个内嵌 slim JAR 的字节及 Maven 附件。 -带注解 tag `vX.Y.Z` 指向锁定的默认分支提交。运行编号、七个源码 SHA、平台项目 ID 和供下载文件的 SHA-256 都在 tag 注释中。GitHub Release 展示七个主 JAR 及对应的源码链接。Minecraft 文件名取自实际游戏版本,例如 `dev/26.1` 构建的文件名中使用 `26.1.2`。 +带注解 tag `v` 指向锁定的默认分支提交。运行编号、七个源码 SHA、平台项目 ID 和供下载文件的 SHA-256 都在 tag 注释中。GitHub Release 展示七个主 JAR 及对应的源码链接。Minecraft 文件名取自实际游戏版本,例如 `dev/26.1` 构建的文件名中使用 `26.1.2`。 | 渠道 | 完成条件 | 失败处理 | | --- | --- | --- | @@ -14,7 +30,7 @@ | GitHub | 七个附件的摘要与 tag 清单一致,七个 Central 成功回执已记录 | 在原运行续传缺失附件或重试公开草稿 | | 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