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
37 changes: 37 additions & 0 deletions .github/workflows/bump-formula.yml
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 }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a credential that triggers the formula checks

Passing the workflow's GITHUB_TOKEN to gh pr create prevents the resulting pull_request event from starting another workflow run: GitHub documents that events triggered by this token do not create workflow runs, except for workflow_dispatch and repository_dispatch (official documentation). Consequently, automated bump PRs never execute the formula checks in .github/workflows/tests.yml, whose formula step is restricted to github.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 👍 / 👎.

VALIDATION_BASE: ${{ inputs.validation_base || '' }}
run: python3 scripts/bump_formula.py
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
122 changes: 122 additions & 0 deletions scripts/bump_formula.py
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)
118 changes: 118 additions & 0 deletions scripts/test_bump_formula.py
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)}")
Loading