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