Repository navigation
Automate EigenScript formula bump pull requests #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)}") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing the workflow's
GITHUB_TOKENtogh pr createprevents the resultingpull_requestevent from starting another workflow run: GitHub documents that events triggered by this token do not create workflow runs, except forworkflow_dispatchandrepository_dispatch(official documentation). Consequently, automated bump PRs never execute the formula checks in.github/workflows/tests.yml, whose formula step is restricted togithub.event_name == 'pull_request'; there will be no pending run to approve as the README suggests. Use a GitHub App/PAT credential that can trigger workflows, or explicitly dispatch a validation workflow that actually runs the formula checks.Useful? React with 👍 / 👎.