Skip to content

Commit 1527f6f

Browse files
committed
Add auto-version workflow and version script
Introduce an automated versioning pipeline: add .github/workflows/auto_version.yml that computes the next version (on push to main or manual dispatch with channel=stable|beta|dev), writes VERSION, commits and creates a v<version> tag. Add scripts/next_version.py which inspects git tags and commits (Conventional Commit style) to determine MAJOR/MINOR/PATCH bumps and numeric prerelease build numbers for beta/dev channels. Update .github/workflows/release.yml to detect 4-segment tags as prereleases and pass the prerelease flag to the GitHub Release action. Update docs/RELEASES.md to describe the automated versioning rules, bump logic, and usage (including how to preview or trigger stable releases). This automates consistent numeric-only versioning and prerelease handling for CI releases.
1 parent 6e17094 commit 1527f6f

4 files changed

Lines changed: 278 additions & 10 deletions

File tree

.github/workflows/auto_version.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: auto-version
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
inputs:
9+
channel:
10+
description: Release channel
11+
required: true
12+
default: dev
13+
type: choice
14+
options:
15+
- stable
16+
- beta
17+
- dev
18+
19+
permissions:
20+
contents: write
21+
22+
concurrency:
23+
group: auto-version
24+
cancel-in-progress: false
25+
26+
jobs:
27+
tag:
28+
if: github.actor != 'github-actions[bot]'
29+
runs-on: ubuntu-latest
30+
steps:
31+
- name: Checkout
32+
uses: actions/checkout@v4
33+
with:
34+
fetch-depth: 0
35+
36+
- name: Setup Python
37+
uses: actions/setup-python@v5
38+
with:
39+
python-version: "3.x"
40+
41+
- name: Compute Version
42+
id: version
43+
shell: bash
44+
run: |
45+
channel="${{ github.event.inputs.channel }}"
46+
if [[ -z "$channel" ]]; then
47+
channel="dev"
48+
fi
49+
version="$(python scripts/next_version.py --channel "$channel" --write)"
50+
echo "version=$version" >> "$GITHUB_OUTPUT"
51+
52+
- name: Commit and Tag
53+
shell: bash
54+
run: |
55+
git config user.name "github-actions[bot]"
56+
git config user.email "github-actions[bot]@users.noreply.github.com"
57+
git add VERSION
58+
if git diff --cached --quiet; then
59+
echo "VERSION unchanged; nothing to tag."
60+
exit 0
61+
fi
62+
git commit -m "chore(release): v${{ steps.version.outputs.version }} [skip release]"
63+
git tag "v${{ steps.version.outputs.version }}"
64+
git push
65+
git push --tags

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,19 @@ jobs:
7171
path: dist
7272
merge-multiple: true
7373

74+
- name: Determine Release Type
75+
shell: bash
76+
run: |
77+
tag="${GITHUB_REF_NAME}"
78+
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
79+
echo "PAKFU_PRERELEASE=true" >> "$GITHUB_ENV"
80+
else
81+
echo "PAKFU_PRERELEASE=false" >> "$GITHUB_ENV"
82+
fi
83+
7484
- name: Publish Release
7585
uses: softprops/action-gh-release@v2
7686
with:
7787
files: dist/**
7888
generate_release_notes: true
89+
prerelease: ${{ env.PAKFU_PRERELEASE == 'true' }}

docs/RELEASES.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,43 @@
11
# PakFu Releases
22

33
## Versioning
4-
PakFu follows SemVer with an updater-friendly, numeric-only identifier.
4+
PakFu follows SemVer with an updater-friendly, numeric-only identifier. Versions
5+
are computed automatically from git history by GitHub Actions.
56

67
Rules:
7-
- `VERSION` is the source of truth and must be strictly numeric dot segments
8+
- `VERSION` is written by automation and must stay strictly numeric dot segments
89
(no leading `v`, no `-beta`/`-rc`, no `+git` metadata).
9-
- Git tags must be `v<version>` and match `VERSION` exactly.
10+
- Git tags are `v<version>` and must match `VERSION` exactly.
1011
- Stable releases use `MAJOR.MINOR.PATCH` (example: `1.4.0`).
1112
- Beta/dev releases use `MAJOR.MINOR.PATCH.BUILD` (example: `1.5.0.3`), and the
12-
GitHub Release must be marked as **prerelease**.
13+
GitHub Release is marked **prerelease** automatically.
1314
- Always increment the numeric portion for every published build; never reuse
1415
a version number.
1516

1617
Why: the auto-updater compares versions using Qt's numeric `QVersionNumber`
1718
parsing, which ignores suffixes like `-beta`. Numeric-only identifiers keep
1819
update ordering reliable.
1920

21+
Automatic bump rules (based on Conventional Commit style since the last stable
22+
tag):
23+
- Breaking change (`type!:` in the subject or `BREAKING CHANGE` in the body)
24+
bumps **MAJOR** (or **MINOR** if `MAJOR=0`).
25+
- `feat:` bumps **MINOR**.
26+
- Everything else bumps **PATCH**.
27+
28+
Stage/maturity rule:
29+
- While `MAJOR=0` (pre-1.0), breaking changes advance **MINOR** instead of
30+
**MAJOR** to reflect ongoing development.
31+
2032
Examples:
2133
- Stable: `1.2.0`
2234
- Beta/dev for the next stable: `1.3.0.1`, `1.3.0.2`
2335

36+
Preview the next version locally:
37+
```sh
38+
python scripts/next_version.py --channel dev
39+
```
40+
2441
## Release Assets
2542
To enable in-app updates, attach platform packages to each GitHub Release.
2643
Current packaging targets:
@@ -32,12 +49,12 @@ Asset names should include platform hints (e.g. `win`, `mac`, `linux`, `x64`) so
3249
the updater can select the correct file automatically.
3350

3451
## Release Workflow
35-
1. Update `VERSION`.
36-
2. Commit changes and create a tag: `git tag vX.Y.Z` (or `vX.Y.Z.BUILD`).
37-
3. Push commits and tag: `git push && git push --tags`.
38-
4. GitHub Actions builds and uploads platform packages automatically.
39-
5. For beta/dev builds, ensure the GitHub Release is marked **prerelease** so
40-
non-stable update channels can see it.
52+
1. Push to `main` (default: auto-creates a **dev** prerelease).
53+
2. The `auto-version` workflow computes the next version, updates `VERSION`,
54+
commits, and tags it.
55+
3. The `release` workflow builds and publishes packages for the tag.
56+
4. For a stable release, run the `auto-version` workflow manually with
57+
`channel=stable`.
4158

4259
The release workflow uses the repository name as the update source and publishes
4360
the artifacts to the GitHub Release matching the tag.

scripts/next_version.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import re
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
VERSION_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?$")
11+
FEATURE_RE = re.compile(r"^feat(\(.+\))?:", re.IGNORECASE)
12+
BREAKING_RE = re.compile(r"^\w+(\(.+\))?!:")
13+
14+
15+
def run_git(args: list[str], cwd: Path) -> str:
16+
try:
17+
return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
18+
except subprocess.CalledProcessError as exc:
19+
msg = exc.output if exc.output else str(exc)
20+
raise RuntimeError(msg) from exc
21+
22+
23+
def parse_version(text: str) -> list[int] | None:
24+
match = VERSION_RE.match(text.strip())
25+
if not match:
26+
return None
27+
parts = [int(group) for group in match.groups() if group is not None]
28+
if len(parts) < 3:
29+
return None
30+
return parts
31+
32+
33+
def format_version(parts: list[int]) -> str:
34+
return ".".join(str(p) for p in parts)
35+
36+
37+
def read_version_file(path: Path) -> list[int]:
38+
raw = path.read_text(encoding="utf-8").strip()
39+
parts = parse_version(raw)
40+
if not parts:
41+
raise RuntimeError(f"VERSION is not numeric: {raw!r}")
42+
return parts[:3]
43+
44+
45+
def list_tags(repo: Path) -> list[tuple[str, list[int]]]:
46+
tags_raw = run_git(["tag", "--list", "v*"], cwd=repo)
47+
tags = []
48+
for tag in tags_raw.splitlines():
49+
parts = parse_version(tag)
50+
if parts:
51+
tags.append((tag, parts))
52+
return tags
53+
54+
55+
def latest_stable_tag(tags: list[tuple[str, list[int]]]) -> tuple[str, list[int]] | None:
56+
stable = [(tag, parts) for tag, parts in tags if len(parts) == 3]
57+
if not stable:
58+
return None
59+
return max(stable, key=lambda item: tuple(item[1]))
60+
61+
62+
def commits_since(repo: Path, base_tag: str | None) -> list[tuple[str, str]]:
63+
if base_tag:
64+
range_spec = f"{base_tag}..HEAD"
65+
else:
66+
range_spec = "HEAD"
67+
log = run_git(["log", range_spec, "--pretty=format:%s%n%b%n==END=="], cwd=repo)
68+
entries = [entry.strip() for entry in log.split("==END==") if entry.strip()]
69+
commits = []
70+
for entry in entries:
71+
lines = entry.splitlines()
72+
subject = lines[0] if lines else ""
73+
body = "\n".join(lines[1:]) if len(lines) > 1 else ""
74+
commits.append((subject.strip(), body))
75+
return commits
76+
77+
78+
def classify_bump(commits: list[tuple[str, str]]) -> str | None:
79+
if not commits:
80+
return None
81+
bump = "patch"
82+
for subject, body in commits:
83+
if BREAKING_RE.match(subject) or "BREAKING CHANGE" in body:
84+
return "major"
85+
if FEATURE_RE.match(subject):
86+
bump = "minor"
87+
return bump
88+
89+
90+
def bump_version(base: list[int], bump: str) -> list[int]:
91+
major, minor, patch = base
92+
if bump == "major":
93+
if major == 0:
94+
minor += 1
95+
patch = 0
96+
else:
97+
major += 1
98+
minor = 0
99+
patch = 0
100+
elif bump == "minor":
101+
minor += 1
102+
patch = 0
103+
else:
104+
patch += 1
105+
return [major, minor, patch]
106+
107+
108+
def next_build_for_base(tags: list[tuple[str, list[int]]], base: list[int]) -> int:
109+
max_build = 0
110+
for _, parts in tags:
111+
if len(parts) != 4:
112+
continue
113+
if parts[:3] == base:
114+
max_build = max(max_build, parts[3])
115+
return max_build + 1
116+
117+
118+
def compute_next_version(repo: Path, channel: str, allow_empty: bool) -> list[int]:
119+
tags = list_tags(repo)
120+
stable_tag = latest_stable_tag(tags)
121+
if stable_tag:
122+
base_tag, base_version = stable_tag
123+
else:
124+
base_tag = None
125+
base_version = read_version_file(repo / "VERSION")
126+
127+
commits = commits_since(repo, base_tag)
128+
bump = classify_bump(commits)
129+
if bump is None:
130+
if not allow_empty:
131+
raise RuntimeError("No commits found since last stable tag.")
132+
bump = "patch"
133+
134+
next_base = bump_version(base_version, bump)
135+
if channel != "stable":
136+
build = next_build_for_base(tags, next_base)
137+
return next_base + [build]
138+
return next_base
139+
140+
141+
def main() -> int:
142+
parser = argparse.ArgumentParser(
143+
description="Compute the next PakFu version from git history.",
144+
)
145+
parser.add_argument(
146+
"--channel",
147+
choices=["stable", "beta", "dev"],
148+
default="stable",
149+
help="Release channel. Beta/dev produce a numeric prerelease build.",
150+
)
151+
parser.add_argument(
152+
"--write",
153+
action="store_true",
154+
help="Write the computed version to VERSION.",
155+
)
156+
parser.add_argument(
157+
"--allow-empty",
158+
action="store_true",
159+
help="Allow version bumps even if no commits were found.",
160+
)
161+
args = parser.parse_args()
162+
163+
repo = Path(__file__).resolve().parent.parent
164+
version_parts = compute_next_version(repo, args.channel, args.allow_empty)
165+
version = format_version(version_parts)
166+
167+
if args.write:
168+
(repo / "VERSION").write_text(version + "\n", encoding="utf-8")
169+
170+
print(version)
171+
return 0
172+
173+
174+
if __name__ == "__main__":
175+
raise SystemExit(main())

0 commit comments

Comments
 (0)