Repository navigation
ci(nightly): every main and rc push run builds in its own group #25959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1e9d4ce
6e58526
94c503f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,14 +95,19 @@ concurrency: | |
| # hold one behind a saturated runner pool. Each lane has its own group, and | ||
| # a newer run may replace only a pending run. An in-flight nightly must always | ||
| # finish so a 42-minute macOS build is not discarded by the next push. | ||
| # Main NIGHTLY and nightly-next install as the same app, so their publishing | ||
| # runs must share one group. This keeps the run-derived Sparkle build number | ||
| # ordered across both feeds; the nightly-next floor check otherwise rejects | ||
| # an older run that finishes after a newer main publication. | ||
| # Every push run (main, rc/**) has its own group, keyed by its run id, so a | ||
| # newer tip builds alongside a running one instead of waiting behind it, and | ||
| # a pending tip is never replaced by the next push (Leo, 2026-10-24: "it is | ||
| # unacceptable to have nightlies >30min out of date"). Ordering is kept at | ||
| # publication, not here: the run-derived Sparkle build number grows with the | ||
| # run, the release-publish-<tag> job groups serialize the feed writers, and | ||
| # the publish step refuses a source older than the published one | ||
| # (auto-resume-nightly-notarization.yml's ancestry and RC version checks; | ||
| # nightly-next's cross-feed floor check refuses a build not above main's). | ||
| # Keep scheduled and manually requested cache seeds in different groups. A | ||
| # scheduled tick may replace an older scheduled tick, but must never cancel | ||
| # an explicit manual refresh that happens to be running at the same time. | ||
| group: nightly-build-${{ (github.ref_name == 'main' || github.ref_name == 'nightly-next') && 'nightly-shared' || github.ref_name }}-${{ github.event_name == 'workflow_dispatch' && inputs.promote_nightly_next_sha != '' && format('nightly-next-promote-{0}', github.run_id) || github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' && 'cache-seed-scheduled' || inputs.seed_only && 'cache-seed-manual' || github.event_name == 'workflow_dispatch' && github.event.inputs.build_only == 'true' && format('nightly-measure-{0}', github.run_id) || (github.event_name == 'workflow_dispatch' && github.event.inputs.fast == 'true' && 'fast' || 'full') }} | ||
| group: nightly-build-${{ (github.ref_name == 'main' || github.ref_name == 'nightly-next') && 'nightly-shared' || github.ref_name }}-${{ github.event_name == 'push' && format('push-{0}', github.run_id) || github.event_name == 'workflow_dispatch' && inputs.promote_nightly_next_sha != '' && format('nightly-next-promote-{0}', github.run_id) || github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' && 'cache-seed-scheduled' || inputs.seed_only && 'cache-seed-manual' || github.event_name == 'workflow_dispatch' && github.event.inputs.build_only == 'true' && format('nightly-measure-{0}', github.run_id) || (github.event_name == 'workflow_dispatch' && github.event.inputs.fast == 'true' && 'fast' || 'full') }} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| cancel-in-progress: false | ||
|
|
||
| permissions: {} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| #!/usr/bin/env python3 | ||
| """Print the newest Sparkle build (sparkle:version) an appcast lists. | ||
|
|
||
| Usage: live_appcast_build.py URL_OR_PATH | ||
| Exit 3 when the URL answers 404 (no feed published yet), 1 when the feed cannot | ||
| be read or lists no numeric build: callers fail closed on 1. The nightly | ||
| publication guards (auto-resume-nightly-notarization.yml) use it to refuse a | ||
| build older than the one the live feed serves: push runs build in parallel, | ||
| so an older run can reach publication after a newer one. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
| import urllib.error | ||
| import urllib.request | ||
| import xml.etree.ElementTree as ET | ||
|
|
||
|
|
||
| def newest_build(data: bytes) -> int | None: | ||
| root = ET.fromstring(data) | ||
| builds = [] | ||
| for item in root.iter(): | ||
| if item.tag.rsplit("}", 1)[-1] != "item": | ||
| continue | ||
| for child in item: | ||
| if child.tag.rsplit("}", 1)[-1] == "version" and (child.text or "").strip().isdigit(): | ||
| builds.append(int(child.text.strip())) | ||
| for enclosure in item: | ||
| if enclosure.tag.rsplit("}", 1)[-1] != "enclosure": | ||
| continue | ||
| for key, value in enclosure.attrib.items(): | ||
| if key.rsplit("}", 1)[-1] == "version" and value.strip().isdigit(): | ||
| builds.append(int(value.strip())) | ||
| return max(builds) if builds else None | ||
|
|
||
|
|
||
| def read(source: str) -> bytes: | ||
| if source.startswith(("https://", "http://")): | ||
| request = urllib.request.Request(source, headers={"User-Agent": "cmux-publication-guard/1", "Cache-Control": "no-cache"}) | ||
| with urllib.request.urlopen(request, timeout=30) as response: | ||
| return response.read() | ||
| with open(source, "rb") as handle: | ||
| return handle.read() | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> int: | ||
| if len(argv) != 2: | ||
| print(__doc__, file=sys.stderr) | ||
| return 2 | ||
| try: | ||
| build = newest_build(read(argv[1])) | ||
| except urllib.error.HTTPError as error: | ||
| print(f"could not read {argv[1]}: {error}", file=sys.stderr) | ||
| return 3 if error.code == 404 else 1 | ||
| except Exception as error: # noqa: BLE001 - any read or parse failure means "unknown" | ||
| print(f"could not read {argv[1]}: {error}", file=sys.stderr) | ||
| return 1 | ||
| if build is None: | ||
| print(f"{argv[1]} lists no numeric build", file=sys.stderr) | ||
| return 1 | ||
| print(build) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main(sys.argv)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| #!/usr/bin/env python3 | ||
| """live_appcast_build.py reads the newest build an appcast lists; the classic | ||
| publication guards refuse a build older than the live feed's and fail closed | ||
| when it cannot be read.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| SCRIPT = ROOT / "scripts" / "ci" / "live_appcast_build.py" | ||
| WORKFLOW = (ROOT / ".github" / "workflows" / "auto-resume-nightly-notarization.yml").read_text(encoding="utf-8") | ||
|
|
||
| FEED = """<?xml version="1.0"?> | ||
| <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"><channel> | ||
| <item><title>new</title><sparkle:version>2026102712345601</sparkle:version></item> | ||
| <item><title>old</title><enclosure url="x" sparkle:version="2026102612345601"/></item> | ||
| </channel></rss> | ||
| """ | ||
|
|
||
|
|
||
| def run(source: str) -> subprocess.CompletedProcess[str]: | ||
| return subprocess.run([sys.executable, str(SCRIPT), source], capture_output=True, text=True, check=False) | ||
|
|
||
|
|
||
| def test_reads_the_newest_build() -> None: | ||
| with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as feed: | ||
| feed.write(FEED) | ||
| out = run(feed.name) | ||
| assert out.returncode == 0 and out.stdout.strip() == "2026102712345601", out | ||
|
|
||
|
|
||
| def test_a_missing_feed_is_exit_3_and_a_server_error_exit_1() -> None: | ||
| import http.server | ||
| import threading | ||
|
|
||
| class Handler(http.server.BaseHTTPRequestHandler): | ||
| def do_GET(self): # noqa: N802 | ||
| self.send_response(404 if self.path.endswith("missing.xml") else 503) | ||
| self.end_headers() | ||
|
|
||
| def log_message(self, *args): | ||
| pass | ||
|
|
||
| server = http.server.HTTPServer(("127.0.0.1", 0), Handler) | ||
| threading.Thread(target=server.serve_forever, daemon=True).start() | ||
| try: | ||
| base = f"http://127.0.0.1:{server.server_port}" | ||
| assert run(f"{base}/missing.xml").returncode == 3 | ||
| assert run(f"{base}/broken.xml").returncode == 1 | ||
| finally: | ||
| server.shutdown() | ||
|
|
||
|
|
||
| def test_an_unreadable_or_empty_feed_is_unknown() -> None: | ||
| assert run("/nonexistent/appcast.xml").returncode == 1 | ||
| with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as feed: | ||
| feed.write("<rss><channel></channel></rss>") | ||
| assert run(feed.name).returncode == 1 | ||
|
|
||
|
|
||
| def guard_script(marker: str) -> str: | ||
| """The run: block of the auto-resume step whose script contains marker.""" | ||
| import yaml | ||
|
|
||
| document = yaml.safe_load(WORKFLOW) | ||
| for job in document["jobs"].values(): | ||
| for step in job.get("steps", []): | ||
| if marker == step.get("name") or marker in str(step.get("run", "")): | ||
| return step["run"] | ||
| raise AssertionError(f"no step contains {marker!r}") | ||
|
|
||
|
|
||
| def run_guard(script: str, helper_rc: int, helper_out: str, env: dict) -> subprocess.CompletedProcess[str]: | ||
| """Run a guard block with a stub live_appcast_build.py that answers (rc, out) for https URLs.""" | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| work = Path(tmp) | ||
| (work / "scripts" / "ci").mkdir(parents=True) | ||
| (work / "scripts" / "ci" / "live_appcast_build.py").write_text( | ||
| "import sys\n" | ||
| f"if sys.argv[1].startswith('https://'):\n print({helper_out!r}) if {helper_rc} == 0 else None\n sys.exit({helper_rc})\n" | ||
| "print(open(sys.argv[1]).read().strip())\n", encoding="utf-8") | ||
| # The release writer: records that it ran, and with what. | ||
| (work / "scripts" / "ci" / "publish-release-assets.py").write_text( | ||
| "import sys\nprint('PUBLISHED', *sys.argv[1:])\n", encoding="utf-8") | ||
| (work / "nightly-deltas").mkdir() | ||
| (work / "nightly-deltas" / "appcast-arm64.xml").write_text(env.get("OWN", "100"), encoding="utf-8") | ||
| (work / "nightly-deltas" / "x.delta").write_text("d", encoding="utf-8") | ||
| (work / "gh").write_text("#!/bin/sh\necho \"$*\" >> \"$GH_LOG\"\n[ \"$1\" = api ] && echo \"$SOURCE_HEAD_SHA\"\nexit 0\n", encoding="utf-8") | ||
| (work / "gh").chmod(0o755) | ||
| (work / "python3").write_text(f"#!/bin/sh\nexec {sys.executable} \"$@\"\n", encoding="utf-8") | ||
| (work / "python3").chmod(0o755) | ||
| out = work / "out" | ||
| out.write_text("", encoding="utf-8") | ||
| full_env = {"PATH": f"{work}:/usr/bin:/bin", "GITHUB_OUTPUT": str(out), "GITHUB_ENV": str(work / "env"), | ||
| "RUNNER_TEMP": str(work), "GH_LOG": str(work / "gh.log"), "RELEASE_TAG": "nightly", "GITHUB_REPOSITORY": "o/r", | ||
| "SOURCE_HEAD_SHA": "a" * 40, **env} | ||
| result = subprocess.run(["bash", "-c", script], cwd=work, env=full_env, capture_output=True, text=True, check=False) | ||
| result.stdout += "\nOUTPUT:" + out.read_text(encoding="utf-8") | ||
| result.stdout += "\nGH:" + ((work / "gh.log").read_text(encoding="utf-8") if (work / "gh.log").exists() else "") | ||
| return result | ||
|
|
||
|
|
||
| def test_the_delta_republish_fails_closed_on_an_unread_feed() -> None: | ||
| script = guard_script("Skipping stale delta result: the live") | ||
| # The live feed serves this build: the deltas go out. | ||
| ok = run_guard(script, 0, "100", {"OWN": "100"}) | ||
| assert ok.returncode == 0 and "publish=true" in ok.stdout and "--replace-feeds" in ok.stdout, ok | ||
| # A newer build is live, or the feed cannot be read: nothing is written. | ||
| for rc, out in ((0, "101"), (1, ""), (3, "")): | ||
| skipped = run_guard(script, rc, out, {"OWN": "100"}) | ||
| assert skipped.returncode == 0 and "publish=false" in skipped.stdout and "--replace-feeds" not in skipped.stdout, (rc, skipped) | ||
|
|
||
|
|
||
| def test_the_publish_guard_refuses_an_older_build_and_an_unread_feed() -> None: | ||
| script = guard_script("Reject stale continuation before publication") | ||
| live = script[script.index("# Fail closed: only a 404"):] | ||
| live = live[:live.index("if grep -Fxq 'already_published=true'")] | ||
| prelude = "set -euo pipefail\n: > \"$RUNNER_TEMP/publication-guard\"\n" | ||
| newer = run_guard(prelude + live + "grep -Fxq already_published=true \"$RUNNER_TEMP/publication-guard\" && echo REFUSED\n", 0, "200", {"NIGHTLY_BUILD": "100"}) | ||
| assert "REFUSED" in newer.stdout, newer | ||
| older = run_guard(prelude + live + "grep -Fxq already_published=true \"$RUNNER_TEMP/publication-guard\" || echo GOES\n", 0, "50", {"NIGHTLY_BUILD": "100"}) | ||
| assert "GOES" in older.stdout, older | ||
| first = run_guard(prelude + live + "echo GOES\n", 3, "", {"NIGHTLY_BUILD": "100"}) | ||
| assert "GOES" in first.stdout, first | ||
| unread = run_guard(prelude + live + "echo GOES\n", 1, "", {"NIGHTLY_BUILD": "100"}) | ||
| assert unread.returncode != 0 and "GOES" not in unread.stdout, unread | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| for name, value in list(globals().items()): | ||
| if name.startswith("test_"): | ||
| value() | ||
| print("PASS: live appcast build guard") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: manaflow-ai/cmux
Length of output: 25195
Script executed:
Repository: manaflow-ai/cmux
Length of output: 20214
Script executed:
Repository: manaflow-ai/cmux
Length of output: 10844
Script executed:
Repository: manaflow-ai/cmux
Length of output: 10827
Script executed:
set -eu nl -ba scripts/ci/upload-r2-object.pyRepository: manaflow-ai/cmux
Length of output: 10939
Web query:
Cloudflare R2 official documentation consistency guarantees GET after successful object upload 404Result:
Web query:
site:developers.cloudflare.com/cache Cache-Control no-cache request header Cloudflare cached 404 revalidateResult:
Require an origin check before accepting a feed 404.
live_appcast_build.pymaps a 404 to exit 3, and the guard treats that as “no feed yet.” Cloudflare documents that its cache may retain a 404 after the R2 object is uploaded. A newer run can therefore upload the feed, fail beforegh release editrecords metadata, and leave an older run free to overwrite it with a normal R2PUT.Require an origin-authenticated R2 existence check, or a serialized or conditional write, before allowing a 404 to pass. The release-body marker cannot establish first publication because it is recorded after the feed upload.