From a1714f44322ae014518ba0301ac6056ccc5d3474 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:31:51 -0400 Subject: [PATCH 1/7] feat: add shared version, publish and mise registry actions --- .github/workflows/ci.yml | 17 +++ README.md | 43 +++++++ __pycache__/release.cpython-314.pyc | Bin 0 -> 12362 bytes __pycache__/test_release.cpython-314.pyc | Bin 0 -> 6217 bytes mise-registry/action.yml | 56 +++++++++ publish/action.yml | 26 ++++ release.py | 149 +++++++++++++++++++++++ test_release.py | 66 ++++++++++ version/action.yml | 32 +++++ 9 files changed, 389 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 __pycache__/release.cpython-314.pyc create mode 100644 __pycache__/test_release.cpython-314.pyc create mode 100644 mise-registry/action.yml create mode 100644 publish/action.yml create mode 100644 release.py create mode 100644 test_release.py create mode 100644 version/action.yml 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/README.md b/README.md new file mode 100644 index 0000000..5f8c584 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# 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. diff --git a/__pycache__/release.cpython-314.pyc b/__pycache__/release.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..631ffb35498bc9cecf8944435f0dc01c6c86dafe GIT binary patch literal 12362 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 literal 0 HcmV?d00001 diff --git a/mise-registry/action.yml b/mise-registry/action.yml new file mode 100644 index 0000000..d568514 --- /dev/null +++ b/mise-registry/action.yml @@ -0,0 +1,56 @@ +name: Register in mise registry +description: Open an auto-merging pull request that adds a published release to chunkzero/mise-registry. + +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 + python3 "$GITHUB_ACTION_PATH/../release.py" registry-entry --tool "$TOOL" --version "$VERSION" \ + --directory "$DIRECTORY" > "$RUNNER_TEMP/entry.json" + branch="$TOOL-$VERSION" + existing=$(gh pr list --repo chunkzero/mise-registry --head "$branch" --state all --json url --jq '.[0].url // empty') + if [ -n "$existing" ]; then + echo "$existing" + exit 0 + fi + gh auth setup-git + git clone --quiet https://github.com/chunkzero/mise-registry "$RUNNER_TEMP/mise-registry" + cd "$RUNNER_TEMP/mise-registry" + if ! python3 scripts/registry.py add "$TOOL" "$RUNNER_TEMP/entry.json"; then + echo "::error::the registry rejected $TOOL $VERSION" + exit 1 + fi + if git diff --quiet; then + echo "$TOOL $VERSION is already registered" + exit 0 + fi + git switch --quiet -c "$branch" + git config user.name "chunkzero-registry[bot]" + git config user.email "chunkzero-registry[bot]@users.noreply.github.com" + git commit --quiet -am "feat: add $TOOL $VERSION" + git push --quiet origin "$branch" + 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.") + gh pr merge "$url" --auto --squash + echo "$url" 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..14cae2a --- /dev/null +++ b/release.py @@ -0,0 +1,149 @@ +#!/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 find_release(repository, tag): + pages = json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{repository}/releases")) + return next((release for page in pages for release in page if release["tag_name"] == tag), None) + + +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.""" + 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) + if existing and existing["target_commitish"] != sha: + raise ValueError(f"{tag} already belongs to {existing['target_commitish']}") + if existing and not existing["draft"]: + verify_uploaded(repository, tag, assets) + print(f"{tag} is already published with identical assets") + return + if not existing: + 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..3533fb3 --- /dev/null +++ b/test_release.py @@ -0,0 +1,66 @@ +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 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" From a2eb91656130fbb556e2bb8e39dd95f3eb904230 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:37:12 -0400 Subject: [PATCH 2/7] fix: verify tag commits, clean stale draft assets and resume registry PRs --- .gitignore | 1 + README.md | 8 +++++ __pycache__/release.cpython-314.pyc | Bin 12362 -> 0 bytes __pycache__/test_release.cpython-314.pyc | Bin 6217 -> 0 bytes mise-registry/action.yml | 44 ++++++++++++----------- release.py | 36 +++++++++++++++---- 6 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 .gitignore delete mode 100644 __pycache__/release.cpython-314.pyc delete mode 100644 __pycache__/test_release.cpython-314.pyc 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 index 5f8c584..9db7cd8 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,11 @@ deletes old releases, so exact pins and lockfiles keep working. `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/__pycache__/release.cpython-314.pyc b/__pycache__/release.cpython-314.pyc deleted file mode 100644 index 631ffb35498bc9cecf8944435f0dc01c6c86dafe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12362 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 diff --git a/mise-registry/action.yml b/mise-registry/action.yml index d568514..b06ab48 100644 --- a/mise-registry/action.yml +++ b/mise-registry/action.yml @@ -27,29 +27,33 @@ runs: run: | set -euo pipefail python3 "$GITHUB_ACTION_PATH/../release.py" registry-entry --tool "$TOOL" --version "$VERSION" \ - --directory "$DIRECTORY" > "$RUNNER_TEMP/entry.json" + --directory "$DIRECTORY" > "$RUNNER_TEMP/$TOOL-$VERSION.json" branch="$TOOL-$VERSION" - existing=$(gh pr list --repo chunkzero/mise-registry --head "$branch" --state all --json url --jq '.[0].url // empty') - if [ -n "$existing" ]; then - echo "$existing" - exit 0 - fi + 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 ;; + OPEN*) gh pr merge "${pr#* }" --auto --squash; echo "${pr#* }"; exit 0 ;; + CLOSED*) echo "::error::the registry PR for $TOOL $VERSION was closed without merging: ${pr#* }"; exit 1 ;; + esac + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT gh auth setup-git - git clone --quiet https://github.com/chunkzero/mise-registry "$RUNNER_TEMP/mise-registry" - cd "$RUNNER_TEMP/mise-registry" - if ! python3 scripts/registry.py add "$TOOL" "$RUNNER_TEMP/entry.json"; then - echo "::error::the registry rejected $TOOL $VERSION" - exit 1 - fi - if git diff --quiet; then - echo "$TOOL $VERSION is already registered" - exit 0 + git clone --quiet https://github.com/chunkzero/mise-registry "$work" + cd "$work" + # A previous run may have pushed the branch and failed before opening its pull request. + if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null; then + python3 scripts/registry.py add "$TOOL" "$RUNNER_TEMP/$TOOL-$VERSION.json" + if git diff --quiet; then + echo "$TOOL $VERSION is already registered" + exit 0 + fi + git switch --quiet -c "$branch" + git config user.name "chunkzero-registry[bot]" + git config user.email "chunkzero-registry[bot]@users.noreply.github.com" + git commit --quiet -am "feat: add $TOOL $VERSION" + git push --quiet origin "$branch" fi - git switch --quiet -c "$branch" - git config user.name "chunkzero-registry[bot]" - git config user.email "chunkzero-registry[bot]@users.noreply.github.com" - git commit --quiet -am "feat: add $TOOL $VERSION" - git push --quiet origin "$branch" 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.") gh pr merge "$url" --auto --squash diff --git a/release.py b/release.py index 14cae2a..b5f4bf1 100644 --- a/release.py +++ b/release.py @@ -45,8 +45,23 @@ def verify_checksums(assets): def find_release(repository, tag): - pages = json.loads(run("gh", "api", "--paginate", "--slurp", f"repos/{repository}/releases")) - return next((release for page in pages for release in page if release["tag_name"] == tag), None) + """The published release for `tag`, else a draft for it among the newest 100 releases, else None.""" + published = subprocess.run(["gh", "api", f"repos/{repository}/releases/tags/{tag}"], capture_output=True, text=True) + if published.returncode == 0: + return json.loads(published.stdout) + recent = json.loads(run("gh", "api", f"repos/{repository}/releases?per_page=100")) + return next((release for release in recent 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 = subprocess.run(["gh", "api", f"repos/{repository}/git/ref/tags/{tag}"], capture_output=True, text=True) + if ref.returncode != 0: + return None + target = json.loads(ref.stdout)["object"] + while target["type"] == "tag": + target = json.loads(run("gh", "api", f"repos/{repository}/git/tags/{target['sha']}"))["object"] + return target["sha"] def verify_uploaded(repository, tag, assets): @@ -61,20 +76,29 @@ def verify_uploaded(repository, tag, assets): def publish(repository, version, sha, directory, title): - """Create or resume a draft, upload and re-download every asset, then publish. Published releases never change.""" + """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) - if existing and existing["target_commitish"] != sha: - raise ValueError(f"{tag} already belongs to {existing['target_commitish']}") + # 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 not existing: + 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)) From 167674db157fd772c54ead4dde0ae1a47ab274c5 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:40:17 -0400 Subject: [PATCH 3/7] fix: wait for registry pull requests to merge --- mise-registry/action.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/mise-registry/action.yml b/mise-registry/action.yml index b06ab48..73d5591 100644 --- a/mise-registry/action.yml +++ b/mise-registry/action.yml @@ -1,5 +1,5 @@ name: Register in mise registry -description: Open an auto-merging pull request that adds a published release to chunkzero/mise-registry. +description: Add a published release to chunkzero/mise-registry through an auto-merging pull request, and wait for it to merge. inputs: tool: @@ -29,11 +29,24 @@ runs: python3 "$GITHUB_ACTION_PATH/../release.py" registry-entry --tool "$TOOL" --version "$VERSION" \ --directory "$DIRECTORY" > "$RUNNER_TEMP/$TOOL-$VERSION.json" branch="$TOOL-$VERSION" + # Waiting for the merge keeps a tool's registrations in order, since each one rewrites the tool's file. + merge() { + gh pr merge "$1" --auto --squash + for _ in $(seq 120); do + case "$(gh pr view "$1" --json state --jq .state)" in + MERGED) echo "registered: $1"; return 0 ;; + CLOSED) echo "::error::$1 was closed without merging"; return 1 ;; + esac + sleep 10 + done + echo "::error::$1 didn't merge within 20 minutes; check its Validate run" + return 1 + } 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 ;; - OPEN*) gh pr merge "${pr#* }" --auto --squash; echo "${pr#* }"; exit 0 ;; + OPEN*) merge "${pr#* }"; exit ;; CLOSED*) echo "::error::the registry PR for $TOOL $VERSION was closed without merging: ${pr#* }"; exit 1 ;; esac work=$(mktemp -d) @@ -56,5 +69,4 @@ runs: fi 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.") - gh pr merge "$url" --auto --squash - echo "$url" + merge "$url" From 850b0b7cd563b3eca87ff493f41aa0179871a4a3 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:44:12 -0400 Subject: [PATCH 4/7] fix: finish a tool's earlier registry pull requests before opening another --- mise-registry/action.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mise-registry/action.yml b/mise-registry/action.yml index 73d5591..0d76383 100644 --- a/mise-registry/action.yml +++ b/mise-registry/action.yml @@ -42,6 +42,11 @@ runs: echo "::error::$1 didn't merge within 20 minutes; check its Validate run" return 1 } + # Finish any earlier registration of this tool first, e.g. one a cancelled run left open. + for earlier in $(gh pr list --repo chunkzero/mise-registry --state open --json headRefName,url \ + --jq ".[] | select(.headRefName != \"$branch\" and (.headRefName | test(\"^$TOOL-[0-9]\"))) | .url"); do + merge "$earlier" + done pr=$(gh pr list --repo chunkzero/mise-registry --head "$branch" --state all --json url,state \ --jq '.[0] // empty | "\(.state) \(.url)"') case "$pr" in From f8212ea158316a8da25bdf0c0e3a54fb89b2d34a Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:45:19 -0400 Subject: [PATCH 5/7] fix: stop on failed GitHub lookups and find drafts beyond the newest 100 --- release.py | 31 +++++++++++++++++++++---------- test_release.py | 13 +++++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/release.py b/release.py index b5f4bf1..85395ce 100644 --- a/release.py +++ b/release.py @@ -44,23 +44,34 @@ def verify_checksums(assets): 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 published release for `tag`, else a draft for it among the newest 100 releases, else None.""" - published = subprocess.run(["gh", "api", f"repos/{repository}/releases/tags/{tag}"], capture_output=True, text=True) - if published.returncode == 0: - return json.loads(published.stdout) - recent = json.loads(run("gh", "api", f"repos/{repository}/releases?per_page=100")) - return next((release for release in recent if release["tag_name"] == tag), None) + """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 = subprocess.run(["gh", "api", f"repos/{repository}/git/ref/tags/{tag}"], capture_output=True, text=True) - if ref.returncode != 0: + ref = api(f"repos/{repository}/git/ref/tags/{tag}") + if ref is None: return None - target = json.loads(ref.stdout)["object"] + target = ref["object"] while target["type"] == "tag": - target = json.loads(run("gh", "api", f"repos/{repository}/git/tags/{target['sha']}"))["object"] + target = api(f"repos/{repository}/git/tags/{target['sha']}")["object"] return target["sha"] diff --git a/test_release.py b/test_release.py index 3533fb3..d77e632 100644 --- a/test_release.py +++ b/test_release.py @@ -34,6 +34,19 @@ def test_release_tag_must_match(self): 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: From 7bb008d08c1be68fae4b53cb75eee4af028eb91e Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:47:46 -0400 Subject: [PATCH 6/7] fix: fail on registry PR lookup errors and list every open PR --- mise-registry/action.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mise-registry/action.yml b/mise-registry/action.yml index 0d76383..5abb936 100644 --- a/mise-registry/action.yml +++ b/mise-registry/action.yml @@ -43,9 +43,10 @@ runs: return 1 } # Finish any earlier registration of this tool first, e.g. one a cancelled run left open. - for earlier in $(gh pr list --repo chunkzero/mise-registry --state open --json headRefName,url \ - --jq ".[] | select(.headRefName != \"$branch\" and (.headRefName | test(\"^$TOOL-[0-9]\"))) | .url"); do - merge "$earlier" + earlier=$(gh pr list --repo chunkzero/mise-registry --state open --limit 1000 --json headRefName,url \ + --jq ".[] | select(.headRefName != \"$branch\" and (.headRefName | test(\"^$TOOL-[0-9]\"))) | .url") + for url in $earlier; do + merge "$url" done pr=$(gh pr list --repo chunkzero/mise-registry --head "$branch" --state all --json url,state \ --jq '.[0] // empty | "\(.state) \(.url)"') From c978360cdc3a22baa26154adf9deebb19a840295 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:49:11 -0400 Subject: [PATCH 7/7] fix: rebuild registry pull requests on main instead of waiting for earlier ones --- mise-registry/action.yml | 99 +++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/mise-registry/action.yml b/mise-registry/action.yml index 5abb936..47deb80 100644 --- a/mise-registry/action.yml +++ b/mise-registry/action.yml @@ -1,5 +1,7 @@ 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. +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: @@ -26,53 +28,68 @@ runs: 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" > "$RUNNER_TEMP/$TOOL-$VERSION.json" + --directory "$DIRECTORY" > "$entry" branch="$TOOL-$VERSION" - # Waiting for the merge keeps a tool's registrations in order, since each one rewrites the tool's file. - merge() { - gh pr merge "$1" --auto --squash - for _ in $(seq 120); do - case "$(gh pr view "$1" --json state --jq .state)" in - MERGED) echo "registered: $1"; return 0 ;; - CLOSED) echo "::error::$1 was closed without merging"; return 1 ;; - esac - sleep 10 - done - echo "::error::$1 didn't merge within 20 minutes; check its Validate run" - return 1 - } - # Finish any earlier registration of this tool first, e.g. one a cancelled run left open. - earlier=$(gh pr list --repo chunkzero/mise-registry --state open --limit 1000 --json headRefName,url \ - --jq ".[] | select(.headRefName != \"$branch\" and (.headRefName | test(\"^$TOOL-[0-9]\"))) | .url") - for url in $earlier; do - merge "$url" - done - 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 ;; - OPEN*) merge "${pr#* }"; exit ;; - CLOSED*) echo "::error::the registry PR for $TOOL $VERSION was closed without merging: ${pr#* }"; exit 1 ;; - esac work=$(mktemp -d) trap 'rm -rf "$work"' EXIT gh auth setup-git - git clone --quiet https://github.com/chunkzero/mise-registry "$work" - cd "$work" - # A previous run may have pushed the branch and failed before opening its pull request. - if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null; then - python3 scripts/registry.py add "$TOOL" "$RUNNER_TEMP/$TOOL-$VERSION.json" + + # 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 "$TOOL $VERSION is already registered" + echo registered exit 0 fi git switch --quiet -c "$branch" - git config user.name "chunkzero-registry[bot]" - git config user.email "chunkzero-registry[bot]@users.noreply.github.com" - git commit --quiet -am "feat: add $TOOL $VERSION" - git push --quiet origin "$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=$(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.") - merge "$url" + 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