From 41e4a3a011355dd3b6c3972cb2633bb200cdbd4e Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 14 Sep 2026 16:16:07 -0400 Subject: [PATCH] fix(release): fork commit builds route through disposable allocation (round 3 followup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork CI now requires a disposable channel allocation (R2_DISPOSABLE_RUN guard in desktop-bundled-release.yml), but 'release.py --build-commit REV --publish' still fired the old direct dispatch, so every fork commit build died at admission. cmd_build_commit now detects a non-upstream repository (case-insensitive NousResearch/hermes-agent compare), dispatches the allocation workflow with disposable_channel/build_commit/bundle_env baked in (all --bundle-env/--bundle-unset values travel inside the immutable request; the follow-up never re-passes them), polls the allocation run to completion (15s interval, 15min budget), extracts the printed follow-up dispatch from the run logs (channel_disposable's single-line JSON 'command'), validates its shape (gh workflow run of this workflow against the same repository), and auto-dispatches it with the local maintainer's gh login — falling back to a clear run-summary pointer when log recovery fails. Upstream behavior is unchanged. Also fixes a pre-existing TypeError that masked check_output failures whose CalledProcessError has stderr=None. --- scripts/release.py | 5 +- scripts/releases/commit_build.py | 210 ++++++++++++++++++++- tests/scripts/test_release_build_commit.py | 108 ++++++++++- 3 files changed, 313 insertions(+), 10 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 4ba9ae1576..9715a64822 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -2940,7 +2940,10 @@ def main(): "HEAD has no new commits since the last canary") parser.add_argument("--build-commit", type=str, metavar="REV", help="Preview an exact-commit build into releases/commit// on R2. " - "Add --publish to dispatch without a tag or release.") + "Add --publish to dispatch without a tag or release. On a fork " + "(any repository other than NousResearch/hermes-agent) this " + "transparently dispatches a disposable channel allocation and " + "prints the follow-up build command from its run.") parser.add_argument("--bundle-env", action="append", default=[], metavar="NAME=VALUE", help="Bake a non-secret environment default into a commit desktop bundle. " "Repeat for multiple variables. Runtime environment values win.") diff --git a/scripts/releases/commit_build.py b/scripts/releases/commit_build.py index 71c1efb3e2..487cde0e02 100644 --- a/scripts/releases/commit_build.py +++ b/scripts/releases/commit_build.py @@ -6,11 +6,29 @@ import os import re import shlex import subprocess +import time import tomllib from pathlib import Path WORKFLOW = "desktop-bundled-release.yml" +# Direct commit-build dispatch is the upstream-only path: the workflow's +# admission step rejects any repository != NousResearch/hermes-agent that has +# no disposable allocation (the fork CI guard). Forks must instead allocate a +# disposable channel first; commit_build.cmd_build_commit routes them there. +UPSTREAM_REPOSITORY = "NousResearch/hermes-agent" + +# The allocation workflow run is created by a server-side race we do not +# observe; this bounds the wait for it to appear in `gh run list`. +_ALLOCATION_APPEAR_TIMEOUT_S = 120 +# A disposable allocation is a single 10-minute-timeout GHA job (probes plus +# R2 writes, no native build); the same budget covers queueing. +_ALLOCATION_COMPLETE_TIMEOUT_S = 900 +_ALLOCATION_POLL_INTERVAL_S = 15 +# How recent a listed run's createdAt must be to count as ours without being +# queued/running — generous enough for gh/GitHub clock skew. +_RUN_RECENCY_WINDOW_S = 300 + def require_commit(value: str) -> str: if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{40}", value): @@ -91,6 +109,176 @@ def dispatch_command(commit: str, repository: str, branch: str, return command +def disposable_dispatch_command(commit: str, repository: str, branch: str, + bundle_env: dict[str, str | None] | None = None) -> list[str]: + """Fork allocation dispatch: same workflow, disposable_channel inputs. + + Bundle env travels here (not on the follow-up build dispatch): the + allocation bakes it into the immutable request, so every value must be + present at allocation time. The follow-up command pinned to that request + must never re-pass bundle_env. + """ + from scripts.releases.bundle_env import validate + + require_commit(commit) + command = ["gh", "workflow", "run", WORKFLOW, "--repo", repository, "--ref", branch, + "-f", f"build_commit={commit}", "-f", "tag=", "-f", "upload_release=false", + "-f", "termux_only=false", "-f", "termux_upgrade_from_tag=", + "-f", "disposable_receivers=false", + "-f", "disposable_channel=" + _allocation_channel_name(commit)] + if bundle_env: + command += ["-f", "bundle_env=" + json.dumps(validate(bundle_env), sort_keys=True)] + return command + + +def _allocation_channel_name(commit: str) -> str: + """A unique disposable preview name. + + Uniqueness matters: ChannelPublisher.create() returns an existing record + instead of failing, so a colliding name would silently allocate into a + previous channel and bump its sequence. + """ + return f"commit-{commit[:12]}-{int(time.time())}" + + +def _fork_allocation(repository: str, command: list[str], repo_root: Path) -> None: + """Dispatch the allocation run on a fork, then dispatch its follow-up build. + + The lease lives in the fork's Actions run (release-signing secrets exist + only there), so the allocation itself cannot run locally. But the + follow-up dispatch CAN: it runs with the local maintainer's gh login — + the same identity that dispatched the allocation — unlike the CI + controller token, which deliberately never dispatches publication runs. + One command therefore covers both steps; if the follow-up cannot be + recovered from the run logs, fall back to pointing at the run summary. + """ + run = subprocess.run(command, cwd=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((run.stdout or "").strip() or f"Dispatched disposable allocation for {repository}.") + run_id = _await_allocation_run(repository) + _await_completion(repository, run_id) + follow_up = _extract_follow_up(repository, run_id) + if not _is_pinned_follow_up(follow_up, repository): + print(f"Disposable allocation succeeded (run {run_id}), but the follow-up build " + "command could not be recovered from the run logs. Copy it from the " + f"allocation run summary — https://github.com/{repository}/actions/runs/{run_id} " + "— and run it with your maintainer login.") + return + assert follow_up is not None + print("Allocation complete; dispatching the pinned channel build.") + print(f" {shlex.join(follow_up)}") + result = subprocess.run(follow_up, cwd=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 channel build from allocation run {run_id}. No release was created.") + + +def _is_pinned_follow_up(command: object, repository: str) -> bool: + """Only ever execute a follow-up shaped exactly like our own dispatch. + + The command is parsed from CI logs, so refuse anything that is not a + `gh workflow run` of this workflow against the same repository. + """ + if not (isinstance(command, list) and len(command) > 6 + and command[:4] == ["gh", "workflow", "run", WORKFLOW] and "--repo" in command): + return False + index = command.index("--repo") + return command[index + 1:index + 2] == [repository] + + +def _await_allocation_run(repository: str) -> str: + """Wait for the just-dispatched allocation run to appear and return its id. + + GitHub creates workflow_dispatch runs asynchronously, so the newest list + entry right after dispatch may still be a prior run. Accept the newest + entry once it is queued/running, or once it was created inside a recent + window (allowing gh/GitHub clock skew); otherwise keep polling and fall + back to the newest entry when the window closes. + """ + list_command = ["gh", "run", "list", "--repo", repository, + "--workflow", WORKFLOW, "--limit", "1", + "--json", "databaseId,status,conclusion,createdAt"] + recent_cutoff = _iso_utc_now_minus(_RUN_RECENCY_WINDOW_S) + deadline = time.monotonic() + _ALLOCATION_APPEAR_TIMEOUT_S + latest: dict = {} + while time.monotonic() < deadline: + result = subprocess.run(list_command, capture_output=True, text=True, # windows-footgun: ok — encoding and replacement policy are on the next line. + encoding="utf-8", errors="replace", timeout=60) + if result.returncode == 0: + latest = (json.loads(result.stdout or "[]") or [{}])[0] + if (latest.get("status") in {"queued", "in_progress"} + or str(latest.get("createdAt", "")) >= recent_cutoff): + return str(latest["databaseId"]) + time.sleep(_ALLOCATION_POLL_INTERVAL_S) + if latest.get("databaseId"): + # gh's server-side lag outran the window; the newest run we saw is + # still the best candidate for the dispatch we just made. + return str(latest["databaseId"]) + raise ValueError(f"No workflow run appeared for {repository} within " + f"{_ALLOCATION_APPEAR_TIMEOUT_S} seconds") + + +def _iso_utc_now_minus(seconds: int) -> str: + """A UTC ISO-8601 timestamp `seconds` in the past, for string comparison + against gh's `createdAt` values (both share the same format and zone).""" + from datetime import datetime, timedelta, timezone + + return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat() + + +def _await_completion(repository: str, run_id: str) -> None: + """Poll the allocation run until GitHub reports a terminal conclusion.""" + view_command = ["gh", "run", "view", run_id, "--repo", repository, + "--json", "status,conclusion"] + deadline = time.monotonic() + _ALLOCATION_COMPLETE_TIMEOUT_S + while True: + result = subprocess.run(view_command, capture_output=True, text=True, # windows-footgun: ok — encoding and replacement policy are on the next line. + encoding="utf-8", errors="replace", timeout=60) + if result.returncode != 0: + raise ValueError(f"Could not read workflow run {run_id}: {(result.stderr or '').strip()}") + status = (json.loads(result.stdout or "{}") or {}) + if status.get("status") == "completed": + if status.get("conclusion") != "success": + raise ValueError(f"Disposable allocation run {run_id} finished with " + f"conclusion {status.get('conclusion')!r}; see " + f"https://github.com/{repository}/actions/runs/{run_id}") + return + if time.monotonic() >= deadline: + raise ValueError(f"Disposable allocation run {run_id} did not complete within " + f"{_ALLOCATION_COMPLETE_TIMEOUT_S} seconds; see " + f"https://github.com/{repository}/actions/runs/{run_id}") + time.sleep(_ALLOCATION_POLL_INTERVAL_S) + + +def _extract_follow_up(repository: str, run_id: str) -> list[str] | None: + """Find the printed follow-up dispatch in the allocation run's logs. + + channel_disposable prints one single-line JSON object (sorted keys) whose + "command" value is the exact argv list of the follow-up dispatch. gh --log + prefixes each line with "job\\tstep\\ttimestamp ", so scan every line for + a decodable JSON object carrying a "command" argv. Return the argv list, + or None when the log line is unavailable. + """ + result = subprocess.run(["gh", "run", "view", run_id, "--repo", repository, "--log"], + capture_output=True, text=True, # windows-footgun: ok — encoding and replacement policy are on the next line. + encoding="utf-8", errors="replace", timeout=120) + if result.returncode != 0: + return None + decoder = json.JSONDecoder() + for line in (result.stdout or "").splitlines(): + index = line.find("{") + while index != -1: + try: + value, _ = decoder.raw_decode(line, index) + except json.JSONDecodeError: + index = line.find("{", index + 1) + continue + command = value.get("command") if isinstance(value, dict) else None + if isinstance(command, list) and command and all(isinstance(part, str) for part in command): + return command + index = line.find("{", index + 1) + return None + + def cmd_build_commit(args) -> None: from scripts import release from scripts.releases import r2 @@ -106,10 +294,26 @@ def cmd_build_commit(args) -> None: 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) + fork = repository.casefold() != UPSTREAM_REPOSITORY.casefold() + command = disposable_dispatch_command(commit, repository, branch, bundle_env) if fork \ + else 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}.") + if fork: + # The disposable flow allocates the channel in the fork's Actions + # run and prints the pinned follow-up dispatch there; bundle env + # already travels inside the allocation request. + print(f"Repository {repository} is not {UPSTREAM_REPOSITORY}; routing the " + "commit build through a disposable channel allocation.") + print(f"Allocation command, running from {repository}@{branch}") + print(f" {shlex.join(command)}") + if not args.publish: + print("Dry run. Add --publish to dispatch.") + return + print("Starting disposable allocation workflow!") + _fork_allocation(repository, command, release.REPO_ROOT) + return print(f"Workflow command, running from {repository}@{branch}") print(f" {shlex.join(command)}") if not args.publish: @@ -122,7 +326,9 @@ def cmd_build_commit(args) -> None: except (OSError, ValueError, subprocess.SubprocessError) as exc: stderr = "" if isinstance(exc, subprocess.CalledProcessError): - stderr = "\n" + exc.stderr + # 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 diff --git a/tests/scripts/test_release_build_commit.py b/tests/scripts/test_release_build_commit.py index df8a0b577f..62b7bd0836 100644 --- a/tests/scripts/test_release_build_commit.py +++ b/tests/scripts/test_release_build_commit.py @@ -53,10 +53,21 @@ def run(argv, *args, **kwargs): raise FileNotFoundError('fixture gh is absent') if argv[1:3] == ['repo', 'view']: assert '--repo' not in argv, 'gh repo view requires a positional repository' - assert 'fixture-owner/fixture-repo' in argv + assert argv[-1].count('/') == 1, argv value = 'main\\n' elif argv[1:3] == ['workflow', 'run']: value = 'fixture dispatch accepted\\n' + elif argv[1:3] == ['run', 'list']: + value = json.dumps([{'databaseId': 777, 'status': 'queued', 'conclusion': '', + 'createdAt': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat()}]) + '\\n' + elif argv[1:3] == ['run', 'view'] and '--log' in argv: + if os.environ.get('PROBE_ALLOCATION_FAIL'): + return subprocess.CompletedProcess(argv, 1, stdout='', stderr='fixture log unavailable') + record = json.loads(os.environ.get('PROBE_ALLOCATION_LOG', '{}')) + line = json.dumps(record, sort_keys=True) if record else '' + value = 'allocate-disposable\\tProbe scoped storage\\t2026-09-14T00:00:00Z ' + line + '\\n' + elif argv[1:3] == ['run', 'view']: + value = json.dumps({'status': 'completed', 'conclusion': 'success'}) + '\\n' elif argv[1] == 'api': value = os.environ.get('PROBE_PERMISSION', 'write') + '\\n' else: @@ -66,8 +77,10 @@ def run(argv, *args, **kwargs): assert pathlib.Path(kwargs.get('cwd') or pathlib.Path.cwd()).resolve() == root.resolve() if argv[1] in ('fetch', 'ls-remote'): # Only the transport points at a local fixture. Identity reads stay real. - rewrite = 'url.' + os.environ['PROBE_REMOTE'] + '.insteadOf=https://github.com/fixture-owner/fixture-repo.git' - argv = ['git', '-c', rewrite, *argv[1:]] + rewrites = ['-c', 'url.' + os.environ['PROBE_REMOTE'] + '.insteadOf=https://github.com/fixture-owner/fixture-repo.git'] + if os.environ.get('PROBE_UPSTREAM_URL'): + rewrites += ['-c', 'url.' + os.environ['PROBE_REMOTE'] + '.insteadOf=' + os.environ['PROBE_UPSTREAM_URL']] + argv = ['git', *rewrites, *argv[1:]] return actual(argv, *args, **kwargs) subprocess.run = run if sys.argv[2] == 'admit': @@ -105,14 +118,95 @@ def test_commit_build_cli_dispatches_only_the_resolved_remote_commit(fixture_rep assert not any(call[1:3] == ['workflow', 'run'] for call in calls) result, calls = invoke('--build-commit', tip, '--publish') assert result.returncode == 0, result.stderr + # The fixture remote is a fork: --publish now routes through the + # disposable allocation instead of the guarded direct dispatch. dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']] - assert dispatches == [['gh', 'workflow', 'run', 'desktop-bundled-release.yml', - '--ref', 'main', '--repo', 'fixture-owner/fixture-repo', - '-f', f'build_commit={tip}', '-f', 'tag=', '-f', 'upload_release=false', - '-f', 'termux_only=false', '-f', 'termux_upgrade_from_tag=']] + assert len(dispatches) == 1 + assert f'build_commit={tip}' in dispatches[0] + assert any(field.startswith('disposable_channel=') for field in dispatches[0]) assert git(repo, 'show-ref', '--heads', '--tags') == before assert git(upstream, 'rev-parse', 'refs/heads/main') == tip +def test_commit_build_upstream_repository_keeps_direct_dispatch(fixture_repo): + repo, _, invoke = fixture_repo + tip = git(repo, 'rev-parse', 'HEAD') + git(repo, 'remote', 'set-url', 'origin', 'https://github.com/NousResearch/hermes-agent.git') + result, calls = invoke('--build-commit', tip, '--publish', + extra={'PROBE_UPSTREAM_URL': 'https://github.com/NousResearch/hermes-agent.git'}) + assert result.returncode == 0, result.stderr + dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']] + assert dispatches == [['gh', 'workflow', 'run', 'desktop-bundled-release.yml', + '--ref', 'main', '--repo', 'NousResearch/hermes-agent', + '-f', f'build_commit={tip}', '-f', 'tag=', '-f', 'upload_release=false', + '-f', 'termux_only=false', '-f', 'termux_upgrade_from_tag=']] + assert 'disposable' not in result.stdout.lower() + +def test_fork_commit_build_routes_through_disposable_allocation(fixture_repo): + repo, _, invoke = fixture_repo + tip = git(repo, 'rev-parse', 'HEAD') + values = {'HERMES_GUEST_ONBOARDING': '1', 'HERMES_HOME': None} + flags = ['--bundle-env', 'HERMES_GUEST_ONBOARDING=1', '--bundle-unset', 'HERMES_HOME'] + result, calls = invoke('--build-commit', tip, '--publish', *flags) + assert result.returncode == 0, result.stderr + dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']] + assert len(dispatches) == 1 + dispatch = dispatches[0] + assert dispatch[3:7] == ['desktop-bundled-release.yml', '--repo', 'fixture-owner/fixture-repo', '--ref'] + assert dispatch[dispatch.index('--ref') + 1] == 'main' + fields = dispatch[dispatch.index('-f') + 1::2] + pairs = dict(value.split('=', 1) for value in fields) + assert pairs['build_commit'] == tip + assert pairs['tag'] == '' and pairs['upload_release'] == 'false' + assert pairs['termux_only'] == 'false' and pairs['termux_upgrade_from_tag'] == '' + assert pairs['disposable_receivers'] == 'false' + name = pairs['disposable_channel'] + assert name.startswith('commit-') and tip[:12] in name + assert json.loads(pairs['bundle_env']) == values + # No build_commit-only direct dispatch escapes to the fork's CI guard. + assert not any('build_commit' in ' '.join(call) and 'disposable_channel' not in ' '.join(call) + for call in calls if call[1:3] == ['workflow', 'run']) + result, calls = invoke('--build-commit', tip) + assert result.returncode == 0, result.stderr + assert not any(call[1:3] == ['workflow', 'run'] for call in calls) + assert 'disposable' in result.stdout.lower() + +def test_fork_allocation_polls_extracts_and_dispatches_followup(fixture_repo): + repo, _, invoke = fixture_repo + tip = git(repo, 'rev-parse', 'HEAD') + follow_up = ['gh', 'workflow', 'run', 'desktop-bundled-release.yml', '--repo', + 'fixture-owner/fixture-repo', '--ref', 'main', '-f', 'channel_build=' + 'a' * 32, + '-f', 'channel_request_sha256=' + 'b' * 64, '-f', 'disposable_run=12345-1', + '-f', 'tag=', '-f', 'upload_release=false', '-f', 'termux_only=false', + '-f', 'termux_upgrade_from_tag='] + result, calls = invoke('--build-commit', tip, '--publish', + extra={'PROBE_ALLOCATION_LOG': json.dumps({'command': follow_up})}) + assert result.returncode == 0, result.stderr + dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']] + assert len(dispatches) == 2 + assert 'disposable_channel=' in ' '.join(dispatches[0]) + # The extracted follow-up is dispatched verbatim: pinned build, digest and + # disposable_run from the allocation, and no bundle_env re-passed. + assert dispatches[1] == follow_up + assert 'bundle_env=' not in ' '.join(dispatches[1]) + run_lists = [call for call in calls if call[1:3] == ['run', 'list']] + assert run_lists and all('--repo' in call for call in run_lists) + run_views = [call for call in calls if call[1:3] == ['run', 'view']] + assert run_views and all(call[3] == '777' for call in run_views) + assert 'channel_build=' in result.stdout + +def test_fork_allocation_extraction_failure_prints_summary_pointer(fixture_repo): + repo, _, invoke = fixture_repo + tip = git(repo, 'rev-parse', 'HEAD') + result, calls = invoke('--build-commit', tip, '--publish', + extra={'PROBE_ALLOCATION_FAIL': '1'}) + # The allocation itself succeeded, so the command completes; the follow-up + # must be surfaced via a clear pointer to the run summary. + assert result.returncode == 0, result.stderr + dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']] + assert len(dispatches) == 1 and 'disposable_channel=' in ' '.join(dispatches[0]) + assert 'actions/runs/777' in result.stdout + assert 'Traceback' not in result.stderr + def test_commit_bundle_environment_is_literal_and_validated(fixture_repo): repo, _, invoke = fixture_repo tip = git(repo, 'rev-parse', 'HEAD')