Skip to content

Commit dcab5a3

Browse files
committed
ci: automate GitHub release tagging
1 parent 2b39558 commit dcab5a3

10 files changed

Lines changed: 358 additions & 8 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: Auto Release Tag
2+
3+
on:
4+
workflow_run:
5+
workflows: ["CI"]
6+
types: [completed]
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: auto-release-main
13+
cancel-in-progress: false
14+
15+
jobs:
16+
tag:
17+
name: auto-tag release
18+
if: >-
19+
github.event.workflow_run.conclusion == 'success' &&
20+
github.event.workflow_run.event == 'push' &&
21+
github.event.workflow_run.head_branch == 'main'
22+
runs-on: ubuntu-latest
23+
24+
steps:
25+
- name: Checkout main tip
26+
uses: actions/checkout@v4
27+
with:
28+
ref: ${{ github.event.workflow_run.head_sha }}
29+
fetch-depth: 0
30+
31+
- name: Fetch tags
32+
run: git fetch --force --tags origin
33+
34+
- name: Ensure workflow run matches current main tip
35+
id: current_tip
36+
shell: bash
37+
run: |
38+
set -euo pipefail
39+
current_main="$(git ls-remote origin refs/heads/main | cut -f1)"
40+
if [ "$current_main" != "${{ github.event.workflow_run.head_sha }}" ]; then
41+
echo "is_current=false" >> "$GITHUB_OUTPUT"
42+
echo "Skipping auto-tag because a newer main commit exists."
43+
exit 0
44+
fi
45+
echo "is_current=true" >> "$GITHUB_OUTPUT"
46+
47+
- name: Set up Python
48+
if: steps.current_tip.outputs.is_current == 'true'
49+
uses: actions/setup-python@v5
50+
with:
51+
python-version: "3.x"
52+
53+
- name: Resolve release tag
54+
if: steps.current_tip.outputs.is_current == 'true'
55+
id: resolve
56+
shell: bash
57+
run: |
58+
set -euo pipefail
59+
python3 scripts/resolve_release_tag.py > release-decision.json
60+
cat release-decision.json
61+
python3 - <<'PY'
62+
import json
63+
import os
64+
from pathlib import Path
65+
66+
data = json.loads(Path("release-decision.json").read_text(encoding="utf-8"))
67+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
68+
handle.write(f"should_tag={'true' if data['should_tag'] else 'false'}\n")
69+
handle.write(f"tag={data.get('tag') or ''}\n")
70+
handle.write(f"reason={data['reason']}\n")
71+
PY
72+
73+
- name: Install Rust
74+
if: steps.resolve.outputs.should_tag == 'true'
75+
uses: dtolnay/rust-toolchain@stable
76+
77+
- name: Rust cache
78+
if: steps.resolve.outputs.should_tag == 'true'
79+
uses: Swatinem/rust-cache@v2
80+
81+
- name: Release preflight
82+
if: steps.resolve.outputs.should_tag == 'true'
83+
run: cargo run -p loopforge-cli -- release check --tag "${{ steps.resolve.outputs.tag }}"
84+
85+
- name: Create semver tag
86+
if: steps.resolve.outputs.should_tag == 'true'
87+
shell: bash
88+
run: |
89+
set -euo pipefail
90+
git config user.name "github-actions[bot]"
91+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
92+
git tag "${{ steps.resolve.outputs.tag }}" "${{ github.event.workflow_run.head_sha }}"
93+
git push origin "${{ steps.resolve.outputs.tag }}"
94+
95+
- name: No release needed
96+
if: steps.current_tip.outputs.is_current == 'true' && steps.resolve.outputs.should_tag != 'true'
97+
run: echo "${{ steps.resolve.outputs.reason }}"

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
scripts.tests.test_ci_workflows \
2626
scripts.tests.test_verify_version_changelog \
2727
scripts.tests.test_verify_release_consistency \
28+
scripts.tests.test_resolve_release_tag \
2829
scripts.tests.test_provider_health_report \
2930
scripts.tests.test_package_release \
3031
scripts.tests.test_onboard_metrics_report

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,13 @@ To run the optional NVIDIA NIM smoke test: `NVIDIA_API_KEY=<key> cargo test --wo
9393

9494
## Releasing (maintainers)
9595

96-
Pushing a `v*` tag triggers the Release workflow which attaches prebuilt archives to a GitHub Release.
96+
Pushing a `v*` tag still triggers the Release workflow which attaches prebuilt archives to a GitHub Release.
97+
On `main`, maintainers usually do not need to push the tag manually anymore: once CI succeeds and the workspace version/changelog are ready, the `Auto Release Tag` workflow creates the missing `vX.Y.Z` tag automatically and then the existing Release workflow publishes the GitHub release.
9798
Before every release, follow the versioning/changelog policy in `docs/versioning-and-release.md`.
9899
If an iteration is marked as "needs version bump", the same change set must include both version number updates and changelog updates (`CHANGELOG.md`).
99100

101+
Manual fallback:
102+
100103
```bash
101104
git tag v1.0.0
102105
git push origin v1.0.0

README.zh-CN.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ loopforge agent run --workspace /tmp/loopforge-work --prompt "Create hello.txt w
6666

6767
## 发版(维护者)
6868

69-
推送一个 `v*` tag 会触发 Release 工作流,构建并把预编译压缩包上传到 GitHub Release。
69+
推送一个 `v*` tag 仍然会触发 Release 工作流,构建并把预编译压缩包上传到 GitHub Release。
70+
但在 `main` 上,维护者通常不需要再手动推 tag:当 CI 成功、workspace version 与 `CHANGELOG.md` 都准备好后,`Auto Release Tag` 工作流会自动创建缺失的 `vX.Y.Z` tag,随后由现有 Release 工作流发布 GitHub 版本。
7071
每次发版前请遵循 `docs/versioning-and-release.md` 的版本与更新说明规则。
7172
如果本次迭代被标记为“需要升级版本号”,则同一批改动必须同时包含版本号更新和 `CHANGELOG.md` 更新。
7273

74+
手动兜底:
75+
7376
```bash
7477
git tag v1.0.0
7578
git push origin v1.0.0

docs-site/examples/case-tasks/release-readiness-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
- `notes/release-readiness-audit.md`
2424

2525
!!! note
26-
This task is for preflight analysis. It should not create tags or publish releases.
26+
This task is for preflight analysis. It should not create tags or publish releases. In LoopForge itself, maintainers now typically merge the version/changelog change to `main` and let Actions auto-create the missing semver tag before the existing Release workflow publishes GitHub assets.

docs-site/zh-CN/examples/case-tasks/release-readiness-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
- `notes/release-readiness-audit.md`
2424

2525
!!! note
26-
这是发布前分析任务,不应创建 tag 或实际发布。
26+
这是发布前分析任务,不应创建 tag 或实际发布。在 LoopForge 自身仓库里,维护者现在通常只需要把版本号与 `CHANGELOG.md` 的更新合并进 `main`,后续由 Actions 自动创建缺失的 semver tag,再由现有 Release 工作流发布 GitHub 版本。

docs/versioning-and-release.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This repo uses SemVer with a `v` tag prefix (`vMAJOR.MINOR.PATCH`):
1414
- `MINOR`: planned feature iteration (preferred during `0.x` stage).
1515
- `PATCH`: bugfixes, documentation, or small safe improvements.
1616

17-
Current workspace version is `1.2.0` (from root `Cargo.toml` `[workspace.package].version`).
17+
Current workspace version is `1.3.0` (from root `Cargo.toml` `[workspace.package].version`).
1818

1919
## Public/Internal Publishing Boundary
2020

@@ -28,14 +28,16 @@ Release preflight now treats competitor-analysis references in public docs as a
2828
Release target:
2929
- Core CLI path is runnable (`loopforge init`, `loopforge agent run`).
3030
- Existing multi-provider routing and harness flow are stable enough for first external users.
31-
- GitHub Release binary workflow is available (tag-triggered).
31+
- GitHub Release binary workflow is available (tag-triggered), and `main` now auto-creates the missing semver tag after CI succeeds when the workspace version is ahead of the latest published tag.
3232

3333
Release checklist:
3434
1. Run full test suite: `cargo test`.
3535
2. Confirm release packaging script works locally:
3636
`python3 scripts/package_release.py --version v1.0.0 --target local --bin target/release/loopforge --out-dir dist`
3737
3. Ensure `CHANGELOG.md` contains a `1.0.0` section.
38-
4. Create and push tag:
38+
4. Merge the version/changelog change to `main`.
39+
5. Wait for CI to pass; the `Auto Release Tag` workflow will create and push `vX.Y.Z` automatically when the workspace version is ahead of the latest release tag.
40+
6. Use manual tag push only as a fallback when automation is intentionally bypassed:
3941
`git tag v1.0.0 && git push origin v1.0.0`
4042

4143
## Mandatory Rule for Version-Bump Iterations
@@ -62,7 +64,7 @@ If either item is missing, iteration is not considered releasable.
6264
3. If iteration is marked "needs version bump", update version + changelog together.
6365
4. Run verification (`cargo test`, plus release packaging smoke check when release-bound).
6466
5. Run `loopforge release check --tag vX.Y.Z` and confirm it passes.
65-
6. Merge, then cut tag (`vX.Y.Z`).
67+
6. Merge to `main`; CI + `Auto Release Tag` will cut `vX.Y.Z` automatically if release metadata is ready.
6668

6769
## Changelog Format
6870

scripts/resolve_release_tag.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import re
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
TAG_PATTERN = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
12+
VERSION_PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
13+
14+
15+
def extract_workspace_version(cargo_toml: str) -> str | None:
16+
in_workspace_package = False
17+
for raw_line in cargo_toml.splitlines():
18+
line = raw_line.strip()
19+
if line.startswith("[") and line.endswith("]"):
20+
in_workspace_package = line == "[workspace.package]"
21+
continue
22+
if not in_workspace_package:
23+
continue
24+
m = re.match(r'version\s*=\s*"([^"]+)"', line)
25+
if m:
26+
return m.group(1)
27+
return None
28+
29+
30+
def changelog_has_version_section(changelog_text: str, version: str) -> bool:
31+
pattern = re.compile(rf"^##\s*\[{re.escape(version)}\](?:\s*-.*)?\s*$", re.MULTILINE)
32+
return pattern.search(changelog_text) is not None
33+
34+
35+
def parse_version(version: str) -> tuple[int, int, int] | None:
36+
m = VERSION_PATTERN.fullmatch(version.strip())
37+
if not m:
38+
return None
39+
return tuple(int(part) for part in m.groups())
40+
41+
42+
def parse_tag(tag: str) -> tuple[int, int, int] | None:
43+
m = TAG_PATTERN.fullmatch(tag.strip())
44+
if not m:
45+
return None
46+
return tuple(int(part) for part in m.groups())
47+
48+
49+
def latest_semver_tag(tags: list[str]) -> str | None:
50+
parsed: list[tuple[tuple[int, int, int], str]] = []
51+
for tag in tags:
52+
version = parse_tag(tag)
53+
if version is None:
54+
continue
55+
parsed.append((version, tag.strip()))
56+
if not parsed:
57+
return None
58+
parsed.sort()
59+
return parsed[-1][1]
60+
61+
62+
def decide_release_tag(
63+
*,
64+
workspace_version: str,
65+
changelog_text: str,
66+
existing_tags: list[str],
67+
) -> tuple[bool, str | None, str]:
68+
parsed_workspace = parse_version(workspace_version)
69+
if parsed_workspace is None:
70+
raise ValueError(
71+
f"workspace version '{workspace_version}' must match X.Y.Z before auto release tagging."
72+
)
73+
74+
exact_tag = f"v{workspace_version}"
75+
normalized_tags = [tag.strip() for tag in existing_tags if tag.strip()]
76+
if exact_tag in normalized_tags:
77+
return False, None, f"release tag {exact_tag} already exists; nothing to do."
78+
79+
latest_tag = latest_semver_tag(normalized_tags)
80+
if latest_tag is not None:
81+
latest_version = parse_tag(latest_tag)
82+
assert latest_version is not None
83+
if parsed_workspace < latest_version:
84+
raise ValueError(
85+
f"workspace version {workspace_version} is behind latest release tag {latest_tag}."
86+
)
87+
if parsed_workspace == latest_version:
88+
return (
89+
False,
90+
None,
91+
f"latest release tag version already matches workspace version ({latest_tag}); nothing to do.",
92+
)
93+
94+
if not changelog_has_version_section(changelog_text, workspace_version):
95+
raise ValueError(
96+
f"CHANGELOG.md is missing section [{workspace_version}] required for auto release tagging."
97+
)
98+
99+
return True, exact_tag, f"create release tag {exact_tag} for workspace version {workspace_version}."
100+
101+
102+
def load_git_tags(repo_root: Path) -> list[str]:
103+
output = subprocess.run(
104+
["git", "tag", "--list"],
105+
cwd=repo_root,
106+
check=True,
107+
capture_output=True,
108+
text=True,
109+
)
110+
return [line.strip() for line in output.stdout.splitlines() if line.strip()]
111+
112+
113+
def main(argv: list[str]) -> int:
114+
parser = argparse.ArgumentParser(
115+
description="Resolve whether the current main tip should auto-create a GitHub release tag."
116+
)
117+
parser.add_argument("--cargo-toml", default="Cargo.toml")
118+
parser.add_argument("--changelog", default="CHANGELOG.md")
119+
args = parser.parse_args(argv)
120+
121+
repo_root = Path(__file__).resolve().parents[1]
122+
cargo_path = (repo_root / args.cargo_toml).resolve()
123+
changelog_path = (repo_root / args.changelog).resolve()
124+
125+
try:
126+
cargo_text = cargo_path.read_text(encoding="utf-8")
127+
changelog_text = changelog_path.read_text(encoding="utf-8")
128+
existing_tags = load_git_tags(repo_root)
129+
except subprocess.CalledProcessError as err:
130+
print(f"error: failed to inspect git tags: {err}", file=sys.stderr)
131+
return 2
132+
except OSError as err:
133+
print(f"error: failed to read release metadata files: {err}", file=sys.stderr)
134+
return 2
135+
136+
workspace_version = extract_workspace_version(cargo_text)
137+
if workspace_version is None:
138+
print("error: failed to parse [workspace.package].version from Cargo.toml.", file=sys.stderr)
139+
return 2
140+
141+
try:
142+
should_tag, tag, reason = decide_release_tag(
143+
workspace_version=workspace_version,
144+
changelog_text=changelog_text,
145+
existing_tags=existing_tags,
146+
)
147+
except ValueError as err:
148+
print(f"error: {err}", file=sys.stderr)
149+
return 1
150+
151+
json.dump(
152+
{
153+
"should_tag": should_tag,
154+
"tag": tag,
155+
"reason": reason,
156+
"workspace_version": workspace_version,
157+
"latest_semver_tag": latest_semver_tag(existing_tags),
158+
},
159+
sys.stdout,
160+
)
161+
sys.stdout.write("\n")
162+
return 0
163+
164+
165+
if __name__ == "__main__":
166+
raise SystemExit(main(sys.argv[1:]))

scripts/tests/test_ci_workflows.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def test_ci_runs_versioning_script_tests(self):
1212
self.assertIn("python3 -m unittest", ci)
1313
self.assertIn("scripts.tests.test_verify_version_changelog", ci)
1414
self.assertIn("scripts.tests.test_verify_release_consistency", ci)
15+
self.assertIn("scripts.tests.test_resolve_release_tag", ci)
1516
self.assertIn("scripts.tests.test_provider_health_report", ci)
1617
self.assertIn("scripts.tests.test_package_release", ci)
1718
self.assertIn("scripts.tests.test_onboard_metrics_report", ci)
@@ -53,6 +54,18 @@ def test_release_workflow_has_packaged_binary_smoke_steps(self):
5354
self.assertIn("runner.os != 'Windows'", workflow)
5455
self.assertIn("runner.os == 'Windows'", workflow)
5556

57+
def test_auto_release_tag_workflow_creates_missing_semver_tag_after_ci(self):
58+
workflow = (
59+
REPO_ROOT / ".github/workflows/auto-release-tag.yml"
60+
).read_text(encoding="utf-8")
61+
self.assertIn("workflow_run", workflow)
62+
self.assertIn('workflows: ["CI"]', workflow)
63+
self.assertIn("github.event.workflow_run.conclusion == 'success'", workflow)
64+
self.assertIn("github.event.workflow_run.head_branch == 'main'", workflow)
65+
self.assertIn("scripts/resolve_release_tag.py", workflow)
66+
self.assertIn("release check --tag", workflow)
67+
self.assertIn("git push origin", workflow)
68+
5669
def test_release_dry_run_workflow_has_packaged_binary_smoke_steps(self):
5770
workflow = (
5871
REPO_ROOT / ".github/workflows/release-dry-run.yml"

0 commit comments

Comments
 (0)