Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<tool>-<version>-<platform>.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.
95 changes: 95 additions & 0 deletions mise-registry/action.yml
Original file line number Diff line number Diff line change
@@ -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 `<tool>-<version>-<platform>.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
26 changes: 26 additions & 0 deletions publish/action.yml
Original file line number Diff line number Diff line change
@@ -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 `<name>.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"
184 changes: 184 additions & 0 deletions release.py
Original file line number Diff line number Diff line change
@@ -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):
"""`<major.minor.patch>-nightly.<UTC commit time>.g<sha12>`; 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 `<tool>-<version>-<platform>.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}-<platform>.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()
Loading