Skip to content

Commit 2136212

Browse files
committed
ci: build Windows artifacts and prepare tagged releases
1 parent cafd22a commit 2136212

7 files changed

Lines changed: 253 additions & 0 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: build
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
windows:
14+
runs-on: windows-2025
15+
steps:
16+
- uses: actions/checkout@v7
17+
18+
- uses: microsoft/setup-msbuild@v3
19+
20+
- uses: actions/setup-python@v6
21+
with:
22+
python-version: '3.13'
23+
24+
- name: Build .NET Framework projects
25+
run: msbuild FireEmblemThreeHouse.sln /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /v:minimal
26+
27+
- name: Package and verify downloads
28+
run: python tools/package.py
29+
30+
- uses: actions/upload-artifact@v7
31+
with:
32+
name: fe3h-editor-windows
33+
path: dist/*
34+
if-no-files-found: error
35+
retention-days: 14

‎.github/workflows/release.yml‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
windows:
13+
runs-on: windows-2025
14+
steps:
15+
- uses: actions/checkout@v7
16+
17+
- uses: microsoft/setup-msbuild@v3
18+
19+
- uses: actions/setup-python@v6
20+
with:
21+
python-version: '3.13'
22+
23+
- name: Verify tag version
24+
shell: pwsh
25+
run: |
26+
$version = (Get-Content VERSION -Raw).Trim()
27+
if ($env:GITHUB_REF_NAME -ne "v$version") { throw "Tag does not match VERSION" }
28+
29+
- name: Build .NET Framework projects
30+
run: msbuild FireEmblemThreeHouse.sln /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /v:minimal
31+
32+
- name: Package and verify downloads
33+
run: python tools/package.py
34+
35+
- uses: actions/upload-artifact@v7
36+
with:
37+
name: fe3h-editor-release
38+
path: dist/*
39+
if-no-files-found: error
40+
41+
- name: Publish prerelease
42+
shell: pwsh
43+
env:
44+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45+
run: |
46+
$version = (Get-Content VERSION -Raw).Trim()
47+
gh release create $env:GITHUB_REF_NAME `
48+
dist/FETH_SaveEditor.exe `
49+
"dist/fe3h-editor-v$version.zip" `
50+
dist/SHA256SUMS `
51+
--verify-tag --generate-notes --prerelease
52+
if ($LASTEXITCODE -ne 0) { throw "Failed to publish release" }
53+
54+
- name: Verify downloadable files
55+
shell: pwsh
56+
env:
57+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58+
run: |
59+
$version = (Get-Content VERSION -Raw).Trim()
60+
New-Item -ItemType Directory -Path release-download | Out-Null
61+
gh release download $env:GITHUB_REF_NAME --dir release-download
62+
if ($LASTEXITCODE -ne 0) { throw "Failed to download release" }
63+
foreach ($name in @("FETH_SaveEditor.exe", "fe3h-editor-v$version.zip")) {
64+
$expected = (Get-FileHash "dist/$name" -Algorithm SHA256).Hash
65+
$actual = (Get-FileHash "release-download/$name" -Algorithm SHA256).Hash
66+
if ($expected -ne $actual) { throw "Download checksum mismatch: $name" }
67+
}

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ DataUnpacker/obj/
33
SaveEditor/bin/
44
DataUnpacker/bin/
55
*.user
6+
dist/

‎VERSION‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.1.0

‎docs/RELEASING.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Building and releasing
2+
3+
`VERSION` is this fork's release number. The supported game version is 1.2.0.
4+
5+
Every push to `master`, pull request, or manual run of the [build workflow](../.github/workflows/build.yml) builds both .NET Framework 4.7.2 projects on Windows. Its `fe3h-editor-windows` artifact contains a standalone save editor executable, a ZIP with both tools and their config files, and SHA-256 checksums. Artifacts are retained for 14 days.
6+
7+
To publish after reviewing a successful Windows build:
8+
9+
1. Update `VERSION` in a Conventional Commit and push `master`.
10+
2. Wait for the build workflow to pass for that commit.
11+
3. Run `python3 tools/release.py --current --check` to check readiness.
12+
4. Run `python3 tools/release.py --current` to create and push an annotated version tag.
13+
14+
The [release workflow](../.github/workflows/release.yml) builds the tagged source on Windows, packages the same files, publishes a prerelease, then downloads and checks the published files. The release script does not create a release until the version tag is pushed. It requires GitHub CLI authentication.

‎tools/package.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""Package the Windows Release build for CI downloads and GitHub Releases."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import hashlib
8+
import re
9+
import shutil
10+
import zipfile
11+
from pathlib import Path
12+
13+
14+
ROOT = Path(__file__).resolve().parent.parent
15+
FILES = {
16+
"FETH_SaveEditor.exe": ROOT / "SaveEditor/bin/Release/FETH_SaveEditor.exe",
17+
"FETH_SaveEditor.exe.config": ROOT / "SaveEditor/bin/Release/FETH_SaveEditor.exe.config",
18+
"DataUnpacker.exe": ROOT / "DataUnpacker/bin/Release/DataUnpacker.exe",
19+
"DataUnpacker.exe.config": ROOT / "DataUnpacker/bin/Release/DataUnpacker.exe.config",
20+
"README.md": ROOT / "README.md",
21+
}
22+
23+
24+
def version() -> str:
25+
value = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
26+
if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?", value):
27+
raise ValueError(f"invalid VERSION: {value}")
28+
return value
29+
30+
31+
def digest(path: Path) -> str:
32+
sha256 = hashlib.sha256()
33+
with path.open("rb") as source:
34+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
35+
sha256.update(chunk)
36+
return sha256.hexdigest()
37+
38+
39+
def main() -> None:
40+
parser = argparse.ArgumentParser(description=__doc__)
41+
parser.add_argument("--output", type=Path, default=ROOT / "dist")
42+
args = parser.parse_args()
43+
44+
for name, source in FILES.items():
45+
if not source.is_file() or source.stat().st_size == 0:
46+
raise FileNotFoundError(f"missing build file: {name} ({source})")
47+
48+
output = args.output.resolve()
49+
output.mkdir(parents=True, exist_ok=True)
50+
editor = output / "FETH_SaveEditor.exe"
51+
package = output / f"fe3h-editor-v{version()}.zip"
52+
checksums = output / "SHA256SUMS"
53+
54+
shutil.copy2(FILES["FETH_SaveEditor.exe"], editor)
55+
with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
56+
for name, source in FILES.items():
57+
archive.write(source, name)
58+
59+
with zipfile.ZipFile(package) as archive:
60+
if archive.testzip() is not None or set(archive.namelist()) != set(FILES):
61+
raise ValueError("release ZIP failed verification")
62+
63+
checksums.write_text(
64+
"".join(f"{digest(path)} {path.name}\n" for path in (editor, package)),
65+
encoding="utf-8",
66+
)
67+
for path in (editor, package, checksums):
68+
print(path)
69+
70+
71+
if __name__ == "__main__":
72+
main()

‎tools/release.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env python3
2+
"""Tag a reviewed master commit; GitHub Actions builds and publishes it."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import subprocess
9+
from pathlib import Path
10+
11+
from package import ROOT, version
12+
13+
14+
def run(*args: str) -> str:
15+
result = subprocess.run(args, cwd=ROOT, check=True, text=True, capture_output=True)
16+
return result.stdout.strip()
17+
18+
19+
def check_ready(tag: str) -> None:
20+
if Path(run("git", "rev-parse", "--show-toplevel")) != ROOT:
21+
raise ValueError("run from this repository")
22+
if run("git", "branch", "--show-current") != "master":
23+
raise ValueError("releases must come from master")
24+
if run("git", "status", "--porcelain"):
25+
raise ValueError("commit working-tree changes first")
26+
27+
run("git", "fetch", "origin", "master", "--tags")
28+
head = run("git", "rev-parse", "HEAD")
29+
if head != run("git", "rev-parse", "origin/master"):
30+
raise ValueError("local master is not synchronized with origin/master")
31+
if run("git", "tag", "--list", tag):
32+
raise ValueError(f"tag already exists: {tag}")
33+
34+
builds = json.loads(run(
35+
"gh", "run", "list", "--workflow", "build.yml", "--commit", head,
36+
"--json", "conclusion,status", "--limit", "20",
37+
))
38+
if not any(build["status"] == "completed" and build["conclusion"] == "success" for build in builds):
39+
raise ValueError("wait for a successful Windows build of this commit before releasing")
40+
41+
42+
def main() -> None:
43+
parser = argparse.ArgumentParser(description=__doc__)
44+
parser.add_argument("--current", action="store_true", required=True)
45+
parser.add_argument("--check", action="store_true", help="validate without tagging")
46+
parser.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
47+
args = parser.parse_args()
48+
49+
tag = f"v{version()}"
50+
check_ready(tag)
51+
if args.check:
52+
print(f"Ready to publish {tag} from {run('git', 'rev-parse', '--short', 'HEAD')}")
53+
return
54+
if not args.yes and input(f"Create and push {tag}? [y/N] ").lower() not in ("y", "yes"):
55+
return
56+
57+
run("git", "tag", "-a", tag, "-m", tag)
58+
run("git", "push", "origin", tag)
59+
print(f"Pushed {tag}. Check the release workflow before sharing its downloads.")
60+
61+
62+
if __name__ == "__main__":
63+
main()

0 commit comments

Comments
 (0)