fix: publish tagged build diagnostics after release failures
This commit is contained in:
24
.github/workflows/desktop-bundled-release.yml
vendored
24
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
79
tests/ci/test_tag_builds_summary.py
Normal file
79
tests/ci/test_tag_builds_summary.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user