fix: publish tagged build diagnostics after release failures

This commit is contained in:
ethernet
2026-09-11 20:00:42 -04:00
parent c4e2d937f7
commit de5615d2b8
4 changed files with 219 additions and 37 deletions

View File

@@ -1607,7 +1607,11 @@ jobs:
builds-table:
name: Render the release builds table
needs: [validate, build-win32, build-darwin, build-linux, publish-win32-updater, publish-darwin-updater, termux-deb]
if: inputs.build_commit == '' && inputs.upload_release == true && inputs.release-phase == ''
# Failed builds still get a per-tag diagnostic page. The renderer only
# advances the channel landing page when every prerequisite succeeded.
if: |
always() && inputs.build_commit == '' && inputs.upload_release == true && inputs.release-phase == ''
&& needs.validate.result == 'success' && needs.validate.outputs.sha != ''
runs-on: ubuntu-24.04
environment: release-signing
steps:
@@ -1620,19 +1624,29 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
HERMES_PAYLOAD_TAG: ${{ inputs.tag }}
RELEASE_NEEDS: ${{ toJSON(needs) }}
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }}
CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }}
run: |
if ! gh release view "$HERMES_PAYLOAD_TAG" >/dev/null 2>&1; then
echo "::error::no release exists for $HERMES_PAYLOAD_TAG — cannot render the builds table"
exit 1
fi
python3 scripts/render-builds-table.py \
--tag "$HERMES_PAYLOAD_TAG" --repo "$GITHUB_REPOSITORY"
# publish-canary consumes this job's result. Publish diagnostics first,
# then retain the original all-needs-success release gate.
- name: Preserve release success gate
env:
RELEASE_NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json, os
from scripts.releases.stable import require_success
needs = json.loads(os.environ['RELEASE_NEEDS'])
require_success(needs, list(needs))
PY
commit-builds-summary:
name: Commit build summary (every binary, built or not)
needs: [validate, build-win32, build-darwin, build-linux, publish-win32-updater, termux-deb]

View File

@@ -2,8 +2,8 @@
"""Render the release download tables into <!-- HERMES_BUILDS_TABLE -->, and
the same rows as standalone pages in the bucket.
Runs as the LAST job of desktop-bundled-release.yml, after every matrix
leg has uploaded, and edits the GitHub release body in place. The tables
Runs after the build jobs of desktop-bundled-release.yml finish, including
failed legs, and edits the GitHub release body in place. The tables
are built from the bucket's ACTUAL object names (scripts/releases/r2.py
list --prefix releases/tag/<tag>/), filtered to the tag's exact version —
a missing artifact shows up as a missing row, never a dead link. The
@@ -14,9 +14,9 @@ URL (CLOUDFLARE_R2_PUBLIC_URL / --r2-base-url).
Every run also publishes the same rows as a tiny HTML page in the bucket,
so a build can be read straight from the download origin:
releases/<channel>/index.html replaced by each release; the channel
page holds the latest stable or canary
builds for every variant
releases/tag/<tag>/index.html every admitted tag, including failed builds
releases/<channel>/index.html latest successful stable or canary build;
failed prerequisites leave it unchanged
releases/commit/<sha>/index.html commit mode: every expected binary of
one commit build, built or not
@@ -116,7 +116,8 @@ def table_rows(assets_by_app: dict) -> list[tuple[str, list[tuple[str, str, str,
return sections
def render_tables(assets_by_app: dict, base_url: str) -> str:
def render_tables(assets_by_app: dict, base_url: str,
incomplete_jobs: list[str] | None = None) -> str:
"""The replacement block: marker + tables + end marker."""
sections = []
for title, rows in table_rows(assets_by_app):
@@ -128,7 +129,10 @@ def render_tables(assets_by_app: dict, base_url: str) -> str:
f"### {title}\n\n| OS | Architecture | Download |\n|---|---|---|\n" + "\n".join(lines)
)
if not sections:
return ""
sections.append("No downloadable artifacts were staged for this build.")
if incomplete_jobs:
sections.insert(0, "> **Build incomplete.** Jobs not successful: "
+ ", ".join(incomplete_jobs) + ". The channel page was not advanced.")
return MARKER + "\n## Downloads\n\n" + "\n\n".join(sections) + "\n" + END_MARKER
@@ -297,6 +301,24 @@ def failed_legs_from_release_needs(release_needs_json: str | None) -> list[str]:
if isinstance(info, dict) and info.get("result") not in ("success", "skipped"))
def incomplete_release_jobs(release_needs_json: str | None) -> list[str]:
"""Tag pages may describe failures; channel pointers require successful jobs.
Unlike a commit summary, skipped release prerequisites are incomplete too.
Standalone invocations without workflow results retain their existing behavior.
"""
if release_needs_json is None:
return []
try:
needs = json.loads(release_needs_json)
if not isinstance(needs, dict) or not needs:
raise ValueError("missing workflow results")
return [f"{name} ({info.get('result', 'unknown')})" for name, info in sorted(needs.items())
if info.get("result") != "success"]
except (ValueError, TypeError, AttributeError):
return ["workflow results unavailable"]
# ---------------------------------------------------------------------------
# Bucket pages: the same rows as the tables, served from the download origin
# ---------------------------------------------------------------------------
@@ -336,15 +358,23 @@ def _link(url: str) -> str:
return f'<a href="{html.escape(url, quote=True)}">'
def render_page(tag: str, assets_by_app: dict, base_url: str) -> str:
"""The channel page: the release-body download table as HTML."""
def render_page(tag: str, assets_by_app: dict, base_url: str,
incomplete_jobs: list[str] | None = None) -> str:
"""The tag/channel page: the release-body download table as HTML."""
channel = r2.channel_for_tag(tag)
body = [
f"<h1>Hermes Desktop {channel} builds</h1>",
f"<p>Release <code>{html.escape(tag)}</code>. Only objects this release "
"actually staged in the bucket are listed.</p>",
]
for title, rows in table_rows(assets_by_app):
if incomplete_jobs:
body.append("<p><strong>Build incomplete.</strong> Jobs not successful: "
+ html.escape(", ".join(incomplete_jobs))
+ ". The channel page was not advanced.</p>")
sections = table_rows(assets_by_app)
if not sections:
body.append("<p>No downloadable artifacts were staged for this build.</p>")
for title, rows in sections:
body.append(f"<h2>{html.escape(title)}</h2>")
body.extend(_table(
("OS", "Architecture", "Download"),
@@ -513,6 +543,26 @@ def main() -> int:
if not args.tag:
parser.error("--tag is required (or use --summary-commit for a commit build summary)")
incomplete: list[str] = []
assets: dict = {}
if args.pending_run_url:
block = render_pending(args.pending_run_url)
names: list[str] = []
else:
if not args.r2_base_url:
print("::error::--r2-base-url (or CLOUDFLARE_R2_PUBLIC_URL) is required to render the tables")
return 1
names = r2_object_names(args.tag)
assets = parse_assets(names)
incomplete = incomplete_release_jobs(os.environ.get("RELEASE_NEEDS"))
block = render_tables(assets, args.r2_base_url, incomplete)
# A failed run still owns its tag page, never the channel pointer
# consumed by source updates. Missing artifacts never become links.
if not args.dry_run:
write_page(r2.staging_key_for(args.tag, "index.html"),
render_page(args.tag, assets, args.r2_base_url, incomplete), args.r2_base_url)
# Keep the per-tag diagnostic page even when GitHub cannot supply a draft.
view = subprocess.run(
["gh", "release", "view", args.tag, "--repo", args.repo,
"--json", "body"],
@@ -523,24 +573,8 @@ def main() -> int:
return 1
release = json.loads(view.stdout)
body = release.get("body") or ""
if args.pending_run_url:
block = render_pending(args.pending_run_url)
names: list[str] = []
else:
if not args.r2_base_url:
print("::error::--r2-base-url (or CLOUDFLARE_R2_PUBLIC_URL) is required to render the tables")
return 1
names = r2_object_names(args.tag)
assets = parse_assets(names)
block = render_tables(assets, args.r2_base_url)
if not block:
print("::warning::no table-shaped assets for this tag in the bucket; leaving the body unchanged")
return 0
# The channel page is independent of the release body (and of the
# marker), so it is published even if the body cannot be edited.
if not args.dry_run:
write_channel_page(args.tag, assets, args.r2_base_url)
if not args.pending_run_url and not args.dry_run and not incomplete and names:
write_channel_page(args.tag, assets, args.r2_base_url)
if MARKER not in body:
print("::warning::release body has no HERMES_BUILDS_TABLE marker; leaving it unchanged")
return 0

View File

@@ -0,0 +1,79 @@
"""Run the tagged-build summary step against a disposable R2 transport."""
import json
import shlex
import sys
import pytest
from tests.ci.test_commit_build_staging import shell_step
from tests.ci.test_desktop_release_tag_admission import _workflow
from tests.scripts.test_release_r2 import r2_server # noqa: F401
@pytest.mark.parametrize("gh_available", [False, True])
def test_admitted_failure_publishes_tag_info_without_promoting_channel(tmp_path, r2_server, gh_available):
job = _workflow()["jobs"]["builds-table"]
gate = job["if"]
# This signing-context observer must survive failed needs without admitting
# rejected tags, commit builds, dry runs, or stable release phases.
for condition in ("always()", "needs.validate.result == 'success'",
"needs.validate.outputs.sha != ''", "inputs.build_commit == ''",
"inputs.upload_release == true", "inputs.release-phase == ''"):
assert condition in gate
render = next(step for step in job["steps"] if step.get("name") == "Render")
assert render["env"]["RELEASE_NEEDS"] == "${{ toJSON(needs) }}"
tag = "v0.28.0-canary.20260818101010"
base = f"http://127.0.0.1:{r2_server.server_port}/hermes-releases"
channel_key = "releases/canary/index.html"
previous = b'<meta name="hermes-build" content="v0.27.0-canary.20260817101010">'
r2_server.store[channel_key] = (previous, "text/html")
helper = tmp_path / "bin"
helper.mkdir()
gh = helper / "gh"
gh.write_text(
f"#!{sys.executable}\n"
"import json, sys\n"
f"sys.exit(1) if not {gh_available!r} else None\n"
"if sys.argv[1:3] == ['release', 'view']:\n"
" print(json.dumps({'body': '<!-- HERMES_BUILDS_TABLE -->'}))\n"
"elif sys.argv[1:3] == ['release', 'edit']:\n"
" assert 'Build incomplete' in sys.stdin.read()\n"
"else: raise AssertionError(sys.argv)\n",
encoding="utf-8",
)
# Use a shell trampoline for interpreters whose full Nix path exceeds
# the host's shebang limit.
driver = helper / "gh-driver.py"
gh.rename(driver)
gh.write_text(f'#!/bin/sh\nexec {shlex.quote(sys.executable)} {shlex.quote(str(driver))} "$@"\n')
gh.chmod(0o755)
needs = {name: {"result": "success"} for name in job["needs"]}
needs["build-win32"]["result"] = "failure"
needs["publish-win32-updater"]["result"] = "skipped"
result = shell_step(tmp_path, r2_server, "builds-table", "Render", {
"HERMES_PAYLOAD_TAG": tag, "GITHUB_REPOSITORY": "o/r",
"RELEASE_NEEDS": json.dumps(needs), "CLOUDFLARE_R2_PUBLIC_URL": base,
"CLOUDFLARE_R2_ACCOUNT_ID": "loopback", "CLOUDFLARE_R2_ACCESS_KEY_ID": "test-inert",
"CLOUDFLARE_R2_SECRET_ACCESS_KEY": "test-inert", "CLOUDFLARE_R2_BUCKET": "hermes-releases",
})
assert (result.returncode == 0) is gh_available, result.stdout + result.stderr
key = f"releases/tag/{tag}/index.html"
assert key in r2_server.store, result.stdout + result.stderr
page = r2_server.store[key][0].decode()
assert "Build incomplete" in page
assert "build-win32 (failure)" in page and "publish-win32-updater (skipped)" in page
assert "No downloadable artifacts" in page and 'href="' not in page
assert f"{base}/{key}" in result.stdout
assert r2_server.store[channel_key][0] == previous
assert set(r2_server.store) == {channel_key, key}
# publish-canary depends on this job's success; rendering diagnostics must
# not turn a failed Termux prerequisite into permission to publish a draft.
needs = {name: {"result": "success"} for name in job["needs"]}
for state in ("failure", "skipped", "success"):
needs["termux-deb"]["result"] = state
checked = shell_step(tmp_path, r2_server, "builds-table", "Preserve release success gate", {
"RELEASE_NEEDS": json.dumps(needs),
})
assert (checked.returncode == 0) is (state == "success"), checked.stdout + checked.stderr

View File

@@ -17,6 +17,11 @@ import re
import subprocess
import sys
from pathlib import Path
from urllib.request import urlopen
import pytest
from tests.scripts.test_release_r2 import r2_server # noqa: F401
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "render-builds-table.py"
_SPEC = importlib.util.spec_from_file_location("render_builds_table", _SCRIPT)
@@ -221,6 +226,10 @@ class TestTagRunPublishesThePage:
raise AssertionError(argv)
def test_page_upload_key_and_bytes(self, monkeypatch, capsys, tmp_path):
monkeypatch.setenv("RELEASE_NEEDS", json.dumps({
"validate": {"result": "success"}, "build-win32": {"result": "success"},
"publish-win32-updater": {"result": "success"},
}))
uploads: list[tuple[str, str, bool]] = []
monkeypatch.setattr(rbt.r2, "list_objects", lambda prefix="": {"keys": self.KEYS})
monkeypatch.setattr(rbt, "existing_page", lambda key: None)
@@ -231,8 +240,10 @@ class TestTagRunPublishesThePage:
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r", "--r2-base-url", BASE_URL,
])
assert rbt.main() == 0
assert len(uploads) == 1
key, page, key_is_full = uploads[0]
assert len(uploads) == 2
assert uploads[0][0] == f"releases/tag/{self.TAG}/index.html"
key, page, key_is_full = uploads[1]
assert uploads[0][1] == page
# Canary tag → the canary channel page, as a full key (not a tag name).
assert key == "releases/canary/index.html" and key_is_full
assert rbt.recorded_build(page) == self.TAG
@@ -244,7 +255,7 @@ class TestTagRunPublishesThePage:
assert "v0.27.0" not in page and ".msixbundle" not in page
assert f"✓ Page {BASE_URL}/releases/canary/index.html" in capsys.readouterr().out
def test_dry_run_and_stale_tags_write_nothing(self, monkeypatch, tmp_path):
def test_stale_tags_keep_their_own_page_and_dry_runs_write_nothing(self, monkeypatch, tmp_path):
uploads: list[str] = []
monkeypatch.setattr(rbt.r2, "list_objects", lambda prefix="": {"keys": self.KEYS})
monkeypatch.setattr(rbt.r2, "put", lambda **kwargs: uploads.append(kwargs["key"]))
@@ -254,7 +265,9 @@ class TestTagRunPublishesThePage:
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r", "--r2-base-url", BASE_URL,
])
assert rbt.main() == 0 # stale tag: page untouched
assert rbt.main() == 0 # stale tag: channel untouched
assert uploads == [f"releases/tag/{self.TAG}/index.html"]
uploads.clear()
monkeypatch.setattr(rbt, "existing_page", lambda key: None)
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r",
@@ -262,3 +275,45 @@ class TestTagRunPublishesThePage:
])
assert rbt.main() == 0 # dry run: nothing published
assert uploads == []
@pytest.mark.parametrize("asset_present", [False, True])
@pytest.mark.parametrize("result", ["failure", "cancelled", "skipped"])
def test_incomplete_tag_is_readable_without_replacing_last_good_channel(
self, monkeypatch, r2_server, asset_present, result,
):
base = f"http://127.0.0.1:{r2_server.server_port}/hermes-releases"
channel_key = "releases/canary/index.html"
previous = rbt.render_page("v0.27.0-canary.20260817101010", {}, base).encode()
r2_server.store[channel_key] = (previous, "text/html")
if asset_present:
r2_server.store[self.KEYS[0]] = (b"transport fixture", "application/octet-stream")
edits = []
def gh(argv, **kwargs):
if argv[:3] == ["gh", "release", "edit"]:
edits.append(kwargs["input"])
return self._gh(argv, **kwargs)
monkeypatch.setattr(rbt.subprocess, "run", gh)
monkeypatch.setenv("RELEASE_NEEDS", json.dumps({
"validate": {"result": "success"},
"build-win32": {"result": "success"},
"publish-win32-updater": {"result": result},
}))
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--r2-base-url", base,
])
assert rbt.main() == 0
tag_key = f"releases/tag/{self.TAG}/index.html"
assert tag_key in r2_server.store
with urlopen(f"{base}/{tag_key}", timeout=5) as response:
page = response.read().decode()
assert rbt.recorded_build(page) == self.TAG
assert "Build incomplete" in page and "Build incomplete" in edits[0]
assert f"publish-win32-updater ({result})" in page
assert f"publish-win32-updater ({result})" in edits[0]
links = re.findall(r'href="([^"]+)"', page)
assert links == ([f"{base}/{self.KEYS[0]}"] if asset_present else [])
assert r2_server.store[channel_key][0] == previous
if not asset_present:
assert "No downloadable artifacts" in page