Skip to content

Commit d01b90c

Browse files
committed
build: add release packaging script
1 parent 0207a5d commit d01b90c

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Rust
22
/target/
3+
/dist/
34
**/*.rs.bk
45

56
# RexOS runtime
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# GitHub Release Binaries Implementation Plan
2+
3+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4+
5+
**Goal:** Publish prebuilt `rexos` CLI binaries to GitHub Releases so end users can download/run without cloning the repo, and add CI to validate changes with `cargo test`.
6+
7+
**Architecture:** Add two GitHub Actions workflows:
8+
- `CI` workflow runs `cargo test` on PRs/pushes.
9+
- `Release` workflow (tag-triggered) builds `rexos` across a small OS matrix, packages archives, and uploads them to a GitHub Release.
10+
11+
Use a small Python (stdlib-only) packaging script to produce consistent `.tar.gz`/`.zip` archives and `.sha256` checksums across platforms.
12+
13+
**Tech Stack:** GitHub Actions, Rust (cargo), Python 3 (stdlib: `tarfile`, `zipfile`, `hashlib`, `argparse`).
14+
15+
---
16+
17+
### Task 1: Add a cross-platform release packager
18+
19+
**Files:**
20+
- Create: `scripts/package_release.py`
21+
22+
**Step 1: Write a minimal “packager smoke” command (manual test)**
23+
24+
Run (local):
25+
```bash
26+
cargo build --release -p rexos-cli
27+
python3 scripts/package_release.py --version v0.0.0 --target local --bin target/release/rexos --out-dir dist
28+
ls -la dist
29+
```
30+
31+
Expected:
32+
- `dist/rexos-v0.0.0-local.tar.gz`
33+
- `dist/rexos-v0.0.0-local.tar.gz.sha256`
34+
35+
**Step 2: Implement the packaging script**
36+
37+
Requirements:
38+
- Create a staging directory `dist/rexos-<version>-<target>/`
39+
- Copy the built binary into the staging directory (`rexos` or `rexos.exe`)
40+
- Optionally include `README.md` (and `LICENSE` if present)
41+
- Produce:
42+
- `.zip` for Windows targets
43+
- `.tar.gz` for everything else
44+
- Write `<archive>.sha256` (SHA-256 hex + two spaces + filename)
45+
46+
**Step 3: Re-run the smoke command**
47+
48+
Expected: archive + checksum created, and the binary is present inside the archive.
49+
50+
**Step 4: Commit**
51+
52+
```bash
53+
git add scripts/package_release.py
54+
git commit -m "build: add release packaging script"
55+
```
56+
57+
---
58+
59+
### Task 2: Add CI workflow (tests)
60+
61+
**Files:**
62+
- Create: `.github/workflows/ci.yml`
63+
64+
**Step 1: Implement CI**
65+
66+
Requirements:
67+
- Trigger on `pull_request` and `push` to `main`
68+
- Run `cargo test` (workspace) on a small OS matrix (Linux/macOS/Windows)
69+
- Use Rust cache (`Swatinem/rust-cache`) for speed
70+
71+
**Step 2: Validate locally**
72+
73+
Run:
74+
```bash
75+
cargo test
76+
```
77+
78+
**Step 3: Commit**
79+
80+
```bash
81+
git add .github/workflows/ci.yml
82+
git commit -m "ci: add github actions test workflow"
83+
```
84+
85+
---
86+
87+
### Task 3: Add Release workflow (tagged builds → GitHub Releases)
88+
89+
**Files:**
90+
- Create: `.github/workflows/release.yml`
91+
92+
**Step 1: Implement build matrix**
93+
94+
Matrix (initial):
95+
- `ubuntu-latest``x86_64-unknown-linux-gnu`
96+
- `macos-13``x86_64-apple-darwin`
97+
- `macos-14``aarch64-apple-darwin`
98+
- `windows-latest``x86_64-pc-windows-msvc`
99+
100+
Each build job:
101+
- `cargo build --release -p rexos-cli --locked`
102+
- `python scripts/package_release.py --version $TAG --target $TARGET ...`
103+
- Upload `dist/*` as workflow artifacts
104+
105+
Release job:
106+
- Download all artifacts into `dist/`
107+
- Create/update GitHub Release for the tag and upload `dist/*` as assets
108+
109+
**Step 2: Document release process**
110+
111+
Update `README.md` with:
112+
- Download-from-Releases instructions for end users
113+
- Maintainer instructions: `git tag vX.Y.Z && git push origin vX.Y.Z` triggers the workflow
114+
115+
**Step 3: Commit**
116+
117+
```bash
118+
git add .github/workflows/release.yml README.md
119+
git commit -m "release: build and upload binaries on tags"
120+
```
121+
122+
---
123+
124+
### Task 4: Final verification
125+
126+
**Step 1: Run tests**
127+
128+
Run:
129+
```bash
130+
cargo test
131+
```
132+
133+
Expected: all tests pass.
134+
135+
**Step 2: Ensure worktree is clean**
136+
137+
Run:
138+
```bash
139+
git status -sb
140+
```
141+
142+
Expected: no uncommitted changes.
143+

scripts/package_release.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import hashlib
6+
import os
7+
import shutil
8+
import sys
9+
import tarfile
10+
import zipfile
11+
from pathlib import Path
12+
13+
14+
def sha256_file(path: Path) -> str:
15+
digest = hashlib.sha256()
16+
with path.open("rb") as f:
17+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
18+
digest.update(chunk)
19+
return digest.hexdigest()
20+
21+
22+
def should_zip(target: str, bin_path: Path) -> bool:
23+
if bin_path.suffix.lower() == ".exe":
24+
return True
25+
t = target.lower()
26+
return ("windows" in t) or t.endswith("msvc")
27+
28+
29+
def create_tar_gz(archive_path: Path, stage_dir: Path) -> None:
30+
with tarfile.open(archive_path, "w:gz") as tf:
31+
tf.add(stage_dir, arcname=stage_dir.name)
32+
33+
34+
def create_zip(archive_path: Path, stage_dir: Path) -> None:
35+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
36+
for p in stage_dir.rglob("*"):
37+
if p.is_dir():
38+
continue
39+
zf.write(p, arcname=str(p.relative_to(stage_dir.parent)))
40+
41+
42+
def main(argv: list[str]) -> int:
43+
parser = argparse.ArgumentParser(description="Package a rexos release archive + sha256 file.")
44+
parser.add_argument("--version", required=True, help="Version string, e.g. v0.1.0")
45+
parser.add_argument("--target", required=True, help="Target triple or label, e.g. x86_64-unknown-linux-gnu")
46+
parser.add_argument("--bin", required=True, help="Path to built rexos binary (rexos or rexos.exe)")
47+
parser.add_argument("--out-dir", default="dist", help="Output directory (default: dist)")
48+
parser.add_argument(
49+
"--include",
50+
action="append",
51+
default=[],
52+
help="Optional file to include in the archive (repeatable)",
53+
)
54+
args = parser.parse_args(argv)
55+
56+
repo_root = Path.cwd()
57+
out_dir = (repo_root / args.out_dir).resolve()
58+
out_dir.mkdir(parents=True, exist_ok=True)
59+
60+
bin_path = (repo_root / args.bin).resolve()
61+
if not bin_path.exists():
62+
print(f"error: --bin does not exist: {bin_path}", file=sys.stderr)
63+
return 2
64+
65+
if not bin_path.is_file():
66+
print(f"error: --bin is not a file: {bin_path}", file=sys.stderr)
67+
return 2
68+
69+
base_name = f"rexos-{args.version}-{args.target}"
70+
stage_dir = out_dir / base_name
71+
if stage_dir.exists():
72+
shutil.rmtree(stage_dir)
73+
stage_dir.mkdir(parents=True, exist_ok=True)
74+
75+
# Copy binary (keep its original filename; on Windows it's usually rexos.exe).
76+
dest_bin = stage_dir / bin_path.name
77+
shutil.copy2(bin_path, dest_bin)
78+
if dest_bin.suffix.lower() != ".exe":
79+
os.chmod(dest_bin, 0o755)
80+
81+
# Default include set if present.
82+
default_includes = ["README.md", "LICENSE", "LICENSE.txt"]
83+
includes: list[Path] = []
84+
for rel in default_includes:
85+
p = (repo_root / rel)
86+
if p.exists() and p.is_file():
87+
includes.append(p)
88+
for user_inc in args.include:
89+
p = (repo_root / user_inc)
90+
if p.exists() and p.is_file():
91+
includes.append(p)
92+
93+
seen: set[Path] = set()
94+
for p in includes:
95+
rp = p.resolve()
96+
if rp in seen:
97+
continue
98+
seen.add(rp)
99+
shutil.copy2(rp, stage_dir / p.name)
100+
101+
if should_zip(args.target, bin_path):
102+
archive_path = out_dir / f"{base_name}.zip"
103+
create_zip(archive_path, stage_dir)
104+
else:
105+
archive_path = out_dir / f"{base_name}.tar.gz"
106+
create_tar_gz(archive_path, stage_dir)
107+
108+
digest = sha256_file(archive_path)
109+
sha_path = archive_path.with_suffix(archive_path.suffix + ".sha256")
110+
sha_path.write_text(f"{digest} {archive_path.name}\n", encoding="utf-8")
111+
112+
print(str(archive_path))
113+
print(str(sha_path))
114+
return 0
115+
116+
117+
if __name__ == "__main__":
118+
raise SystemExit(main(sys.argv[1:]))
119+

0 commit comments

Comments
 (0)