From 0db4f7d628ed0bd32f47eeebaf5c309329d0f8a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 07:23:47 +0000 Subject: [PATCH 1/2] Wait for Swift CI before TestFlight ships. Mac Release already waits on push. workflow_dispatch now uses the same check, and a skipped companion fails the wait instead of polling until timeout. iOS and Mac TestFlight wait on ubuntu before taking a macOS runner, and the scheduled iOS skip no longer queues one. The ruleset script refuses to require the new check names until they are green on main. Co-authored-by: Jay Wedgeworth --- .github/workflows/ios-ship.yml | 63 +++-- .github/workflows/mac-release.yml | 55 +---- .github/workflows/macos-ship.yml | 22 +- docs/rollouts/2026-10-15-cc19-compile-gate.md | 30 ++- scripts/apply-main-compile-gate-ruleset.sh | 89 +++++++ scripts/await-swift-ci-checks.sh | 103 ++++++++ scripts/compile_gate_checks_test.py | 219 ++++++++++++++++++ scripts/compile_gate_ruleset.py | 95 ++++++++ scripts/swift_ci_check_status.py | 103 ++++++++ 9 files changed, 705 insertions(+), 74 deletions(-) create mode 100755 scripts/apply-main-compile-gate-ruleset.sh create mode 100755 scripts/await-swift-ci-checks.sh create mode 100755 scripts/compile_gate_checks_test.py create mode 100755 scripts/compile_gate_ruleset.py create mode 100755 scripts/swift_ci_check_status.py diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 2ac54782..a8bcea93 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -33,24 +33,23 @@ on: permissions: contents: read + checks: read concurrency: group: ios-ship-codecaps cancel-in-progress: false jobs: - ship: - name: TestFlight archive + upload + # The scheduled tick used to queue macos-latest just to decide "skip". + # That decision, and the wait for Swift CI, both run on ubuntu. The Mac + # runner starts only when this commit should ship and has compiled. + gate: + name: Ship gate if: github.event.repository.fork == false - runs-on: macos-latest + runs-on: ubuntu-latest timeout-minutes: 90 - env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} - ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} - IOS_DIST_P12_BASE64: ${{ secrets.IOS_DIST_P12_BASE64 }} - IOS_DIST_P12_PASSWORD: ${{ secrets.IOS_DIST_P12_PASSWORD }} + outputs: + should_ship: ${{ steps.gate.outputs.should_ship }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -74,8 +73,45 @@ jobs: --repo-root "$GITHUB_WORKSPACE" \ codecaps - - name: Assert stable Xcode (never beta) + - name: Wait for Swift CI compile checks on this commit if: steps.gate.outputs.should_ship == '1' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: bash scripts/await-swift-ci-checks.sh + + ship: + name: TestFlight archive + upload + needs: gate + if: >- + github.event.repository.fork == false && + needs.gate.result == 'success' && + needs.gate.outputs.should_ship == '1' + runs-on: macos-latest + timeout-minutes: 90 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + IOS_DIST_P12_BASE64: ${{ secrets.IOS_DIST_P12_BASE64 }} + IOS_DIST_P12_PASSWORD: ${{ secrets.IOS_DIST_P12_PASSWORD }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Restore ship-state cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ios-fleet + key: ios-fleet-state-codecaps-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + ios-fleet-state-codecaps-${{ github.run_id }}- + ios-fleet-state-codecaps- + + - name: Assert stable Xcode (never beta) run: | echo "DEVELOPER_DIR=${DEVELOPER_DIR}" xcodebuild -version @@ -89,7 +125,6 @@ jobs: esac - name: Install xcodegen - if: steps.gate.outputs.should_ship == '1' run: | if command -v xcodegen >/dev/null 2>&1; then xcodegen --version @@ -98,7 +133,6 @@ jobs: fi - name: Load Sentry ship secrets (Infisical, then repository secrets) - if: steps.gate.outputs.should_ship == '1' uses: ./.github/actions/infisical-secrets with: names: SENTRY_DSN SENTRY_AUTH_TOKEN @@ -110,7 +144,6 @@ jobs: GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Import signing + ASC key - if: steps.gate.outputs.should_ship == '1' run: | if [ -z "${ASC_KEY_ID:-}" ]; then echo "::error::ASC_KEY_ID empty. Confirm ASC_/IOS_DIST_ secrets are configured." @@ -119,11 +152,9 @@ jobs: bash "$GITHUB_WORKSPACE/scripts/ios-appstore-gm-prepare.sh" - name: Install App Store profiles - if: steps.gate.outputs.should_ship == '1' run: bash "$GITHUB_WORKSPACE/scripts/ios-install-appstore-profiles.sh" - name: Ship CodeCaps to TestFlight - if: steps.gate.outputs.should_ship == '1' env: IOS_TF_RELEASE_NOTES: "1" IOS_MANUAL_SIGN: "1" diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index 12d4c05c..c7d3fe2f 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -55,69 +55,30 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Push and workflow_dispatch wait here, on ubuntu, before any macos-latest + # release runner starts. Pull requests skip this job; the release job + # below still builds the bundle and does not publish. await-swift-ci: - if: github.event.repository.fork == false && github.event_name == 'push' + if: github.event.repository.fork == false && github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 90 permissions: checks: read contents: read steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Wait for Swift CI compile checks on this commit env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} - run: | - set -euo pipefail - required_checks=("Mac package build and test" "iOS companion compile") - deadline=$(( $(date +%s) + 5100 )) - while [ "$(date +%s)" -lt "$deadline" ]; do - payload="$(gh api "repos/${REPO}/commits/${SHA}/check-runs?per_page=100")" - pending=0 - failed=0 - for check_name in "${required_checks[@]}"; do - conclusion="$(printf '%s' "$payload" | python3 -c ' - import json, sys - name = sys.argv[1] - data = json.load(sys.stdin) - matches = [r for r in data.get("check_runs", []) if r.get("name") == name] - if not matches: - print("missing") - else: - print(matches[0].get("conclusion") or "pending") - ' "$check_name")" - case "$conclusion" in - success) ;; - failure|cancelled|timed_out|action_required|stale) - echo "::error::Swift CI check '${check_name}' concluded ${conclusion} on ${SHA}" - failed=1 - ;; - missing|pending|""|null) - pending=1 - ;; - *) - pending=1 - ;; - esac - done - if [ "$failed" -eq 1 ]; then - exit 1 - fi - if [ "$pending" -eq 0 ]; then - echo "Swift CI compile checks succeeded on ${SHA}" - exit 0 - fi - echo "Waiting for Swift CI on ${SHA}..." - sleep 30 - done - echo "::error::Timed out waiting for Swift CI compile checks on ${SHA}" - exit 1 + run: bash scripts/await-swift-ci-checks.sh release: needs: await-swift-ci # always() is required so release still runs when await-swift-ci is skipped - # (pull_request / workflow_dispatch); without it GitHub treats skipped needs as failure. + # on a pull request. Without it GitHub treats a skipped need as failure. if: >- always() && github.event.repository.fork == false && diff --git a/.github/workflows/macos-ship.yml b/.github/workflows/macos-ship.yml index dbe49cda..3efc1eb9 100644 --- a/.github/workflows/macos-ship.yml +++ b/.github/workflows/macos-ship.yml @@ -15,15 +15,35 @@ on: permissions: contents: read + checks: read concurrency: group: macos-ship-codecaps cancel-in-progress: true jobs: + await-swift-ci: + name: Wait for Swift CI + if: github.event.repository.fork == false && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Wait for Swift CI compile checks on this commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: bash scripts/await-swift-ci-checks.sh + ship: name: Mac TestFlight archive + upload - if: github.event.repository.fork == false && github.ref == 'refs/heads/main' + needs: await-swift-ci + if: >- + github.event.repository.fork == false && + github.ref == 'refs/heads/main' && + needs.await-swift-ci.result == 'success' runs-on: macos-latest timeout-minutes: 90 env: diff --git a/docs/rollouts/2026-10-15-cc19-compile-gate.md b/docs/rollouts/2026-10-15-cc19-compile-gate.md index 068cf911..201e2764 100644 --- a/docs/rollouts/2026-10-15-cc19-compile-gate.md +++ b/docs/rollouts/2026-10-15-cc19-compile-gate.md @@ -16,19 +16,27 @@ document gives Jay the exact check names to add in GitHub (admin-only). only after `package` (`needs:`) so a PR never holds two macOS runners at once. Explicit job `name:` values match the contexts GitHub reports for required checks. -- **Mac Release (Sparkle)** — new `await-swift-ci` job on `ubuntu-latest` - polls until `package` and `companion` succeed on the merge commit (or fails - closed if either fails, is cancelled, or times out). The release job - `needs: await-swift-ci`, closing the race where Sparkle published while - companion was still running. -- **This rollout** — owner steps for ruleset required checks (not applied in - CI; Jay has the admin grant). +- **Mac Release (Sparkle)** — `await-swift-ci` on `ubuntu-latest` polls until + `Mac package build and test` and `iOS companion compile` succeed on the + commit (or fails closed if either fails, is skipped, is cancelled, or + times out). Push and `workflow_dispatch` both wait. The release job + `needs: await-swift-ci`, so Sparkle cannot publish while companion is + still running. +- **iOS and Mac TestFlight** — the same wait runs before a ship. The + scheduled iOS gate itself runs on ubuntu, so a skip no longer queues a + macOS runner. +- **Ruleset script** — `scripts/apply-main-compile-gate-ruleset.sh` prints + the rule change and refuses `--apply` until the three contexts below are + already success on the default branch. It is not run from CI. ## Verification (agents) ```bash +python3 scripts/compile_gate_checks_test.py python3 -c "import yaml; yaml.safe_load(open('.github/workflows/swift-ci.yml'))" python3 -c "import yaml; yaml.safe_load(open('.github/workflows/mac-release.yml'))" +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ios-ship.yml'))" +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/macos-ship.yml'))" ``` Hosted macOS compile is validated on the pull request’s Swift CI run after push. @@ -71,7 +79,9 @@ Do **not** require workflow names (`Swift CI`, `Secret scanning`). Require the ## Deferred -- Ruleset edit itself (Jay admin). +- Applying the ruleset. After a green Swift CI on `main`, someone with + ruleset admin runs `scripts/apply-main-compile-gate-ruleset.sh --apply`. + A dry run is the same command without `--apply`. - Moving compile to self-hosted macOS runners (fleet BotFleet pinning work). -- Requiring Mac Release or TestFlight workflows as merge gates (ship lanes, not - compile). +- Requiring the ship workflows themselves as merge checks. They wait on the + compile checks; they are not compile checks. diff --git a/scripts/apply-main-compile-gate-ruleset.sh b/scripts/apply-main-compile-gate-ruleset.sh new file mode 100755 index 00000000..ae6f7147 --- /dev/null +++ b/scripts/apply-main-compile-gate-ruleset.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Add required status checks to CodeCaps ruleset 24433457. +# +# Dry-run unless --apply. --apply refuses unless the Swift CI job names +# and gitleaks are already success on the default branch tip, so a starved or +# red Swift CI cannot become a gate that wedges every merge. +# +# Needs a token that can administer repository rulesets (GITHUB_TOKEN from +# Actions cannot). The token is never printed. +# +# scripts/apply-main-compile-gate-ruleset.sh +# scripts/apply-main-compile-gate-ruleset.sh --apply + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="${REPO:-Simple-With-Us/CodeCaps}" +RULESET_ID="${RULESET_ID:-24433457}" +APPLY=0 + +if [[ "${1:-}" == "--apply" ]]; then + APPLY=1 +elif [[ -n "${1:-}" ]]; then + echo "usage: $0 [--apply]" >&2 + exit 2 +fi + +[[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "REPO is not owner/name" >&2 + exit 1 +} + +current="$(mktemp)" +updated="$(mktemp)" +trap 'rm -f "$current" "$updated"' EXIT + +gh api "repos/${REPO}/rulesets/${RULESET_ID}" >"$current" +python3 "$ROOT/compile_gate_ruleset.py" --ruleset "$current" >"$updated" + +echo "ruleset ${RULESET_ID} on ${REPO}" +python3 - "$current" "$updated" "$ROOT/compile_gate_ruleset.py" <<'PY' +import importlib.util +import json +import sys +from pathlib import Path + +spec = importlib.util.spec_from_file_location("compile_gate_ruleset", sys.argv[3]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +current = json.loads(Path(sys.argv[1]).read_text()) +updated = json.loads(Path(sys.argv[2]).read_text()) +print("current contexts:", ", ".join(module.contexts_of(current)) or "(none)") +print("updated contexts:", ", ".join(module.contexts_of(updated))) +print("other rules kept:", ", ".join( + rule.get("type") for rule in updated["rules"] if rule.get("type") != "required_status_checks" +)) +PY + +branch="$(gh api "repos/${REPO}" --jq .default_branch)" +sha="$(gh api "repos/${REPO}/commits/${branch}" --jq .sha)" +echo "default branch ${branch} @ ${sha}" + +set +e +ONCE=1 REPO="$REPO" SHA="$sha" \ + bash "$ROOT/await-swift-ci-checks.sh" \ + "Mac package build and test" \ + "iOS companion compile" \ + "gitleaks" +checks=$? +set -e +if [[ "$checks" -eq 0 ]]; then + echo "compile checks on ${branch} are success" +else + echo "compile checks on ${branch} are not success (exit ${checks})" +fi + +if [[ "$APPLY" -eq 0 ]]; then + echo "dry-run only. Re-run with --apply after those checks are success." + exit 0 +fi + +if [[ "$checks" -ne 0 ]]; then + echo "refusing --apply until Mac package build and test, iOS companion compile, and gitleaks are success on ${branch}" >&2 + exit 1 +fi + +gh api --method PUT "repos/${REPO}/rulesets/${RULESET_ID}" --input "$updated" \ + --jq '{id,name,enforcement,rule_types:[.rules[].type]}' +echo "updated ruleset ${RULESET_ID}" diff --git a/scripts/await-swift-ci-checks.sh b/scripts/await-swift-ci-checks.sh new file mode 100755 index 00000000..be879835 --- /dev/null +++ b/scripts/await-swift-ci-checks.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Wait until the Swift CI compile checks on one commit have succeeded. +# +# Required environment: +# REPO GitHub owner/name +# SHA 40-character commit +# GH_TOKEN token that can read check runs (unless CHECKS_JSON is set) +# +# Optional arguments: +# check names to require. The default is the Swift CI job names on main. +# Names may contain spaces, so pass each as its own argument. +# +# Optional environment: +# DEADLINE_SECONDS how long to poll (default: 5100, just under 90 minutes) +# POLL_SECONDS delay between polls (default: 30) +# ONCE if 1, decide from one fetch and do not poll +# CHECKS_JSON one-shot fixture path; skips the network +# +# Exit 0 when every named check's newest github-actions run succeeded. +# Exit 1 on a failed, skipped, or cancelled check, or on timeout. +# Exit 2 when ONCE or CHECKS_JSON sees a check that has not finished. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="${REPO:-}" +SHA="${SHA:-}" +DEADLINE_SECONDS="${DEADLINE_SECONDS:-5100}" +POLL_SECONDS="${POLL_SECONDS:-30}" +ONCE="${ONCE:-0}" +if [[ $# -gt 0 ]]; then + CHECK_NAMES=("$@") +else + CHECK_NAMES=( + "Mac package build and test" + "iOS companion compile" + ) +fi + +die() { + echo "await-swift-ci-checks: $*" >&2 + exit 1 +} + +[[ -n "$REPO" || -n "${CHECKS_JSON:-}" ]] || die "REPO is required" +[[ -n "$SHA" || -n "${CHECKS_JSON:-}" ]] || die "SHA is required" +if [[ -z "${CHECKS_JSON:-}" ]]; then + [[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die "REPO is not owner/name" + [[ "$SHA" =~ ^[0-9a-fA-F]{40}$ ]] || die "SHA is not a 40-character commit" + if [[ -z "${GH_TOKEN:-}" ]]; then + gh auth status >/dev/null 2>&1 || die "GH_TOKEN is required" + fi +fi +for name in "${CHECK_NAMES[@]}"; do + [[ "$name" =~ ^[A-Za-z0-9_.\ -]+$ ]] || die "check name has an unexpected character: ${name}" +done + +decide() { + python3 "$ROOT/swift_ci_check_status.py" --names "${CHECK_NAMES[@]}" --json "$@" +} + +if [[ -n "${CHECKS_JSON:-}" ]]; then + decide "$CHECKS_JSON" + exit $? +fi + +deadline=$(( $(date +%s) + DEADLINE_SECONDS )) +while true; do + files=() + for name in "${CHECK_NAMES[@]}"; do + payload="$(mktemp)" + files+=("$payload") + encoded="$(python3 -c 'import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")" + gh api "repos/${REPO}/commits/${SHA}/check-runs?check_name=${encoded}&per_page=100" >"$payload" + done + set +e + verdict="$(decide "${files[@]}")" + code=$? + set -e + rm -f "${files[@]}" + if [[ "$code" -eq 0 ]]; then + echo "Swift CI checks succeeded on ${SHA}: ${CHECK_NAMES[*]}" + exit 0 + fi + if [[ "$code" -eq 1 ]]; then + echo "::error::Swift CI checks failed on ${SHA}: ${verdict}" + exit 1 + fi + if [[ "$code" -ne 2 ]]; then + echo "::error::Could not read Swift CI checks on ${SHA} (exit ${code})" + exit 1 + fi + if [[ "$ONCE" == "1" || "$(date +%s)" -ge "$deadline" ]]; then + if [[ "$ONCE" == "1" ]]; then + echo "Swift CI checks still pending on ${SHA}: ${verdict}" >&2 + exit 2 + fi + echo "::error::Timed out after ${DEADLINE_SECONDS}s waiting for Swift CI on ${SHA}: ${verdict}" + exit 1 + fi + echo "Waiting for Swift CI on ${SHA}: ${verdict}" + sleep "$POLL_SECONDS" +done diff --git a/scripts/compile_gate_checks_test.py b/scripts/compile_gate_checks_test.py new file mode 100755 index 00000000..0301ad2e --- /dev/null +++ b/scripts/compile_gate_checks_test.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Unit tests for the compile-gate check classifier and ruleset body.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def load(name: str): + path = ROOT / name + spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +status = load("swift_ci_check_status.py") +ruleset = load("compile_gate_ruleset.py") + + +def run_check(name: str, conclusion, run_id: int, state: str = "completed", app: str = "github-actions"): + payload = { + "name": name, + "id": run_id, + "status": state, + "conclusion": conclusion, + } + if app is not None: + payload["app"] = {"slug": app} + return payload + + +class DecideTests(unittest.TestCase): + names = ["package", "companion"] + + def test_both_success(self): + payload = {"check_runs": [ + run_check("package", "success", 2), + run_check("companion", "success", 3), + run_check("gitleaks", "failure", 4), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "success") + self.assertEqual(details, []) + + def test_package_failure_hides_nothing(self): + payload = {"check_runs": [ + run_check("package", "failure", 2), + run_check("companion", None, 3, state="queued"), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "failure") + self.assertIn("package=failure", details) + + def test_missing_companion_is_pending(self): + payload = {"check_runs": [run_check("package", "success", 2)]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "pending") + self.assertEqual(details, ["companion=missing"]) + + def test_newer_success_replaces_cancelled(self): + payload = {"check_runs": [ + run_check("package", "cancelled", 1), + run_check("package", "success", 5), + run_check("companion", "success", 6), + ]} + verdict, _details = status.decide([payload], self.names) + self.assertEqual(verdict, "success") + + def test_newer_failure_replaces_success(self): + payload = {"check_runs": [ + run_check("package", "success", 1), + run_check("package", "failure", 9), + run_check("companion", "success", 3), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "failure") + self.assertIn("package=failure", details) + + def test_skipped_companion_is_failure(self): + payload = {"check_runs": [ + run_check("package", "failure", 2), + run_check("companion", "skipped", 4), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "failure") + self.assertIn("companion=skipped", details) + + def test_in_progress_is_pending(self): + payload = {"check_runs": [ + run_check("package", "success", 2), + run_check("companion", None, 3, state="in_progress"), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "pending") + self.assertEqual(details, ["companion=in_progress"]) + + def test_foreign_app_does_not_count(self): + payload = {"check_runs": [ + run_check("package", "success", 8, app="some-other-app"), + run_check("companion", "success", 9), + ]} + verdict, details = status.decide([payload], self.names) + self.assertEqual(verdict, "pending") + self.assertEqual(details, ["package=missing"]) + + def test_cli_exit_codes(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "checks.json" + path.write_text(json.dumps({"check_runs": [ + run_check("package", "success", 1), + run_check("companion", "success", 2), + ]}), encoding="utf-8") + completed = subprocess.run( + ["python3", str(ROOT / "swift_ci_check_status.py"), + "--names", "package", "companion", "--json", str(path)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0) + self.assertEqual(completed.stdout.strip(), "success") + + +class RulesetTests(unittest.TestCase): + def live_shape(self): + return { + "id": 24433457, + "name": "default-main-protection", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"exclude": [], "include": ["~DEFAULT_BRANCH"]}}, + "rules": [ + {"type": "pull_request", "parameters": {"required_approving_review_count": 0}}, + {"type": "non_fast_forward", "parameters": None}, + {"type": "deletion", "parameters": None}, + ], + } + + def test_adds_contexts_and_keeps_other_rules(self): + body = ruleset.with_required_checks(self.live_shape()) + self.assertEqual( + ruleset.contexts_of(body), + ["Mac package build and test", "iOS companion compile", "gitleaks"], + ) + self.assertEqual( + [rule["type"] for rule in body["rules"]], + ["pull_request", "non_fast_forward", "deletion", "required_status_checks"], + ) + self.assertEqual(body["conditions"]["ref_name"]["include"], ["~DEFAULT_BRANCH"]) + self.assertNotIn("id", body) + parameters = body["rules"][-1]["parameters"] + self.assertTrue(parameters["do_not_enforce_on_create"]) + self.assertFalse(parameters["strict_required_status_checks_policy"]) + + def test_replaces_existing_required_checks_once(self): + current = self.live_shape() + current["rules"].append({ + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": True, + "strict_required_status_checks_policy": False, + "required_status_checks": [{"context": "verify"}], + }, + }) + once = ruleset.with_required_checks(current) + twice = ruleset.with_required_checks(once) + self.assertEqual( + ruleset.contexts_of(twice), + ["Mac package build and test", "iOS companion compile", "gitleaks"], + ) + self.assertEqual( + sum(1 for rule in twice["rules"] if rule["type"] == "required_status_checks"), + 1, + ) + + +class AwaitScriptTests(unittest.TestCase): + def test_fixture_success_and_pending(self): + script = ROOT / "await-swift-ci-checks.sh" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "checks.json" + path.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "success", 1), + run_check("iOS companion compile", "success", 2), + ]}), encoding="utf-8") + ok = subprocess.run( + ["bash", str(script)], + check=False, + capture_output=True, + text=True, + env={"CHECKS_JSON": str(path), "PATH": "/usr/bin:/bin"}, + ) + self.assertEqual(ok.returncode, 0, ok.stderr) + self.assertEqual(ok.stdout.strip(), "success") + + path.write_text(json.dumps({"check_runs": [ + run_check("Mac package build and test", "success", 1), + ]}), encoding="utf-8") + pending = subprocess.run( + ["bash", str(script)], + check=False, + capture_output=True, + text=True, + env={"CHECKS_JSON": str(path), "PATH": "/usr/bin:/bin"}, + ) + self.assertEqual(pending.returncode, 2, pending.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/compile_gate_ruleset.py b/scripts/compile_gate_ruleset.py new file mode 100755 index 00000000..595a7553 --- /dev/null +++ b/scripts/compile_gate_ruleset.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Build the CodeCaps main-branch ruleset body with a compile gate. + +The live contexts are the Swift CI job names ``Mac package build and test`` +and ``iOS companion compile``, plus the gitleaks job. Renaming a job +without updating REQUIRED_CONTEXTS makes the gate wait for a check that +will never arrive. + +Usage: compile_gate_ruleset.py --ruleset ruleset.json +Prints the PUT body for the repository ruleset API. Does not call GitHub. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Mapping, Sequence + +# Match Usage-Monitor's working ruleset: do not require the branch to be +# up to date with the base, and do not enforce the checks when a branch +# is created. The contexts themselves are what block a merge. +# These are the Swift CI job `name:` values on main (CC-19 / #236), not the +# job ids. GitHub reports the name as the status context. +REQUIRED_CONTEXTS = ( + "Mac package build and test", + "iOS companion compile", + "gitleaks", +) + + +def required_status_checks_rule() -> dict: + return { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": True, + "strict_required_status_checks_policy": False, + "required_status_checks": [{"context": name} for name in REQUIRED_CONTEXTS], + }, + } + + +def contexts_of(ruleset: Mapping) -> list[str]: + found: list[str] = [] + for rule in ruleset.get("rules") or []: + if rule.get("type") != "required_status_checks": + continue + parameters = rule.get("parameters") or {} + for check in parameters.get("required_status_checks") or []: + context = check.get("context") + if context: + found.append(context) + return found + + +def with_required_checks(ruleset: Mapping) -> dict: + """Return a ruleset update body. Other rules are copied through.""" + for key in ("name", "target", "enforcement"): + if not ruleset.get(key): + raise ValueError(f"ruleset is missing {key}") + updated_rules = [] + replaced = False + new_rule = required_status_checks_rule() + for rule in ruleset.get("rules") or []: + if rule.get("type") == "required_status_checks": + updated_rules.append(new_rule) + replaced = True + else: + updated_rules.append(rule) + if not replaced: + updated_rules.append(new_rule) + return { + "name": ruleset["name"], + "target": ruleset["target"], + "enforcement": ruleset["enforcement"], + "bypass_actors": list(ruleset.get("bypass_actors") or []), + "conditions": ruleset.get("conditions"), + "rules": updated_rules, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ruleset", required=True, help="GET ruleset JSON") + args = parser.parse_args(argv) + with open(args.ruleset, encoding="utf-8") as handle: + current = json.load(handle) + body = with_required_checks(current) + json.dump(body, sys.stdout) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/swift_ci_check_status.py b/scripts/swift_ci_check_status.py new file mode 100755 index 00000000..af81a3e1 --- /dev/null +++ b/scripts/swift_ci_check_status.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Classify GitHub Actions check runs for the CodeCaps compile gate. + +Reads one or more check-run list payloads (the ``check_runs`` array from +``GET /repos/{owner}/{repo}/commits/{sha}/check-runs``) and prints one word +on stdout: ``success``, ``failure``, or ``pending``. + +Exit status matches that word: 0, 1, or 2. The newest github-actions run +for each requested name wins, so a rerun replaces a cancelled attempt. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Iterable, Mapping, Sequence + + +def _runs_from(payloads: Iterable[Mapping]) -> list[Mapping]: + runs: list[Mapping] = [] + for payload in payloads: + chunk = payload.get("check_runs") or [] + if not isinstance(chunk, list): + raise ValueError("check_runs must be a list") + runs.extend(chunk) + return runs + + +def latest_by_name(runs: Sequence[Mapping], names: Sequence[str]) -> dict[str, Mapping]: + """Newest github-actions run for each name. A missing app slug is kept.""" + wanted = set(names) + latest: dict[str, Mapping] = {} + for run in runs: + name = run.get("name") + if name not in wanted: + continue + app = run.get("app") or {} + slug = app.get("slug") if isinstance(app, Mapping) else None + if slug not in (None, "", "github-actions"): + continue + run_id = int(run.get("id") or 0) + previous = latest.get(name) + if previous is None or run_id >= int(previous.get("id") or 0): + latest[name] = run + return latest + + +def decide(payloads: Iterable[Mapping], names: Sequence[str]) -> tuple[str, list[str]]: + """Return (verdict, details). + + Any concluded non-success fails the gate immediately, including a + skipped companion after a failed package. A missing or still-running + check stays pending so the caller can poll. Failure wins over pending + so a red package is not hidden behind a companion that has not started. + """ + if not names: + raise ValueError("at least one check name is required") + latest = latest_by_name(_runs_from(payloads), names) + failed: list[str] = [] + pending: list[str] = [] + for name in names: + run = latest.get(name) + if run is None: + pending.append(f"{name}=missing") + continue + conclusion = run.get("conclusion") + status = run.get("status") or "" + if conclusion == "success": + continue + if conclusion: + failed.append(f"{name}={conclusion}") + continue + pending.append(f"{name}={status or 'pending'}") + if failed: + return "failure", failed + if pending: + return "pending", pending + return "success", [] + + +def _load(paths: Sequence[str]) -> list[dict]: + payloads = [] + for path in paths: + with open(path, encoding="utf-8") as handle: + payloads.append(json.load(handle)) + return payloads + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--names", nargs="+", required=True) + parser.add_argument("--json", nargs="+", required=True, dest="json_paths") + args = parser.parse_args(argv) + verdict, details = decide(_load(args.json_paths), args.names) + print(verdict) + for line in details: + print(line, file=sys.stderr) + return {"success": 0, "failure": 1, "pending": 2}[verdict] + + +if __name__ == "__main__": + sys.exit(main()) From 6319074fa832f62515d8499c7a02366746a91b5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 07:24:49 +0000 Subject: [PATCH 2/2] Make Mac Release pull requests wait for Swift CI too. A pull request that touches the release workflow was still starting a macos-latest bundle build beside Swift CI. The ubuntu wait now covers that event, using the pull request head SHA where the checks are reported. Co-authored-by: Jay Wedgeworth --- .github/workflows/mac-release.yml | 14 +++++--------- docs/rollouts/2026-10-15-cc19-compile-gate.md | 3 ++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml index c7d3fe2f..f9e0ba64 100644 --- a/.github/workflows/mac-release.yml +++ b/.github/workflows/mac-release.yml @@ -55,11 +55,10 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Push and workflow_dispatch wait here, on ubuntu, before any macos-latest - # release runner starts. Pull requests skip this job; the release job - # below still builds the bundle and does not publish. + # Every non-fork event waits here, on ubuntu, before a macos-latest + # release runner starts. Pull request checks are on the head SHA. await-swift-ci: - if: github.event.repository.fork == false && github.event_name != 'pull_request' + if: github.event.repository.fork == false runs-on: ubuntu-latest timeout-minutes: 90 permissions: @@ -72,17 +71,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} - SHA: ${{ github.sha }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: bash scripts/await-swift-ci-checks.sh release: needs: await-swift-ci - # always() is required so release still runs when await-swift-ci is skipped - # on a pull request. Without it GitHub treats a skipped need as failure. if: >- - always() && github.event.repository.fork == false && - (needs.await-swift-ci.result == 'success' || needs.await-swift-ci.result == 'skipped') + needs.await-swift-ci.result == 'success' runs-on: macos-latest timeout-minutes: 60 env: diff --git a/docs/rollouts/2026-10-15-cc19-compile-gate.md b/docs/rollouts/2026-10-15-cc19-compile-gate.md index 201e2764..3db935c4 100644 --- a/docs/rollouts/2026-10-15-cc19-compile-gate.md +++ b/docs/rollouts/2026-10-15-cc19-compile-gate.md @@ -19,7 +19,8 @@ document gives Jay the exact check names to add in GitHub (admin-only). - **Mac Release (Sparkle)** — `await-swift-ci` on `ubuntu-latest` polls until `Mac package build and test` and `iOS companion compile` succeed on the commit (or fails closed if either fails, is skipped, is cancelled, or - times out). Push and `workflow_dispatch` both wait. The release job + times out). Push, pull request, and `workflow_dispatch` all wait, so the + bundle build does not take a macOS runner beside Swift CI. The release job `needs: await-swift-ci`, so Sparkle cannot publish while companion is still running. - **iOS and Mac TestFlight** — the same wait runs before a ship. The