diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 4381bd7dcc..3242870be4 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -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] diff --git a/scripts/render-builds-table.py b/scripts/render-builds-table.py index 200f594e15..6f30d13510 100644 --- a/scripts/render-builds-table.py +++ b/scripts/render-builds-table.py @@ -2,8 +2,8 @@ """Render the release download tables into , 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//), 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//index.html replaced by each release; the channel - page holds the latest stable or canary - builds for every variant + releases/tag//index.html every admitted tag, including failed builds + releases//index.html latest successful stable or canary build; + failed prerequisites leave it unchanged releases/commit//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'' -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"

Hermes Desktop {channel} builds

", f"

Release {html.escape(tag)}. Only objects this release " "actually staged in the bucket are listed.

", ] - for title, rows in table_rows(assets_by_app): + if incomplete_jobs: + body.append("

Build incomplete. Jobs not successful: " + + html.escape(", ".join(incomplete_jobs)) + + ". The channel page was not advanced.

") + sections = table_rows(assets_by_app) + if not sections: + body.append("

No downloadable artifacts were staged for this build.

") + for title, rows in sections: body.append(f"

{html.escape(title)}

") 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 diff --git a/tests/ci/test_tag_builds_summary.py b/tests/ci/test_tag_builds_summary.py new file mode 100644 index 0000000000..abc1abdb9b --- /dev/null +++ b/tests/ci/test_tag_builds_summary.py @@ -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'' + 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': ''}))\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 diff --git a/tests/scripts/test_render_builds_table.py b/tests/scripts/test_render_builds_table.py index 52da6e1154..dcc2c33fd9 100644 --- a/tests/scripts/test_render_builds_table.py +++ b/tests/scripts/test_render_builds_table.py @@ -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