fix(release): fork commit builds route through disposable allocation (round 3 followup)

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.
This commit is contained in:
ethernet
2026-09-14 16:16:07 -04:00
parent 3cbd595c84
commit 41e4a3a011
3 changed files with 313 additions and 10 deletions

View File

@@ -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/<sha>/ 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.")

View File

@@ -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

View File

@@ -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')