Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/nightly-step-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
"verify-final-dmg-nightly": {
"Initialize cmux-next notices submodule": {"plumbing": true}, "Select Xcode": {"plumbing": true},
"Verify final DMG artifact": {"nightly_only": "statically verifies the notarized stapled final DMG and records its exact digest without launching app code"},
"Apple's checks of a pool-signed DMG and the app in it": {"nightly_only": "Apple's tools on the notarized release-pool DMG and its app; only a release run has one"},
"Bundle size budget": {"pr": "cmux-next.yml:checks:Script tests (pin-cmux-tui, embed-cef, ensure-cef store)"}},
"signer-build": {"Set up Rust": {"plumbing": true},
"Build the signer": {"nightly_only": "builds the pinned release signer; the build script's pins are tested by tests/test_release_signer_scripts.py"}},
Expand All @@ -203,9 +204,6 @@
"The signed-app checks": {"nightly_only": "the checks build-sign-notarize-nightly runs on its signed app, on the release-pool build"},
"Build the unsigned DMG": {"nightly_only": "packages the notarized release-pool app with create-dmg, as notarize-nightly-dmg.sh does"}},
"signer-dmg": {"Install the signer": {"pr": "ci-guards.yml:workflow-guard-tests:Validate the release signer scripts"}, "Sign, notarize and staple the DMG": {"pr": "ci-guards.yml:workflow-guard-tests:Validate the release signer scripts"}, "Write the appcast": {"pr": "ci-guards.yml:workflow-guard-tests:Validate the release signer scripts"}},
"signer-verify": {"Initialize cmux-next notices submodule": {"plumbing": true}, "Select Xcode": {"plumbing": true},
"The final DMG and the app in it": {"nightly_only": "Apple's tools on the notarized release-pool DMG and its app; only a release run has one"},
"The appcast's Sparkle signature over the final DMG": {"pr": "ci-guards.yml:workflow-guard-tests:Validate the release signer scripts"}},
"find-nightly-next-recovery": {
"Wait through GitHub API rate limits in this job's gh calls": {"pr": "ci-guards.yml:workflow-guard-tests:Validate nightly publish-path gh calls wait through a rate limit"},

Expand Down
101 changes: 26 additions & 75 deletions .github/workflows/nightly.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/ci/nightly-steps/signer-apple-checks.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# nightly.yml signer-dmg-image and signer-verify: Apple's checks of a cmux NEXT
# nightly.yml signer-dmg-image and verify-final-dmg-nightly (signer-final-dmg-checks.sh): Apple's checks of a cmux NEXT
# app the release runner pool signed, on a Mac with no secrets.
#
# signer-apple-checks.sh <app>
Expand Down
20 changes: 20 additions & 0 deletions scripts/ci/nightly-steps/signer-final-dmg-checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# nightly.yml verify-final-dmg-nightly: "Apple's checks of a pool-signed DMG
# and the app in it". The DMG signer-dmg signed and notarized: its signature,
# Gatekeeper's notarized source, Apple's checks of the app inside
# (signer-apple-checks.sh: stapler, spctl, syspolicy_check and the copied
# Computer Use helper), and the appcast's Sparkle signature against the pinned
# key. "Verify final DMG artifact" has already checked the staples and the bytes.
set -euo pipefail
: "${CHANNEL_DMG_PREFIX:?}" "${NIGHTLY_VARIANT:?}" "${CHANNEL_APP_NAME:?}" "${NIGHTLY_BUILD:?}" "${NIGHTLY_NEXT_SPARKLE_PUBLIC_KEY:?}"
dmg="final-dmg/${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}.dmg"
immutable="final-dmg/${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-${NIGHTLY_BUILD}.dmg"
/usr/bin/codesign --verify --verbose=2 "$dmg"
spctl -a -vv -t open --context context:primary-signature "$dmg" 2>&1 | tee "$RUNNER_TEMP/spctl-dmg.txt"
grep -qx 'source=Notarized Developer ID' "$RUNNER_TEMP/spctl-dmg.txt"
python3 -I scripts/ci/release-signer/verify_appcast_item.py "final-dmg/appcast-${NIGHTLY_VARIANT}.xml" \
"$immutable" "$NIGHTLY_NEXT_SPARKLE_PUBLIC_KEY" "$NIGHTLY_BUILD" | tee -a "$GITHUB_STEP_SUMMARY"
mnt="$(mktemp -d)"
hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mnt" >/dev/null
trap 'hdiutil detach "$mnt" >/dev/null 2>&1 || hdiutil detach -force "$mnt" >/dev/null 2>&1 || true' EXIT
bash scripts/ci/nightly-steps/signer-apple-checks.sh "$mnt/$CHANNEL_APP_NAME.app"
2 changes: 1 addition & 1 deletion scripts/ci/nightly_build_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

`nightly_build_runner.py verify` prints only `owned`, never the dedicated builder (it keeps
that Mac for the next tip's build): pick-nightly-sign-runner's route for the secret-free
verify jobs (verify-signed-nightly, verify-final-dmg-nightly, signer-dmg-image, signer-verify),
verify jobs (verify-signed-nightly, verify-final-dmg-nightly, signer-dmg-image),
by the same owned rule as the build. Until 2026-10-27 that step ran the pull request pool picker
(e2e_runner_pool.py), which named a Blacksmith macOS 15 label in run 40515681971 while owned
side runners were idle, so verify queued about 1.5 h on Blacksmith.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_nightly_next_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def test_publish_runs_for_an_accepted_own_or_recovered_build(self):
condition = workflow()["jobs"]["publish-nightly"]["if"]
self.assertTrue(condition.startswith("!cancelled() && needs.decide.result == 'success' && "), condition)
self.assertIn(
"(((needs.build-sign-notarize-nightly.result == 'success' || needs.signer-verify.result == 'success') && needs.build-sign-notarize-nightly.outputs.notary_pending != 'true' && needs.verify-signed-nightly.result == 'success' && needs.verify-final-dmg-nightly.result == 'success') || (needs.recover-nightly-next-notarization.outputs.accepted == 'true' && needs.verify-recovered-nightly-next.result == 'success'))",
"(((needs.build-sign-notarize-nightly.result == 'success' || needs.signer-dmg.result == 'success') && needs.build-sign-notarize-nightly.outputs.notary_pending != 'true' && needs.verify-signed-nightly.result == 'success' && needs.verify-final-dmg-nightly.result == 'success') || (needs.recover-nightly-next-notarization.outputs.accepted == 'true' && needs.verify-recovered-nightly-next.result == 'success'))",
condition,
)

Expand Down
5 changes: 2 additions & 3 deletions tests/test_nightly_sign_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@
"verify-nightly-identity-legacy": "needs.legacy-identity-target.outputs.sign_runner",
# Signs the Sparkle deltas (the release environment), after the feed is published.
"generate-nightly-deltas": "needs.pick-nightly-sign-runner.outputs.runner",
# The release-pool path's Mac jobs: the DMG image and Apple's checks, no secrets.
# The release-pool path's one Mac job: Apple's checks and the DMG image, no secrets.
"signer-dmg-image": "needs.pick-nightly-sign-runner.outputs.runner",
"signer-verify": "needs.pick-nightly-sign-runner.outputs.runner",
}
# Each picker's re-run label, the other Blacksmith macOS 26 size.
ALTERNATES = {
Expand Down Expand Up @@ -197,7 +196,7 @@ class OwnedVerify(unittest.TestCase):
(Leo, 2026-10-24); the signing jobs, which hold the keys, stay on Blacksmith.
"""

OWNED_JOBS = ("verify-signed-nightly", "verify-final-dmg-nightly", "signer-dmg-image", "signer-verify")
OWNED_JOBS = ("verify-signed-nightly", "verify-final-dmg-nightly", "signer-dmg-image")
OWNED = "needs.pick-nightly-sign-runner.outputs.owned"

def test_verify_jobs_read_the_owned_pick_on_attempt_one_only(self) -> None:
Expand Down
58 changes: 32 additions & 26 deletions tests/test_nightly_signer_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
job as their fallback, and the secrets kept in the release-pool jobs; and
verify_appcast_item.py checks the Sparkle item against the pinned key.

Only signer-verify, after every check, hands output on, under the names the
macOS signer job uses. That job runs whenever signer-verify did not pass, so a
failed, skipped or not-opted-in pool run signs as before, and exactly one of
the two produces what verify-signed-nightly, verify-final-dmg-nightly and
publish-nightly read.
Only signer-dmg, after the DMG is notarized and its appcast checked, hands
output on, under the names the macOS signer job uses. That job runs whenever
signer-dmg did not pass, so a failed, skipped or not-opted-in pool run signs as
before, and exactly one of the two produces what verify-signed-nightly,
verify-final-dmg-nightly and publish-nightly read. The pool path has one Mac job
(signer-dmg-image) before that decision; the final DMG's Mac checks run in
verify-final-dmg-nightly, which publish needs anyway.
"""

from __future__ import annotations
Expand All @@ -30,7 +32,7 @@

WORKFLOW = yaml.safe_load(nightly_text())
JOBS = WORKFLOW["jobs"]
SIGNER_JOBS = ("signer-build", "signer-app", "signer-dmg-image", "signer-dmg", "signer-verify")
SIGNER_JOBS = ("signer-build", "signer-app", "signer-dmg-image", "signer-dmg")
SECRET_JOBS = {"signer-app", "signer-dmg"}
VERIFY = ROOT / "scripts/ci/release-signer/verify_appcast_item.py"
TIP_APPCAST = ROOT / "scripts/ci/tip_appcast_linux.py"
Expand All @@ -55,37 +57,37 @@ def test_only_the_input_or_the_variable_turns_them_on(self):
self.assertNotIn("always()", str(JOBS[name].get("if", "")))

def test_the_macos_signer_is_the_fallback_and_one_path_hands_on(self):
signed = "(needs.build-sign-notarize-nightly.result == 'success' || needs.signer-verify.result == 'success')"
signed = "(needs.build-sign-notarize-nightly.result == 'success' || needs.signer-dmg.result == 'success')"
fallback = JOBS["build-sign-notarize-nightly"]
self.assertIn("signer-verify", needs(fallback))
self.assertIn("signer-dmg", needs(fallback))
self.assertTrue(fallback["if"].startswith("!cancelled() && "))
self.assertIn("needs.signer-verify.result != 'success'", fallback["if"])
self.assertIn("needs.signer-dmg.result != 'success'", fallback["if"])
# Its own status check replaces the implicit success(), so it names every other need's success.
for need in needs(fallback):
if need != "signer-verify":
if need != "signer-dmg":
self.assertIn(f"needs.{need}.result == 'success'", fallback["if"])
for name in ("verify-signed-nightly", "verify-final-dmg-nightly", "publish-nightly"):
with self.subTest(job=name):
self.assertIn("signer-verify", needs(JOBS[name]))
self.assertIn("signer-dmg", needs(JOBS[name]))
self.assertIn(signed, JOBS[name]["if"])
self.assertNotRegex(JOBS[name]["if"], r"\(needs\.build-sign-notarize-nightly\.result == 'success' &&")
self.assertIn("REQUIRE_HELPER_TICKET: ${{ needs.signer-verify.result == 'success' }}",
self.assertIn("REQUIRE_HELPER_TICKET: ${{ needs.signer-dmg.result == 'success' }}",
yaml.safe_dump(JOBS["verify-signed-nightly"]["env"], width=1000))
# A pool run that passed reads as the job's own success in publish's source pick.
self.assertIn("(needs.build-sign-notarize-nightly.result == 'success' || needs.signer-verify.result == 'success') && 'success' || needs.build-sign-notarize-nightly.result",
self.assertIn("(needs.build-sign-notarize-nightly.result == 'success' || needs.signer-dmg.result == 'success') && 'success' || needs.build-sign-notarize-nightly.result",
yaml.safe_dump(JOBS["publish-nightly"], width=1000))
# Only signer-verify is needed outside the signer jobs; the failure report and its close
# Only signer-dmg is needed outside the signer jobs; the failure report and its close
# never need a signer job, so a pool run the fallback covered raises no failure issue.
for name, job in JOBS.items():
if name in SIGNER_JOBS:
continue
with self.subTest(job=name):
self.assertLessEqual(set(needs(job)) & set(SIGNER_JOBS), {"signer-verify"})
self.assertLessEqual(set(needs(job)) & set(SIGNER_JOBS), {"signer-dmg"})
for name in ("report-nightly-failure", "close-nightly-failure-issue"):
self.assertFalse(set(needs(JOBS[name])) & set(SIGNER_JOBS), name)

def test_signer_verify_hands_on_only_after_every_check(self):
steps = JOBS["signer-verify"]["steps"]
def test_signer_dmg_hands_on_only_after_every_check(self):
steps = JOBS["signer-dmg"]["steps"]
uploads = [i for i, s in enumerate(steps) if "upload-artifact" in str(s.get("uses", ""))]
checks = [i for i, s in enumerate(steps) if "run" in s]
self.assertTrue(uploads and checks)
Expand All @@ -97,7 +99,7 @@ def test_signer_verify_hands_on_only_after_every_check(self):
# rerun of failed jobs would not run them again on the replacement.
for i in uploads:
self.assertNotIn("overwrite", steps[i]["with"], steps[i]["with"]["name"])
# "Re-run failed jobs" after a pool failure also reruns the jobs that need signer-verify, even when
# "Re-run failed jobs" after a pool failure also reruns the jobs that need signer-dmg, even when
# they passed: each of their uploads must take the place of its earlier one in this run.
for job in ("build-sign-notarize-nightly", "verify-final-dmg-nightly", "publish-nightly"):
for step in JOBS[job]["steps"]:
Expand Down Expand Up @@ -146,15 +148,19 @@ def test_the_mac_jobs_run_every_apple_check_including_the_copied_helper(self):
for command in ("codesign --verify --deep --strict", "stapler validate", "spctl -a -vv -t exec",
"syspolicy_check distribution", "ditto \"$helper\"", "source=Notarized Developer ID"):
self.assertIn(command, checks)
for name in ("signer-dmg-image", "signer-verify"):
self.assertIn("signer-apple-checks.sh", yaml.safe_dump(JOBS[name]))
self.assertIn("signer-apple-checks.sh", yaml.safe_dump(JOBS["signer-dmg-image"]))
self.assertIn("verify_appcast_item.py", (ROOT / "scripts/ci/nightly-steps/signer-appcast.sh").read_text())
# The Mac job checks the appcast it downloaded, with the workflow commit's verifier.
final = next(s for s in JOBS["signer-verify"]["steps"] if s.get("name") == "The appcast's Sparkle signature over the final DMG")
self.assertIn(".publish-tooling/scripts/ci/release-signer/verify_appcast_item.py", final["run"])
self.assertIn("$NIGHTLY_NEXT_SPARKLE_PUBLIC_KEY", final["run"])
# Its output goes through tee, so only pipefail lets a failed check fail the step.
self.assertTrue(final["run"].startswith("set -euo pipefail\n"))
# The final DMG's Mac checks run in verify-final-dmg-nightly, for a pool-signed DMG only.
final = next(s for s in JOBS["verify-final-dmg-nightly"]["steps"]
if s.get("name") == "Apple's checks of a pool-signed DMG and the app in it")
self.assertEqual(final["if"], "needs.signer-dmg.result == 'success'")
script = (ROOT / "scripts/ci/nightly-steps/signer-final-dmg-checks.sh").read_text()
for command in ("set -euo pipefail", "codesign --verify", "-t open", "source=Notarized Developer ID",
"verify_appcast_item.py", "$NIGHTLY_NEXT_SPARKLE_PUBLIC_KEY", "signer-apple-checks.sh"):
self.assertIn(command, script)
steps = [s.get("name") for s in JOBS["verify-final-dmg-nightly"]["steps"]]
self.assertGreater(steps.index(final["name"]), steps.index("Verify final DMG artifact"))
self.assertLess(steps.index(final["name"]), steps.index("Upload tested DMG digests"))
# signer-dmg.sh changes directory, so the tools path must be absolute first.
dmg = (ROOT / "scripts/ci/nightly-steps/signer-dmg.sh").read_text()
self.assertLess(dmg.index('TOOLS="$(cd "$TOOLS" && pwd)"'), dmg.index("cd signer-out"))
Expand Down
5 changes: 2 additions & 3 deletions tests/upload_artifact_retention_allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,10 @@
{"step": ".github/workflows/nightly.yml::build-nightly-ghostty-cli-helper::cmux-next-license-trees", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::build-nightly-ghostty-cli-helper::cmux-next-source-archive", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-app::cmux-nightly-next-signer-app-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-dmg::cmux-nightly-next-signer-variant-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-dmg::cmux-nightly-variant-arm64", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-dmg::cmux-${{ needs.decide.outputs.channel }}-signed-app-arm64-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-dmg-image::cmux-nightly-next-signer-unsigned-dmg-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-dmg-image::cmux-nightly-next-signer-handoff-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-verify::cmux-${{ needs.decide.outputs.channel }}-signed-app-arm64-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::signer-verify::cmux-nightly-variant-arm64", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::build-sign-notarize-nightly::cef-runtime-pack", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics"},
{"step": ".github/workflows/nightly.yml::build-sign-notarize-nightly::cmux-${{ needs.decide.outputs.channel }}-notarization-recovery-${{ matrix.variant }}-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
{"step": ".github/workflows/nightly.yml::build-sign-notarize-nightly::cmux-${{ needs.decide.outputs.channel }}-signed-app-${{ matrix.variant }}-${{ needs.resolve-nightly-cmux-tui-client.outputs.build_short_sha }}", "reason": "a release, sign, publish or package handoff: only its storage may change, in its own reviewed PR that keeps publish semantics; flagged as a large kind by name or path"},
Expand Down
Loading