test(ci): resolve commit and channel staging jobs by role

test_commit_build_staging and test_channel_build_publication ran staging
scripts from jobs named by id and step name, and checked literal `if:`
substrings. The per-arch build split broke them for that reason alone.

- Staging scripts come from the native leg for a target and trust branch, the
  universal assembler, or the termux builder. Within the job, the step is
  found by the receipt it stages (--commit-build or --channel-request). The
  summary and channel publisher are found by the table they render.
- Summary needs come from the summary job's own needs, not a copied list.
- "Selection env mentions needs.<id>-commit.result" becomes "no native leg is
  fail-fast". That is what the commit summary relies on. The selection logic
  itself is evaluated in test_desktop_bundle_smoke.
- Channel exclusion is evaluated: on every native leg the tag/commit stage
  stands down for a pinned channel build and the pinned-request stage runs.
  The commit-only status page is evaluated to run for a commit build and to
  stand down for channel and disposable-channel dispatches.
This commit is contained in:
ethernet
2026-09-24 14:02:53 -04:00
parent 37d3e32bbe
commit 8f7da10406
2 changed files with 72 additions and 61 deletions

View File

@@ -18,6 +18,10 @@ import pytest
from hermes_cli.release_channels import canonical_json
from scripts.releases import channel_publish, handoff, r2
from scripts.releases.channels import preview_identity
from tests.ci.desktop_release_roles import (
DOWNLOADABLE_DISPATCHES, admitted, channel_publisher, commit_summary, gate, native_builds, needs_of, stage_step,
universal_assembler,
)
from tests.ci.test_desktop_release_tag_admission import _seed_repo, _git, _workflow
from tests.scripts.test_release_r2 import r2_server # noqa: F401
@@ -244,7 +248,8 @@ def test_real_workflow_admission_and_public_smoke_fetch(tmp_path, r2_server, sta
@pytest.mark.platforms("posix")
def test_workflow_promotion_missing_native_gate_does_not_write(tmp_path, r2_server, staged_channel):
request, _ = staged_channel
script = workflow_step("desktop-bundled-release.yml", "publish-channel", "Publish immutable feeds and manifest, then CAS channel head")
script = workflow_step("desktop-bundled-release.yml", channel_publisher(_workflow()["jobs"]),
"Publish immutable feeds and manifest, then CAS channel head")
env = {"CHANNEL_BUILD": request["buildId"], "CHANNEL_REQUEST_SHA256": hashlib.sha256(canonical_json(request)).hexdigest(),
"CLOUDFLARE_R2_PUBLIC_URL": request["publicBase"], "GITHUB_REPOSITORY": request["repository"],
"RUNNER_TEMP": str(tmp_path), "RELEASE_NEEDS": "{}"}
@@ -265,7 +270,8 @@ def test_real_publication_cas_and_manifest_summary(tmp_path, r2_server, staged_c
"policy": "preview", "state": "active", "revision": 1, "nextSequence": 8,
"identity": request["identity"], "head": None}
r2_server.store[channel_key] = (canonical_json(record), '"record"')
script = workflow_step("desktop-bundled-release.yml", "publish-channel", "Publish immutable feeds and manifest, then CAS channel head")
script = workflow_step("desktop-bundled-release.yml", channel_publisher(_workflow()["jobs"]),
"Publish immutable feeds and manifest, then CAS channel head")
env = {"CHANNEL_BUILD": request["buildId"], "CHANNEL_REQUEST_SHA256": hashlib.sha256(canonical_json(request)).hexdigest(),
"CLOUDFLARE_R2_PUBLIC_URL": request["publicBase"], "GITHUB_REPOSITORY": request["repository"],
"RUNNER_TEMP": str(tmp_path), "GITHUB_STEP_SUMMARY": str(tmp_path / "summary.md"),
@@ -350,7 +356,8 @@ def test_channel_windows_record_stage_and_assembly_handoff_shell(tmp_path, r2_se
prefix = handoff.channel_prefix(request)
for key in ("metadata-windows-x64.json", "handoff-win32-x64.json"):
r2_server.store.pop(prefix + key)
script = workflow_step("desktop-bundled-release.yml", "build-win32-x64-commit", "Record and stage channel windows packages")
jobs = _workflow()["jobs"]
script = stage_step(jobs[native_builds(jobs)[("win32-x64", "commit")]], channel=True)["run"]
result = run_shell(tmp_path, r2_server, script, env)
assert result.returncode == 0, result.stdout + result.stderr
# One-dispatch: the env-default commit equals the request's own, which is
@@ -368,7 +375,7 @@ def test_channel_windows_record_stage_and_assembly_handoff_shell(tmp_path, r2_se
metadata = json.loads((release / "metadata-windows-x64.json").read_text(encoding="utf-8"))
assert metadata["identity"] == request["identity"]["msixAppIdWithOrg"]
assert metadata["applicationId"] == request["identity"]["appNamePascal"]
fetch = workflow_step("desktop-bundled-release.yml", "assemble-win32-bundle", "Retrieve Windows packages from R2")
fetch = workflow_step("desktop-bundled-release.yml", universal_assembler(jobs), "Retrieve Windows packages from R2")
result = run_shell(tmp_path, r2_server, fetch, env)
assert result.returncode == 0, result.stdout + result.stderr
assert sorted(p.name for p in release.glob("*.msix")) == sorted(p.name for p in build.glob("*.msix"))
@@ -493,35 +500,40 @@ def test_tag_and_commit_staging_never_runs_for_a_pinned_channel_build():
alone, so every other handoff staging step must be gated off whenever a
channel build is pinned.
"""
workflow = _workflow()
seen = set()
for job in ("build-win32-x64-commit", "build-win32-arm64-commit",
"build-darwin-arm64-commit", "build-darwin-x64-commit"):
for step in workflow["jobs"][job]["steps"]:
run = step.get("run", "") if isinstance(step, dict) else ""
if "scripts.releases.handoff stage" not in run:
continue
if "--channel-request" in run:
continue
seen.add(step["name"])
assert "needs.validate.outputs.channel-build == ''" in (step.get("if") or ""), (
f"{step['name']!r} stages tag/commit receipts without excluding channel builds")
assert seen, "walk broken: no non-channel handoff staging steps found"
jobs = _workflow()["jobs"]
channel = admitted(["validate"], channel=True)
for name in native_builds(jobs).values():
step = stage_step(jobs[name])
# Every trust branch stages tag/commit receipts through this step, so
# it must stand down for a pinned channel build and run otherwise.
assert not gate(step.get("if", "true"), DOWNLOADABLE_DISPATCHES["channel"], channel, job_if=False), (
f"{name}: {step['name']!r} stages tag/commit receipts for a channel build")
for dispatch in ("tag", "candidate", "commit"):
assert gate(step.get("if", "true"), DOWNLOADABLE_DISPATCHES[dispatch], admitted(["validate"]),
job_if=False), (name, dispatch)
# The pinned-request stage is the one that runs instead.
pinned = stage_step(jobs[name], channel=True)
assert gate(pinned["if"], DOWNLOADABLE_DISPATCHES["channel"], channel, job_if=False), name
def test_commit_only_status_page_is_not_published_for_a_channel_build():
"""Two renderers exist; only the channel one may run for a channel build.
``publish-channel`` renders the channel matrix from the pinned request.
The channel publisher renders the channel matrix from the pinned request.
Letting the commit-only summary run too would fetch commit-namespace
receipts that a channel build never wrote and publish a page claiming the
commit's binaries were not built.
"""
workflow = _workflow()
channel = next(step for step in workflow["jobs"]["publish-channel"]["steps"]
if "render-builds-table.py" in step.get("run", ""))
assert "--channel-build" in channel["run"]
summary = workflow["jobs"]["commit-builds-summary"]
assert "inputs.channel == ''" in summary["if"], (
jobs = _workflow()["jobs"]
channel_publisher(jobs) # the channel renderer exists; it fails loudly otherwise
summary = jobs[commit_summary(jobs)]
def runs(inputs, *, channel=False):
needs = admitted(needs_of(summary), channel=channel)
needs["validate"]["outputs"]["all-jobs"] = "true"
return gate(summary["if"], inputs, needs)
assert runs(DOWNLOADABLE_DISPATCHES["commit"])
assert not runs(DOWNLOADABLE_DISPATCHES["channel"], channel=True), (
"the commit-only status page must not run for a channel dispatch")
assert "inputs.disposable_channel == ''" in summary["if"]
assert not runs({**DOWNLOADABLE_DISPATCHES["commit"], "disposable_channel": "native-preview"})

View File

@@ -12,6 +12,9 @@ from urllib.parse import quote, unquote
import pytest
from tests.ci.desktop_release_roles import (
commit_summary, native_builds, needs_of, selection_gates, stage_step, termux_builder, universal_assembler,
)
from tests.ci.test_desktop_release_tag_admission import _BASH, _child_env, _workflow
from tests.scripts.test_release_r2 import r2_server # noqa: F401
@@ -66,6 +69,7 @@ def test_failed_commit_summary_publishes_downloads_or_run_links(tmp_path, r2_ser
base = f'http://127.0.0.1:{r2_server.server_port}/hermes-releases'
summary = tmp_path / 'summary.md'
jobs = _workflow()['jobs']
summary_job = commit_summary(jobs)
bundle_env = {'HERMES_HOME': None, 'HERMES_SKIP_INTRO': '',
'HERMES_SHARED_AUTH_DIR': '<script>\n"café" & value</script>'}
env = dict(HERMES_BUILD_COMMIT=sha, HERMES_PAYLOAD_TAG='', RELEASE_COMMIT=sha,
@@ -76,14 +80,15 @@ def test_failed_commit_summary_publishes_downloads_or_run_links(tmp_path, r2_ser
CLOUDFLARE_R2_ACCOUNT_ID='loopback', CLOUDFLARE_R2_ACCESS_KEY_ID='test-inert',
CLOUDFLARE_R2_SECRET_ACCESS_KEY='test-inert', CLOUDFLARE_R2_BUCKET='hermes-releases',
RELEASE_NEEDS=json.dumps({name: {'result': 'success' if name == 'validate' else 'failure'}
for name in jobs['commit-builds-summary']['needs']}))
for name in needs_of(jobs[summary_job])}))
if has_download:
artifact = tmp_path / 'apps/desktop/release/HermesBundled-0.33.0-win-x64.msix'
artifact.parent.mkdir(parents=True)
artifact.write_bytes(b'inert downloadable fixture')
staged = shell_step(tmp_path, r2_server, 'build-win32-x64-commit', 'Stage Windows packages to R2', env)
producer = jobs[native_builds(jobs)[('win32-x64', 'commit')]]
staged = shell_step(tmp_path, r2_server, '', '', env, script=stage_step(producer)['run'])
assert staged.returncode == 0, staged.stdout + staged.stderr
result = shell_step(tmp_path, r2_server, 'commit-builds-summary',
result = shell_step(tmp_path, r2_server, summary_job,
'Render the full expected-binary matrix', env)
assert result.returncode == 0, result.stdout + result.stderr
text = summary.read_text(encoding='utf-8-sig')
@@ -109,13 +114,11 @@ def test_failed_commit_summary_publishes_downloads_or_run_links(tmp_path, r2_ser
elif line.startswith('| Linux'):
assert 'Disabled' in line and '](' not in line
assert all(key.startswith(f'releases/commit/{sha}/') for key in r2_server.store)
for name in ('build-win32-x64', 'build-win32-arm64', 'build-darwin-arm64', 'build-darwin-x64'):
# The commit/release split collapsed into one leg per arch; the
# result job's env still encodes exactly-which-trust-branch-succeeded.
result_job = jobs[name]
assert f"needs.{name}-commit.result" in result_job['env']['SELECTED_BUILD_SUCCEEDED']
assert jobs[f'{name}-commit']['strategy']['fail-fast'] is False
step = next(step for step in jobs['commit-builds-summary']['steps'] if 'run' in step)
# One failed arch must not cancel its siblings: the summary above only
# has downloads to offer for the legs that were allowed to finish.
for name in native_builds(jobs).values():
assert jobs[name]['strategy']['fail-fast'] is False, name
step = next(step for step in jobs[summary_job]['steps'] if 'run' in step)
assert step['env']['RUN_URL'] == '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
assert step['env']['HERMES_BUNDLE_ENV_JSON'] == '${{ inputs.bundle_env }}'
@@ -130,27 +133,29 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
CLOUDFLARE_R2_SECRET_ACCESS_KEY='test-inert', CLOUDFLARE_R2_BUCKET='hermes-releases')
release = tmp_path / 'apps/desktop/release'
release.mkdir(parents=True)
jobs = _workflow()['jobs']
legs = native_builds(jobs)
gates = selection_gates(jobs)
summary_job = commit_summary(jobs)
producers = [
('build-win32-x64-commit', 'Stage Windows packages to R2', 'win32-x64', [
'HermesBundled-0.33.0-win-x64.msix']),
('build-win32-arm64-commit', 'Stage Windows packages to R2', 'win32-arm64', [
'HermesBundled-0.33.0-win-arm64.msix']),
('build-darwin-arm64-commit', 'Stage macOS packages and feed inputs to R2', 'darwin-arm64', [
(legs[('win32-x64', 'commit')], 'win32-x64', ['HermesBundled-0.33.0-win-x64.msix']),
(legs[('win32-arm64', 'commit')], 'win32-arm64', ['HermesBundled-0.33.0-win-arm64.msix']),
(legs[('darwin-arm64', 'commit')], 'darwin-arm64', [
'HermesBundled-0.33.0-mac-arm64.dmg', 'HermesBundled-0.33.0-mac-arm64.zip',
'HermesBundled-0.33.0-mac-arm64.zip.blockmap']),
('build-darwin-x64-commit', 'Stage macOS packages and feed inputs to R2', 'darwin-x64', [
(legs[('darwin-x64', 'commit')], 'darwin-x64', [
'HermesBundled-0.33.0-mac-x64.dmg', 'HermesBundled-0.33.0-mac-x64.zip',
'HermesBundled-0.33.0-mac-x64.zip.blockmap']),
('assemble-win32-bundle', 'Stage universal bundles to R2', 'windows-universal', [
'HermesBundled-0.33.0.0-win.msixbundle']),
(universal_assembler(jobs), 'windows-universal', ['HermesBundled-0.33.0.0-win.msixbundle']),
]
artifact_keys = set()
for job, name, target, names in producers:
for job, target, names in producers:
for file in release.iterdir():
file.unlink()
for filename in names:
(release / filename).write_bytes(f'transport fixture: {filename}'.encode())
result = shell_step(tmp_path, r2_server, job, name, {**env, 'TARGET': target})
result = shell_step(tmp_path, r2_server, '', '', {**env, 'TARGET': target},
script=stage_step(jobs[job])['run'])
assert result.returncode == 0, result.stdout + result.stderr
receipt_key = f'releases/commit/{sha}/handoff-{target}.json'
receipt = json.loads(r2_server.store[receipt_key][0])
@@ -160,15 +165,15 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
puts = [path for method, path, _ in r2_server.requests if method == 'PUT']
assert puts[-1].endswith(receipt_key)
termux_name = 'Stage the commit-build deb to R2'
termux_stage = stage_step(jobs[termux_builder(jobs)])['run']
before = dict(r2_server.store)
missing = shell_step(tmp_path, r2_server, 'termux-deb', termux_name, env)
missing = shell_step(tmp_path, r2_server, '', '', env, script=termux_stage)
assert missing.returncode != 0
assert r2_server.store == before
deb = tmp_path / 'termux-build/deb/hermes agent_0.33.0~commit.aaaaaaaaaaaa_aarch64.deb'
deb.parent.mkdir(parents=True)
deb.write_bytes(b'transport fixture, not a native Debian package')
staged = shell_step(tmp_path, r2_server, 'termux-deb', termux_name, env)
staged = shell_step(tmp_path, r2_server, '', '', env, script=termux_stage)
assert staged.returncode == 0, staged.stdout + staged.stderr
artifact_keys.add(f'releases/commit/{sha}/deb/{deb.name}')
receipt = json.loads(r2_server.store[f'releases/commit/{sha}/handoff-termux.json'][0])
@@ -176,14 +181,8 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
summary = tmp_path / 'summary.md'
summary_env = {**env, 'GITHUB_STEP_SUMMARY': str(summary), 'RELEASE_NEEDS': json.dumps({
'validate': {'result': 'success'}, 'build-win32-x64': {'result': 'success'},
'build-win32-arm64': {'result': 'success'}, 'build-darwin-arm64': {'result': 'success'},
'build-darwin-x64': {'result': 'success'},
'assemble-win32-bundle': {'result': 'success'},
'termux-deb': {'result': 'success'}, 'build-linux-x64': {'result': 'success'},
'build-linux-arm64': {'result': 'success'},
})}
result = shell_step(tmp_path, r2_server, 'commit-builds-summary',
name: {'result': 'success'} for name in needs_of(jobs[summary_job])})}
result = shell_step(tmp_path, r2_server, summary_job,
'Render the full expected-binary matrix', summary_env)
assert result.returncode == 0, result.stdout + result.stderr
text = summary.read_text(encoding='utf-8-sig')
@@ -207,7 +206,7 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
receipt_key = f'releases/commit/{sha}/handoff-win32-x64.json'
original = r2_server.store[receipt_key]
r2_server.store[receipt_key] = (b'not-json', '"invalid"')
failed = shell_step(tmp_path, r2_server, 'commit-builds-summary',
failed = shell_step(tmp_path, r2_server, summary_job,
'Render the full expected-binary matrix', summary_env)
assert failed.returncode != 0
assert summary.read_text(encoding='utf-8-sig') == text
@@ -218,8 +217,8 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
r2_server.store.pop(f'releases/commit/{sha}/handoff-darwin-x64.json')
summary.unlink()
summary_env['RELEASE_NEEDS'] = json.dumps({'validate': {'result': 'success'},
'build-darwin-x64': {'result': 'failure'}})
incomplete = shell_step(tmp_path, r2_server, 'commit-builds-summary',
gates['darwin-x64']: {'result': 'failure'}})
incomplete = shell_step(tmp_path, r2_server, summary_job,
'Render the full expected-binary matrix', summary_env)
assert incomplete.returncode == 0, incomplete.stdout + incomplete.stderr
assert 'failed: build-darwin-x64' in summary.read_text(encoding='utf-8-sig')
assert f"failed: {gates['darwin-x64']}" in summary.read_text(encoding='utf-8-sig')