"""Admit and dispatch tagless builds without changing release channels.""" from __future__ import annotations import json import os import re import shlex import subprocess import tomllib from datetime import datetime, timezone from pathlib import Path WORKFLOW = "desktop-bundled-release.yml" def require_commit(value: str) -> str: if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{40}", value): raise ValueError("Commit builds require an exact full 40-character SHA") return value def output(argv: list[str], repo: Path | None = None) -> str: return subprocess.check_output(argv, cwd=repo, text=True, encoding="utf-8", timeout=60).strip() def require_pushed(commit: str, remote: str, repo: Path | None = None, *, run=output) -> None: """Require ancestry from a branch or tag currently advertised by this remote.""" require_commit(commit) advertised = {line.split()[0] for line in run( ["git", "ls-remote", remote, "refs/heads/*", "refs/tags/*"], repo).splitlines()} containing = set(run(["git", "for-each-ref", f"--contains={commit}", "--format=%(objectname)", f"refs/remotes/{remote}/", "refs/tags/"], repo).splitlines()) if not advertised.intersection(containing): raise ValueError(f"Commit {commit} is not reachable from a pushed branch or tag on {remote}") def version_at(repo: Path | None, commit: str, *, run=output) -> str: require_commit(commit) document = tomllib.loads(run(["git", "show", f"{commit}:pyproject.toml"], repo)) version = document["project"]["version"] if not isinstance(version, str) or not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): raise ValueError("Commit packaging requires project.version=X.Y.Z") return version def _controller(env: dict[str, str], commit: str, *, run=output, repo: Path | None = None) -> dict: default = env.get("DEFAULT_BRANCH", "") ref = f"refs/heads/{default}" repository = env.get("GITHUB_REPOSITORY", "") expected_workflow = f"{repository}/.github/workflows/{WORKFLOW}@{ref}" if (not default or not repository or env.get("GITHUB_EVENT_NAME") != "workflow_dispatch" or env.get("GITHUB_REF") != ref or env.get("GITHUB_WORKFLOW_REF") != expected_workflow): raise ValueError("Commit builds require workflow_dispatch from the repository default-branch workflow") actors = {env.get("GITHUB_ACTOR", ""), env.get("GITHUB_TRIGGERING_ACTOR") or env.get("GITHUB_ACTOR", "")} for actor in actors: if not actor: raise ValueError("Commit builds require a repository maintainer") permission = run(["gh", "api", f"repos/{repository}/collaborators/{actor}/permission", "--jq", ".permission"], repo) if permission not in {"write", "maintain", "admin"}: raise ValueError("Commit builds require repository write, maintain or admin permission") require_pushed(commit, "origin", repo, run=run) return {"repository": repository, "default": default} def admit(env: dict[str, str], *, run=output, repo: Path | None = None) -> dict[str, str]: from scripts.releases.bundle_env import decode commit = require_commit(env.get("BUILD_COMMIT", "")) if env.get("TAG") or env.get("RELEASE_PHASE") or env.get("UPLOAD_RELEASE", "false") != "false": raise ValueError("Commit builds cannot use tag, release-phase or upload_release") if env.get("TERMUX_UPGRADE_FROM_TAG"): raise ValueError("Commit builds do not run release-channel upgrade acceptance") _controller(env, commit, run=run, repo=repo) decode(env.get("BUNDLE_ENV_JSON", "")) return {"sha": commit, "channel": "commit", "payload-version": version_at(repo, commit, run=run)} def receipt_tag(kind: str, version: str, created_at: str, run_id: str) -> str: """Return the canonical post-build receipt identity.""" from hermes_cli.update_channel import STABLE_TAG_RE if kind not in {"channel", "commit"} or not STABLE_TAG_RE.fullmatch("v" + version): raise ValueError("Build receipt kind or version is invalid") if not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): raise ValueError("Build receipt run ID is invalid") try: instant = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(timezone.utc) except (AttributeError, ValueError) as error: raise ValueError("Build receipt creation time is invalid") from error if instant.microsecond or created_at != instant.strftime("%Y-%m-%dT%H:%M:%SZ"): raise ValueError("Build receipt creation time must be UTC whole seconds") return f"v{version}+{kind}.{instant.strftime('%Y%m%dT%H%M%SZ')}.{run_id}" def _verify_receipt(tag: str, commit: str, record: dict, *, run, repo: Path | None) -> None: # Imported here, not at module level: this module's import surface stays # stdlib-only so the isolated checkout-admission step can run it from a copy # of scripts/releases alone, without the application package. from scripts.releases.versioning import tag_record tag_object = run(["git", "rev-parse", f"refs/tags/{tag}"], repo) if (run(["git", "cat-file", "-t", tag_object], repo) != "tag" or run(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], repo) != commit or tag_record(run(["git", "tag", "-l", tag, "--format=%(contents)"], repo)) != record): raise ValueError("Build receipt tag differs from this run") def publish_receipt(kind: str, env: dict[str, str], *, version: str, commit: str, details: dict, run=output, repo: Path | None = None) -> dict: """Create one annotated post-build receipt, or verify its exact replay.""" require_commit(commit) controller = _controller(env, commit, run=run, repo=repo) run_id = env.get("GITHUB_RUN_ID", "") if env.get("GITHUB_ACTIONS") != "true" or not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): raise ValueError("Build receipts require a GitHub Actions run ID") info = json.loads(run([ "gh", "api", f"repos/{controller['repository']}/actions/runs/{run_id}", ], repo)) created_at = info.get("created_at") if (info.get("id") != int(run_id) or info.get("event") != "workflow_dispatch" or info.get("status") != "in_progress" or info.get("head_branch") != controller["default"] or info.get("head_sha") != env.get("GITHUB_SHA") or not isinstance(created_at, str)): raise ValueError("Build receipt run differs from the trusted controller") tag = receipt_tag(kind, version, created_at, run_id) record = { "schema": 1, "kind": kind, "tag": tag, "version": version, "commit": commit, "runId": run_id, "runCreatedAt": created_at, "details": details, } ref = f"refs/tags/{tag}" remote = {} for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines(): sha, name = line.split() remote[name] = sha if remote: if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit: raise ValueError("Remote build receipt tag custody changed") run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo) _verify_receipt(tag, commit, record, run=run, repo=repo) return record try: local = run(["git", "rev-parse", "--verify", ref], repo) except subprocess.CalledProcessError: local = "" if local: _verify_receipt(tag, commit, record, run=run, repo=repo) else: message = json.dumps(record, sort_keys=True, separators=(",", ":")) run([ "git", "-c", "user.name=Hermes Build Receipt", "-c", "user.email=actions@users.noreply.github.com", "tag", "-a", tag, commit, "-m", message, ], repo) try: run(["git", "push", "origin", ref], repo) except subprocess.CalledProcessError: pass remote = {} for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines(): sha, name = line.split() remote[name] = sha if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit: raise ValueError("Build receipt tag was not published exactly") run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo) _verify_receipt(tag, commit, record, run=run, repo=repo) return record def resolve_revision(rev: str, remote: str, repo: Path) -> str: if not isinstance(rev, str) or not rev or rev.startswith("-"): raise ValueError("Commit builds require a Git revision") output(["git", "fetch", "--quiet", remote], repo) commit = require_commit(output(["git", "rev-parse", "--verify", "--end-of-options", f"{rev}^{{commit}}"], repo)) require_pushed(commit, remote, repo) return commit def dispatch_command(commit: str, repository: str, branch: str, bundle_env: dict[str, str | None] | None = None) -> list[str]: from scripts.releases.bundle_env import validate require_commit(commit) command = ["gh", "workflow", "run", WORKFLOW, "--ref", branch, "--repo", repository, "-f", f"build_commit={commit}", "-f", "tag=", "-f", "upload_release=false", "-f", "termux_upgrade_from_tag="] if bundle_env: command += ["-f", "bundle_env=" + json.dumps(validate(bundle_env), sort_keys=True)] return command def cmd_build_commit(args) -> None: from scripts import release from scripts.releases import r2 from scripts.releases.bundle_env import parse_assignments try: bundle_env = parse_assignments(args.bundle_env, args.bundle_unset) remote = release.resolve_push_remote(args.remote) repository = release.remote_github_repo(remote) if not repository: raise ValueError("commit builds require an explicit GitHub remote") commit = resolve_revision(args.build_commit, remote, release.REPO_ROOT) branch = release._default_branch(repository) if not branch: raise ValueError("could not resolve the repository default branch") command = dispatch_command(commit, repository, branch, bundle_env) page = r2.public_url_for(r2.public_base_url(), r2.commit_page_key_for(commit)) print(f"Building one-off bundle for commit {commit}") print(f"Builds will be available at: {page}.") print(f"Workflow command, running from {repository}@{branch}") print(f" {shlex.join(command)}") if not args.publish: print("Dry run. Add --publish to dispatch.") return print("Starting workflow!") result = subprocess.run(command, cwd=release.REPO_ROOT, capture_output=True, text=True, # windows-footgun: ok — encoding and replacement policy are on the next line. encoding="utf-8", errors="replace", check=True, timeout=60) print((result.stdout or "").strip() or f"Dispatched commit build {commit}. No release was created.") print("Wait for that workflow to finish. It builds this commit and uploads the bundles.") print(f"The builds page is {page}.") print("This build does not publish a release and does not move a channel.") except (OSError, ValueError, subprocess.SubprocessError) as exc: stderr = "" if isinstance(exc, subprocess.CalledProcessError): # check_output failures carry no captured stderr; don't mask the # original error with a TypeError while reporting it. stderr = "\n" + (exc.stderr or "") raise SystemExit(f"release: commit build refused: {exc}{stderr}") from exc def main() -> None: import sys if sys.argv[1:] not in (["admit"], ["receipt"]): raise SystemExit("usage: python -m scripts.releases.commit_build {admit|receipt}") env = dict(os.environ) values = admit(env) if sys.argv[1:] == ["admit"]: with Path(env["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: stream.write("".join(f"{key}={value}\n" for key, value in values.items())) print(json.dumps(values, sort_keys=True)) return from scripts.releases.bundle_env import decode from scripts.releases.stable import require_success needs = json.loads(env.get("RELEASE_NEEDS", "{}")) require_success(needs, list(needs)) result = publish_receipt( "commit", env, version=values["payload-version"], commit=values["sha"], details={"bundleEnv": decode(env.get("BUNDLE_ENV_JSON", ""))}, ) print(json.dumps(result, sort_keys=True)) if __name__ == "__main__": main()