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:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user