diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39f09e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - run: python3 -m unittest -v test_release.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9db7cd8 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# release-tools + +Shared GitHub Actions for releasing chunkzero projects. Every project releases the same way: + +1. Keep the **upcoming** release version in the project's version file, e.g. `0.1.0` or `0.1.0-beta.1`. Bump it after each + release. +2. Compute the build's version once with `version` and pass it to every build step. +3. Build and verify every artifact, then publish Maven artifacts before the GitHub release that depends on them. +4. Publish the release with `publish`, then register CLI archives with `mise-registry`. + +| Channel | Version | Published as | +|---|---|---| +| Stable | `0.1.0` | GitHub release marked latest | +| Prerelease | `0.1.0-alpha.1`, `0.1.0-beta.1`, `0.1.0-rc.1` | GitHub prerelease | +| Nightly | `0.1.0-nightly.20261004062300.ge282f11816cd` (UTC commit time, 12-character commit) | GitHub prerelease | + +Versions are immutable: a commit always gets the same nightly version, a published release is never modified, and nothing +deletes old releases, so exact pins and lockfiles keep working. + +```yaml +- id: version + uses: chunkzero/release-tools/version@v1 + with: + base: ${{ steps.base.outputs.version }} + channel: nightly + +- uses: chunkzero/release-tools/publish@v1 + with: + version: ${{ steps.version.outputs.version }} + directory: dist + title: Chunk + +- uses: chunkzero/release-tools/mise-registry@v1 + with: + tool: chunk + version: ${{ steps.version.outputs.version }} + directory: dist + token: ${{ steps.registry-token.outputs.token }} +``` + +`version` also reports `published: true` when that version is already public, so scheduled nightlies can skip unchanged +commits. CLI archives must be named `--.tar.gz`, with platforms `linux-x64`, `linux-arm64`, +`darwin-x64`, `darwin-arm64` and `windows-x64`, and contain one top-level directory holding the executable. + +## Requirements + +- Jobs running `publish` need `permissions: contents: write`, and must not publish the same version concurrently; use a + workflow `concurrency` group. Enable immutable releases in the repository settings so GitHub itself rejects changes to + published releases. +- `mise-registry` needs a GitHub App installation token for `chunkzero/mise-registry`. That repository allows squash + merges and auto-merge, and requires its Validate check, so registrations merge only after their archives verify. diff --git a/mise-registry/action.yml b/mise-registry/action.yml new file mode 100644 index 0000000..47deb80 --- /dev/null +++ b/mise-registry/action.yml @@ -0,0 +1,95 @@ +name: Register in mise registry +description: >- + Add a published release to chunkzero/mise-registry through an auto-merging pull request and wait for it to merge, + rebuilding the pull request on the registry's main branch whenever another registration conflicts with it. + +inputs: + tool: + description: Tool name; archives must be named `--.tar.gz`. + required: true + version: + description: Published release version, without the leading v. + required: true + directory: + description: Directory holding the published archives and their `.sha256` files. + required: true + token: + description: GitHub App installation token with contents and pull request write access to chunkzero/mise-registry. + required: true + +runs: + using: composite + steps: + - shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + TOOL: ${{ inputs.tool }} + VERSION: ${{ inputs.version }} + DIRECTORY: ${{ inputs.directory }} + run: | + set -euo pipefail + shopt -s inherit_errexit + entry="$RUNNER_TEMP/$TOOL-$VERSION.json" + python3 "$GITHUB_ACTION_PATH/../release.py" registry-entry --tool "$TOOL" --version "$VERSION" \ + --directory "$DIRECTORY" > "$entry" + branch="$TOOL-$VERSION" + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + gh auth setup-git + + # Rebuilds the registration branch on the registry's current main and prints `pushed`, or prints `registered` when + # main already has this version. Callers assign its output so any failure stops the step. + prepare() ( + rm -rf "$work/registry" + git clone --quiet https://github.com/chunkzero/mise-registry "$work/registry" + cd "$work/registry" + python3 scripts/registry.py add "$TOOL" "$entry" + if git diff --quiet; then + echo registered + exit 0 + fi + git switch --quiet -c "$branch" + git -c user.name="chunkzero-registry[bot]" -c user.email="chunkzero-registry[bot]@users.noreply.github.com" \ + commit --quiet -am "feat: add $TOOL $VERSION" + git push --quiet --force origin "$branch" + echo pushed + ) + + pr=$(gh pr list --repo chunkzero/mise-registry --head "$branch" --state all --json url,state \ + --jq '.[0] // empty | "\(.state) \(.url)"') + case "$pr" in + MERGED*) echo "$TOOL $VERSION is already registered: ${pr#* }"; exit 0 ;; + CLOSED*) echo "::error::the registry PR for $TOOL $VERSION was closed without merging: ${pr#* }"; exit 1 ;; + esac + result=$(prepare) + if [ "$result" = registered ]; then + echo "$TOOL $VERSION is already registered" + exit 0 + fi + url=${pr#* } + if [ -z "$pr" ]; then + url=$(gh pr create --repo chunkzero/mise-registry --head "$branch" --title "feat: add $TOOL $VERSION" \ + --body "Registers https://github.com/$GITHUB_REPOSITORY/releases/tag/v$VERSION.") + fi + + # Another registration of this tool can merge first and conflict with this one; rebuilding resolves that. + for _ in $(seq 120); do + status=$(gh pr view "$url" --json state,mergeable --jq '"\(.state) \(.mergeable)"') + read -r state mergeable <<< "$status" + case "$state" in + MERGED) echo "registered: $url"; exit 0 ;; + CLOSED) echo "::error::$url was closed without merging"; exit 1 ;; + esac + if [ "$mergeable" = CONFLICTING ]; then + result=$(prepare) + if [ "$result" = registered ]; then + echo "$TOOL $VERSION is already registered" + exit 0 + fi + fi + # Fails harmlessly if the pull request merged since it was checked; the next check sees that. + gh pr merge "$url" --auto --squash || true + sleep 10 + done + echo "::error::$url didn't merge within 20 minutes; check its Validate run" + exit 1 diff --git a/publish/action.yml b/publish/action.yml new file mode 100644 index 0000000..f5cfaec --- /dev/null +++ b/publish/action.yml @@ -0,0 +1,26 @@ +name: Publish GitHub release +description: >- + Publish verified assets as an immutable GitHub release: upload to a draft, download and compare every asset, then make + it public. Prereleases are never marked latest. Rerunning for an already published version only verifies it. + +inputs: + version: + description: Release version, without the leading v. + required: true + directory: + description: Directory holding every asset; each file except `.sha256` files needs a matching `.sha256`. + required: true + title: + description: Release title prefix, e.g. the project name. + required: true + +runs: + using: composite + steps: + - shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + DIRECTORY: ${{ inputs.directory }} + TITLE: ${{ inputs.title }} + run: python3 "$GITHUB_ACTION_PATH/../release.py" publish --version "$VERSION" --directory "$DIRECTORY" --title "$TITLE" diff --git a/release.py b/release.py new file mode 100644 index 0000000..85395ce --- /dev/null +++ b/release.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Compute release versions, publish immutable GitHub releases, and describe them for the mise registry.""" + +import argparse +import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + +BASE = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?") +PLATFORMS = ("linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "windows-x64") + + +def run(*args): + return subprocess.check_output(args, text=True) + + +def nightly_version(base, sha, committed): + """`-nightly..g`; the same commit always gets the same version.""" + match = BASE.fullmatch(base) + if not match or not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError(f"invalid base version {base!r} or commit {sha!r}") + timestamp = committed.astimezone(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + return f"{'.'.join(match.groups())}-nightly.{timestamp}.g{sha[:12]}" + + +def release_version(base, ref): + if not BASE.fullmatch(base): + raise ValueError(f"release versions must be X.Y.Z or X.Y.Z-(alpha|beta|rc).N, got {base!r}") + if ref.startswith("refs/tags/") and ref != f"refs/tags/v{base}": + raise ValueError(f"tag {ref} does not match the project version {base}") + return base + + +def verify_checksums(assets): + for path in assets: + if path.suffix != ".sha256": + expected = path.with_name(path.name + ".sha256").read_text().split()[0] + if expected != hashlib.sha256(path.read_bytes()).hexdigest(): + raise ValueError(f"checksum mismatch: {path.name}") + + +def api(path): + """GitHub API response, or None only when GitHub answers 404; any other failure raises.""" + result = subprocess.run(["gh", "api", path], capture_output=True, text=True) + if result.returncode == 0: + return json.loads(result.stdout) + if "HTTP 404" in result.stderr: + return None + raise RuntimeError(f"gh api {path} failed: {result.stderr.strip()}") + + +def find_release(repository, tag): + """The release for `tag`, published or draft, or None.""" + published = api(f"repos/{repository}/releases/tags/{tag}") + if published: + return published + # Drafts are only reachable through the release list. + pages = json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{repository}/releases?per_page=100")) + return next((release for page in pages for release in page if release["tag_name"] == tag), None) + + +def tag_commit(repository, tag): + """Commit the tag points at, following annotated tags, or None when the tag doesn't exist.""" + ref = api(f"repos/{repository}/git/ref/tags/{tag}") + if ref is None: + return None + target = ref["object"] + while target["type"] == "tag": + target = api(f"repos/{repository}/git/tags/{target['sha']}")["object"] + return target["sha"] + + +def verify_uploaded(repository, tag, assets): + with tempfile.TemporaryDirectory() as temporary: + run("gh", "release", "download", tag, "--repo", repository, "--dir", temporary) + downloaded = Path(temporary) + if sorted(path.name for path in downloaded.iterdir()) != [path.name for path in assets]: + raise ValueError("release assets differ from the verified build") + for path in assets: + if path.read_bytes() != (downloaded / path.name).read_bytes(): + raise ValueError(f"uploaded asset differs: {path.name}") + + +def publish(repository, version, sha, directory, title): + """Create or resume a draft, upload and re-download every asset, then publish. Published releases never change. + + Callers must not publish the same version concurrently, e.g. with a workflow `concurrency` group.""" + tag = f"v{version}" + assets = sorted(path for path in directory.iterdir() if path.is_file()) + if not assets: + raise ValueError("release has no assets") + verify_checksums(assets) + existing = find_release(repository, tag) + # A draft has no tag until it's published, so its target is the only record of its commit. + commit = tag_commit(repository, tag) or (existing["target_commitish"] if existing else sha) + if commit != sha: + raise ValueError(f"{tag} already belongs to {commit}, not {sha}") + if existing and not existing["draft"]: + verify_uploaded(repository, tag, assets) + print(f"{tag} is already published with identical assets") + return + if existing: + names = {path.name for path in assets} + for asset in existing["assets"]: + if asset["name"] not in names: + run("gh", "release", "delete-asset", tag, asset["name"], "--repo", repository, "--yes") + else: + run("gh", "release", "create", tag, "--repo", repository, "--draft", "--target", sha, + "--title", f"{title} {version}", "--notes", f"Source commit: {sha}\n") + run("gh", "release", "upload", tag, "--repo", repository, "--clobber", *map(str, assets)) + verify_uploaded(repository, tag, assets) + flags = ["--prerelease", "--latest=false"] if "-" in version else ["--prerelease=false", "--latest"] + run("gh", "release", "edit", tag, "--repo", repository, "--draft=false", *flags) + + +def registry_entry(tool, version, repository, directory): + """Registry version entry for `--.tar.gz` archives and their `.sha256` files.""" + tag = f"v{version}" + assets = {} + for platform in PLATFORMS: + archive = directory / f"{tool}-{version}-{platform}.tar.gz" + if archive.exists(): + verify_checksums([archive]) + assets[platform] = { + "url": f"https://github.com/{repository}/releases/download/{tag}/{archive.name}", + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + } + if not assets: + raise ValueError(f"no {tool}-{version}-.tar.gz archives in {directory}") + return {"version": version, "tag": tag, "assets": assets} + + +def output(**values): + lines = "".join(f"{key}={value}\n" for key, value in values.items()) + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as file: + file.write(lines) + print(lines, end="") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + version = commands.add_parser("version") + version.add_argument("--base", required=True, help="the upcoming release, e.g. 0.1.0 or 0.1.0-beta.1") + version.add_argument("--channel", choices=["nightly", "release"], required=True) + version.add_argument("--sha", default=os.environ.get("GITHUB_SHA")) + version.add_argument("--ref", default=os.environ.get("GITHUB_REF", "")) + version.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + publish_command = commands.add_parser("publish") + publish_command.add_argument("--version", required=True) + publish_command.add_argument("--directory", type=Path, required=True) + publish_command.add_argument("--title", required=True) + publish_command.add_argument("--sha", default=os.environ.get("GITHUB_SHA")) + publish_command.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + entry = commands.add_parser("registry-entry") + entry.add_argument("--tool", required=True) + entry.add_argument("--version", required=True) + entry.add_argument("--directory", type=Path, required=True) + entry.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + args = parser.parse_args() + + if args.command == "version": + if args.channel == "nightly": + committed = datetime.datetime.fromtimestamp(int(run("git", "show", "-s", "--format=%ct", args.sha)), + datetime.timezone.utc) + value = nightly_version(args.base, args.sha, committed) + else: + value = release_version(args.base, args.ref) + published = find_release(args.repository, f"v{value}") if args.repository else None + output(version=value, tag=f"v{value}", published=str(bool(published and not published["draft"])).lower()) + elif args.command == "publish": + publish(args.repository, args.version, args.sha, args.directory, args.title) + else: + print(json.dumps(registry_entry(args.tool, args.version, args.repository, args.directory), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/test_release.py b/test_release.py new file mode 100644 index 0000000..d77e632 --- /dev/null +++ b/test_release.py @@ -0,0 +1,79 @@ +import datetime +import hashlib +from pathlib import Path +import tempfile +import unittest + +import release + +SHA = "e282f11816cd" + "0" * 28 +COMMITTED = datetime.datetime(2026, 10, 4, 6, 23, 0, tzinfo=datetime.timezone.utc) + + +class VersionTest(unittest.TestCase): + def test_nightly_uses_commit_time_and_drops_prerelease_suffix(self): + self.assertEqual(release.nightly_version("0.1.0", SHA, COMMITTED), "0.1.0-nightly.20261004062300.ge282f11816cd") + self.assertEqual(release.nightly_version("0.2.0-beta.1", SHA, COMMITTED), + "0.2.0-nightly.20261004062300.ge282f11816cd") + + def test_nightly_converts_to_utc(self): + local = COMMITTED.astimezone(datetime.timezone(datetime.timedelta(hours=-5))) + self.assertEqual(release.nightly_version("0.1.0", SHA, local), "0.1.0-nightly.20261004062300.ge282f11816cd") + + def test_rejects_unknown_base_versions(self): + for base in ("0.1", "0.1.0-alpha.0.1", "0.1.0-nightly.1", "v0.1.0"): + with self.assertRaises(ValueError): + release.nightly_version(base, SHA, COMMITTED) + with self.assertRaises(ValueError): + release.release_version(base, "") + + def test_release_tag_must_match(self): + self.assertEqual(release.release_version("0.1.0-beta.1", "refs/tags/v0.1.0-beta.1"), "0.1.0-beta.1") + self.assertEqual(release.release_version("0.1.0", "refs/heads/main"), "0.1.0") + with self.assertRaises(ValueError): + release.release_version("0.1.0", "refs/tags/v0.1.1") + + +class ApiTest(unittest.TestCase): + def test_only_404_means_absent(self): + from unittest import mock + import subprocess + missing = subprocess.CompletedProcess([], 1, "", "gh: Not Found (HTTP 404)") + failing = subprocess.CompletedProcess([], 1, "", "gh: Server Error (HTTP 500)") + with mock.patch.object(release.subprocess, "run", return_value=missing): + self.assertIsNone(release.tag_commit("chunkzero/chunk", "v0.1.0")) + with mock.patch.object(release.subprocess, "run", return_value=failing): + with self.assertRaises(RuntimeError): + release.tag_commit("chunkzero/chunk", "v0.1.0") + + +class RegistryEntryTest(unittest.TestCase): + def test_describes_archives_with_verified_checksums(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + archive = directory / "chunk-0.1.0-linux-x64.tar.gz" + archive.write_bytes(b"archive") + digest = hashlib.sha256(b"archive").hexdigest() + archive.with_name(archive.name + ".sha256").write_text(f"{digest} {archive.name}\n") + entry = release.registry_entry("chunk", "0.1.0", "chunkzero/chunk", directory) + self.assertEqual(entry, { + "version": "0.1.0", + "tag": "v0.1.0", + "assets": {"linux-x64": { + "url": "https://github.com/chunkzero/chunk/releases/download/v0.1.0/chunk-0.1.0-linux-x64.tar.gz", + "sha256": digest, + }}, + }) + + def test_rejects_mismatched_checksum(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + archive = directory / "chunk-0.1.0-linux-x64.tar.gz" + archive.write_bytes(b"archive") + archive.with_name(archive.name + ".sha256").write_text("0" * 64 + "\n") + with self.assertRaises(ValueError): + release.registry_entry("chunk", "0.1.0", "chunkzero/chunk", directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/version/action.yml b/version/action.yml new file mode 100644 index 0000000..a31ed18 --- /dev/null +++ b/version/action.yml @@ -0,0 +1,32 @@ +name: Release version +description: Compute the version for this build from the project's upcoming release. + +inputs: + base: + description: The upcoming release version from the project's version file, e.g. 0.1.0 or 0.1.0-beta.1. + required: true + channel: + description: "`nightly` builds `-nightly..g`; `release` uses the base as is." + required: true + +outputs: + version: + description: Version for every artifact of this build. + value: ${{ steps.version.outputs.version }} + tag: + description: Git tag, `v`. + value: ${{ steps.version.outputs.tag }} + published: + description: "`true` when a public GitHub release already exists for this version." + value: ${{ steps.version.outputs.published }} + +runs: + using: composite + steps: + - id: version + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BASE: ${{ inputs.base }} + CHANNEL: ${{ inputs.channel }} + run: python3 "$GITHUB_ACTION_PATH/../release.py" version --base "$BASE" --channel "$CHANNEL"