diff --git a/.github/workflows/bump-formula.yml b/.github/workflows/bump-formula.yml new file mode 100644 index 0000000..f182717 --- /dev/null +++ b/.github/workflows/bump-formula.yml @@ -0,0 +1,37 @@ +name: Bump EigenScript formula + +on: + schedule: + - cron: '23 6 * * *' + workflow_dispatch: + inputs: + validation_base: + description: 'Optional reserved automation/validation-1297-* base; empty uses main' + required: false + default: '' + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: bump-eigenscript-formula + cancel-in-progress: false + +jobs: + bump: + if: github.repository == 'InauguralSystems/homebrew-eigenscript' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + ref: main + - name: Check automation + run: python3 -B scripts/test_bump_formula.py + - name: Open a formula bump PR when behind + env: + GH_TOKEN: ${{ github.token }} + VALIDATION_BASE: ${{ inputs.validation_base || '' }} + run: python3 scripts/bump_formula.py diff --git a/README.md b/README.md index 80d6a14..73f0592 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,31 @@ The formula runs the default `make install` build, so the optional HTTP/model/DB ## Versioning -The tap tracks tagged releases of the parent repo; bumping the formula to a new tag is a manual release step. Use `brew install --HEAD eigenscript` to build from `main` instead. +The tap checks the parent repo's latest stable GitHub release daily and on manual +dispatch of **Bump EigenScript formula** from `main`. When behind, it downloads +the release's tag archive, computes its SHA-256, and opens a formula bump PR in +this tap. An up-to-date formula or an existing open bump PR produces no changes. +API, download, git, and PR errors fail the workflow. No extra secret is needed: +the workflow uses this repository's `GITHUB_TOKEN`. + +Approve pending brew test-bot runs if GitHub requests approval on the generated +PR, then merge after the formula checks pass. GitHub documents this +[approval requirement for token-created PRs](https://docs.github.com/en/actions/concepts/security/github_token#when-github_token-triggers-workflow-runs). +The test-bot's manual dispatch runs syntax/setup checks; its formula checks run +on `pull_request`. + +To validate the entire bump path without changing `main`, create a temporary +`automation/validation-1297-*` branch from current `main` and restore a historical +formula using its real release URL and checksum from git history. Dispatch +**Bump EigenScript formula** from `main` with that branch as `validation_base`. +It opens a PR targeting only the validation branch. Approve any pending test-bot +runs, confirm all three platforms' formula checks pass, repeat the dispatch to +confirm it reuses the open PR, then close the PR and delete both temporary +branches. Do not merge the validation PR. Scheduled runs always target `main`; +other manual base names are refused. + +Run the small mocked automation checks with `python3 -B scripts/test_bump_formula.py`. +Use `brew install --HEAD eigenscript` to build from `main` instead. ## License diff --git a/scripts/bump_formula.py b/scripts/bump_formula.py new file mode 100644 index 0000000..ae05128 --- /dev/null +++ b/scripts/bump_formula.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Open a same-tap PR for the latest stable EigenScript release.""" +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +TAP = "InauguralSystems/homebrew-eigenscript" +UPSTREAM = "InauguralSystems/EigenScript" +FORMULA = "Formula/eigenscript.rb" +ARCHIVE = f"https://github.com/{UPSTREAM}/archive/refs/tags/" +VERSION = r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def run(*args): + return subprocess.run(args, check=True, text=True, stdout=subprocess.PIPE, + timeout=180).stdout.rstrip("\n") + + +def version(tag): + require(isinstance(tag, str), "release tag must be a string") + match = re.fullmatch("v" + VERSION, tag) + require(match, f"unsupported stable release tag: {tag!r}") + return tuple(map(int, match.groups())) + + +def main(): + require(os.environ.get("GITHUB_REPOSITORY") == TAP, "wrong repository") + event = os.environ.get("GITHUB_EVENT_NAME") + require(event in ("schedule", "workflow_dispatch"), "unsupported event") + require(os.environ.get("GITHUB_REF") == "refs/heads/main", "run from main") + validation = os.environ.get("VALIDATION_BASE", "") + require(not validation or (event == "workflow_dispatch" and re.fullmatch( + r"automation/validation-1297-[A-Za-z0-9][A-Za-z0-9-]{0,63}", validation)), + "validation_base must be a reserved validation branch on manual dispatch") + base = validation or "main" + require(not run("git", "status", "--porcelain"), "checkout must be clean") + release = json.loads(run("gh", "api", f"repos/{UPSTREAM}/releases/latest")) + require(isinstance(release, dict) and release.get("draft") is False and + release.get("prerelease") is False, "expected a published stable release") + tag = release.get("tag_name") + latest = version(tag) + run("git", "fetch", "--no-tags", "origin", + f"+refs/heads/{base}:refs/remotes/origin/{base}") + formula = run("git", "show", f"refs/remotes/origin/{base}:{FORMULA}") + "\n" + urls = list(re.finditer(r'^ url "' + re.escape(ARCHIVE) + + r'(v' + VERSION + r')\.tar\.gz"$', formula, re.M)) + sums = list(re.finditer(r'^ sha256 "([0-9a-f]{64})"$', formula, re.M)) + require(len(urls) == len(sums) == 1, "expected one release URL and checksum") + current = version(urls[0].group(1)) + print(f"base={base} formula={urls[0].group(1)} latest={tag}", flush=True) + if current >= latest: + print("Formula is up to date; no changes.") + return + branch = f"automation/bump-eigenscript-{tag[1:]}" + if validation: + branch += "-" + validation.replace("/", "-") + prs = json.loads(run("gh", "pr", "list", "--repo", TAP, "--state", "open", + "--base", base, "--head", branch, "--limit", "2", "--json", + "number,url,isCrossRepository,baseRefName,headRefName")) + require(isinstance(prs, list) and len(prs) <= 1, "ambiguous open bump PRs") + if prs: + pr = prs[0] + require(pr.get("isCrossRepository") is False and pr.get("baseRefName") == base + and pr.get("headRefName") == branch, "unexpected existing PR") + print(f"Bump PR already exists: {pr['url']}") + return + url = ARCHIVE + tag + ".tar.gz" + with tempfile.TemporaryDirectory(prefix="eigenscript-release-") as temporary: + archive = Path(temporary) / "release.tar.gz" + run("curl", "--fail", "--silent", "--show-error", "--location", "--retry", "2", "--connect-timeout", + "15", "--max-time", "45", "--proto", "=https", "--proto-redir", + "=https", "--output", str(archive), url) + require(archive.stat().st_size > 0, "empty release archive") + with archive.open("rb") as source: + checksum = hashlib.file_digest(source, "sha256").hexdigest() + updated = formula.replace(urls[0].group(0), f' url "{url}"', 1) + updated = updated.replace(sums[0].group(0), f' sha256 "{checksum}"', 1) + remote = run("git", "ls-remote", "--heads", "origin", f"refs/heads/{branch}") + if remote: + run("git", "fetch", "--no-tags", "origin", f"refs/heads/{branch}") + require(run("git", "show", f"FETCH_HEAD:{FORMULA}") + "\n" == updated and + run("git", "diff", "--name-only", f"origin/{base}", "FETCH_HEAD") + == FORMULA, "existing branch does not contain exactly this formula bump") + else: + run("git", "switch", "--create", branch, f"origin/{base}") + Path(FORMULA).write_text(updated) + run("git", "config", "user.name", "github-actions[bot]") + run("git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + run("git", "add", "--", FORMULA) + run("git", "commit", "--message", f"formula: bump eigenscript to {tag[1:]}") + run("git", "push", "origin", f"HEAD:refs/heads/{branch}") + body = (f"Update EigenScript to [{tag}](https://github.com/{UPSTREAM}/releases/tag/{tag}).\n\n" + f"SHA-256 `{checksum}` was computed from the downloaded [tag archive]({url}).\n\n" + "Maintainer: approve pending brew test-bot workflow runs if GitHub requests it; " + "merge after the formula checks pass.") + if validation: + body += "\n\nValidation only: close this PR after checks; do not merge into main." + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", prefix="bump-pr-", + suffix=".md") as body_file: + body_file.write(body) + body_file.flush() + print(run("gh", "pr", "create", "--repo", TAP, "--base", base, "--head", branch, + "--title", f"formula: bump eigenscript to {tag[1:]}", + "--body-file", body_file.name)) + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, OSError, subprocess.SubprocessError) as error: + print(f"Formula bump failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/test_bump_formula.py b/scripts/test_bump_formula.py new file mode 100644 index 0000000..22a73a0 --- /dev/null +++ b/scripts/test_bump_formula.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Finite command-surface controls; no API, downloads, builds, or remote writes.""" +import contextlib +import hashlib +import io +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +from unittest.mock import patch +import bump_formula as bump + +# Keep these mocked releases stable when an actual formula bump changes the tree. +ORIGINAL = re.sub(r'/v[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz', '/v0.43.0.tar.gz', + Path(bump.FORMULA).read_text()) +CASES = [ + ("up-to-date", {}), + ("no downgrade", {"tag": "v0.42.0"}), + ("numeric behind", {"tag": "v0.100.0", "writes": True}), + ("new stable release", {"tag": "v0.44.0", "writes": True}), + ("existing PR", {"tag": "v0.44.0", "pr": True}), + ("malformed formula", {"bad_formula": True, "error": True}), + ("cross-repo PR refused", {"tag": "v0.44.0", "pr": True, "cross": True, "error": True}), + ("retry pushed branch", {"tag": "v0.44.0", "remote": True, "writes": True}), + ("unexpected pushed branch", {"tag": "v0.44.0", "remote": True, "bad_branch": True, "error": True}), + ("validation bump", {"old": True, "base": "automation/validation-1297-test", "writes": True}), + ("invalid base", {"base": "other", "error": True}), + ("schedule cannot validate", {"event": "schedule", "base": "automation/validation-1297-test", "error": True}), + ("non-main dispatch", {"ref": "refs/heads/other", "error": True}), + ("prerelease", {"prerelease": True, "error": True}), + ("malformed tag", {"tag": "v0.44.0;bad", "error": True}), + ("API error", {"failure": ("gh", "api"), "error": True}), + ("dirty checkout", {"dirty": True, "error": True}), + ("download error", {"tag": "v0.44.0", "failure": ("curl",), "error": True}), + ("git push error", {"tag": "v0.44.0", "failure": ("git", "push"), "error": True}), + ("PR error", {"tag": "v0.44.0", "failure": ("gh", "pr", "create"), "error": True}), +] + +for name, case in CASES: + calls, bodies, output = [], [], io.StringIO() + base = case.get("base", "") or "main" + tag = case.get("tag", "v0.43.0") + formula = ORIGINAL.replace("v0.43.0", "v0.41.0") if case.get("old") else ORIGINAL + if case.get("bad_formula"): + formula = formula.replace("sha256", "missing_checksum") + branch = f"automation/bump-eigenscript-{tag[1:]}" + if base != "main": + branch += "-" + base.replace("/", "-") + + def command(args, **kwargs): + calls.append(args) + if args[:3] == ("gh", "pr", "create"): + assert "--body" not in args and "--body-file" in args, name + bodies.append(Path(args[args.index("--body-file") + 1]).read_text()) + failure = case.get("failure") + if failure and args[:len(failure)] == failure: + raise subprocess.CalledProcessError(1, args) + text = "" + if args[:2] == ("gh", "api"): + text = json.dumps({"tag_name": tag, "draft": False, "prerelease": case.get("prerelease", False)}) + elif args[:2] == ("git", "show"): + text = formula + if args[2].startswith("FETCH_HEAD:") and not case.get("bad_branch"): + text = formula.replace("v0.43.0", tag) + text = re.sub(r'sha256 "[0-9a-f]{64}"', f'sha256 "{hashlib.sha256(b"mock release archive\n").hexdigest()}"', text) + elif args[:2] == ("git", "diff"): + text = bump.FORMULA + elif args[:2] == ("git", "ls-remote") and case.get("remote"): + text = "mock remote ref" + elif args[:2] == ("git", "status") and case.get("dirty"): + text = " M README.md" + elif args[:3] == ("gh", "pr", "list"): + text = json.dumps([{"url": "https://github.com/" + bump.TAP + "/pull/99", "isCrossRepository": case.get("cross", False), + "baseRefName": base, "headRefName": branch}] if case.get("pr") else []) + elif args[0] == "curl": + assert args[-1] == bump.ARCHIVE + tag + ".tar.gz", name + Path(args[args.index("--output") + 1]).write_bytes(b"mock release archive\n") + return subprocess.CompletedProcess(args, 0, stdout=text) + + with tempfile.TemporaryDirectory(prefix="bump-check-") as temporary: + previous = Path.cwd() + os.chdir(temporary) + try: + Path("Formula").mkdir() + Path(bump.FORMULA).write_text(formula) + env = {"GITHUB_REPOSITORY": bump.TAP, "GITHUB_EVENT_NAME": case.get("event", "workflow_dispatch"), + "GITHUB_REF": case.get("ref", "refs/heads/main"), "VALIDATION_BASE": case.get("base", "")} + failed = False + with patch.dict(os.environ, env, clear=True), patch.object(subprocess, "run", command), contextlib.redirect_stdout(output): + try: + bump.main() + except (ValueError, subprocess.SubprocessError): + failed = True + assert failed == case.get("error", False), (name, output.getvalue()) + creates = [c for c in calls if c[:3] == ("gh", "pr", "create")] + for create in creates: + assert not Path(create[create.index("--body-file") + 1]).exists(), name + if not failed: + assert len(creates) == int(case.get("writes", False)), name + if creates: + assert creates[0][creates[0].index("--base") + 1] == base, name + expected = hashlib.sha256(b"mock release archive\n").hexdigest() + assert len(bodies) == 1 and expected in bodies[0], name + assert bodies[0].startswith(f"Update EigenScript to [{tag}]("), name + assert f"[tag archive]({bump.ARCHIVE}{tag}.tar.gz).\n\n" in bodies[0], name + assert "\n\nSHA-256 " in bodies[0] and "\n\nMaintainer: approve pending" in bodies[0], name + assert ("\n\nValidation only:" in bodies[0]) == bool(case.get("base")), name + if not case.get("remote"): + assert f'sha256 "{expected}"' in Path(bump.FORMULA).read_text(), name + else: + assert not any(c[0] == "curl" or c[:2] == ("git", "push") for c in calls), name + assert Path(bump.FORMULA).read_text() == formula, name + finally: + os.chdir(previous) + print(f"PASS {name}") +print(f"BUMP_CHECKS_DONE passed={len(CASES)} examined={len(CASES)}")