refactor(releases): drop fork-conditional dispatch and scoping

Repository identity no longer selects behavior: a commit build is the
same direct dispatch from any repository, and R2 disposable scoping is
opt-in via R2_DISPOSABLE_RUN rather than fork-mandated. The fork guard
in the workflow admission step, the fork refusal in R2Scope.configured,
and the client-side fork routing (disposable_dispatch_command) all go.
Disposable namespaces keep their own protections: malformed leases are
refused, and a scoped namespace still belongs to exactly one repository.
This commit is contained in:
ethernet
2026-09-14 21:18:51 -04:00
parent e2e5dbed4a
commit d8286ae58f
5 changed files with 32 additions and 95 deletions

View File

@@ -6,18 +6,11 @@ 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 dispatch the same
# workflow in disposable mode instead; cmd_build_commit selects the flags.
UPSTREAM_REPOSITORY = "NousResearch/hermes-agent"
def require_commit(value: str) -> str:
if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{40}", value):
@@ -98,37 +91,6 @@ 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 dispatch: same workflow, disposable_channel inputs.
Bundle env travels here: the allocation bakes it into the immutable
request inside the same run that builds it, so every value must be
present at dispatch time.
"""
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 cmd_build_commit(args) -> None:
from scripts import release
from scripts.releases import r2
@@ -144,17 +106,10 @@ def cmd_build_commit(args) -> None:
branch = release._default_branch(repository)
if not branch:
raise ValueError("could not resolve the repository default branch")
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)
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}.")
if fork:
# One dispatch: the fork run allocates its disposable channel and
# builds it in the same run; bundle env travels in the allocation.
print(f"Repository {repository} is not {UPSTREAM_REPOSITORY}; dispatching "
"a disposable channel build.")
print(f"Workflow command, running from {repository}@{branch}")
print(f" {shlex.join(command)}")
if not args.publish:

View File

@@ -566,7 +566,7 @@ def required_env(name: str) -> str:
def credentials() -> tuple[dict[str, str], str, str]:
"""(creds, base, bucket) from the R2 env vars. No secrets are printed."""
R2Scope.configured() # Fail closed before exposing an unscoped fork transport.
R2Scope.configured() # Fail closed on a malformed disposable lease.
creds = {
"access_key_id": required_env("CLOUDFLARE_R2_ACCESS_KEY_ID"),
"secret_key": required_env("CLOUDFLARE_R2_SECRET_ACCESS_KEY"),

View File

@@ -31,10 +31,9 @@ class R2Scope:
def configured(cls, repository: str | None = None) -> R2Scope:
run = os.environ.get("R2_DISPOSABLE_RUN", "")
if not run:
authority = repository if repository is not None else (
os.environ.get("GITHUB_REPOSITORY", "") if os.environ.get("GITHUB_ACTIONS") == "true" else None)
if authority is not None and authority.casefold() != "nousresearch/hermes-agent":
raise ValueError("Fork channel operations require a disposable R2 run")
# Opt-in scoping: R2_DISPOSABLE_RUN selects the disposable
# namespace regardless of repository. Unset means production
# keys — every caller already holds the release-signing secret.
return cls()
require_run(run)
repository_id = os.environ.get("GITHUB_REPOSITORY_ID", "")

View File

@@ -107,61 +107,43 @@ 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.
# Repository identity no longer selects the dispatch: every commit build
# is the same direct dispatch (disposable mode is an explicit opt-in).
dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']]
assert len(dispatches) == 1
assert f'build_commit={tip}' in dispatches[0]
assert any(field.startswith('disposable_channel=') for field in dispatches[0])
assert not 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):
def test_commit_build_dispatch_is_repository_independent(fixture_repo):
repo, _, invoke = fixture_repo
tip = git(repo, 'rev-parse', 'HEAD')
expected = ['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=']
result, calls = invoke('--build-commit', tip, '--publish')
assert result.returncode == 0, result.stderr
dispatches = [call for call in calls if call[1:3] == ['workflow', 'run']]
assert dispatches == [expected]
assert 'disposable' not in result.stdout.lower()
# The upstream URL used to select a different command shape; it no longer does.
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()
'--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=']]
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()
# (One-dispatch redesign) The allocation poll/extract/auto-dispatch machinery these
# two tests exercised was deleted: a fork commit build is now a SINGLE dispatch whose
# run both allocates and builds. See test_fork_commit_build_routes_through_disposable_allocation.
# (Fork-conditional removal) The disposable routing test retired with it: a
# commit build dispatch does not branch on repository identity. Disposable
# channels remain reachable via explicit --channel-request / CI allocation.
def test_commit_bundle_environment_is_literal_and_validated(fixture_repo):

View File

@@ -73,22 +73,23 @@ def test_missing_controller_branch_is_rejected_without_channel_creation(source):
assert not objects
def test_local_fork_requires_scope_before_credentials_or_storage(monkeypatch):
def test_disposable_scope_is_opt_in_and_lease_bound(monkeypatch):
from scripts.releases import channel_build, r2
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
monkeypatch.delenv("R2_DISPOSABLE_RUN", raising=False)
monkeypatch.setenv("CLOUDFLARE_R2_PUBLIC_URL", "https://archive.example")
monkeypatch.setattr(r2, "credentials", lambda: pytest.fail("Unscoped fork reached credentials"))
with pytest.raises(ValueError, match="disposable"):
channel_build.configured_publisher("ethernet8023/hermes-agent")
# No lease and no repository identity check: scoping is opt-in, the
# unscoped publisher talks production keys only when the caller holds them.
monkeypatch.setattr(r2, "credentials", lambda: ({"access_key_id": "inert", "secret_key": "inert"}, "https://r2.example", "bucket"))
unscoped = channel_build.configured_publisher("ethernet8023/hermes-agent")
assert unscoped.store.scope.key("releases/channels/preview.json") == "releases/channels/preview.json"
monkeypatch.setenv("R2_DISPOSABLE_RUN", "123-1")
monkeypatch.setenv("GITHUB_REPOSITORY_ID", "456")
monkeypatch.setattr(channel_build.commit_build, "output", lambda args: "789")
with pytest.raises(ValueError, match="another repository"):
channel_build.configured_publisher("ethernet8023/hermes-agent")
monkeypatch.setattr(channel_build.commit_build, "output", lambda args: "456")
monkeypatch.setattr(r2, "credentials", lambda: ({"access_key_id": "inert", "secret_key": "inert"}, "https://r2.example", "bucket"))
publisher = channel_build.configured_publisher("ethernet8023/hermes-agent")
assert publisher.store.scope.key("releases/channels/preview.json") == "ci-disposable/456/123-1/releases/channels/preview.json"
assert publisher.reader.base_url == "https://archive.example/ci-disposable/456/123-1"