Cog CI and runner operations
August 21, 2026.
This runbook defines Cog's runners, workflow permissions, and repository settings. mise run workflows:check checks the workflow rules.
macOS runners
Trusted macOS jobs belong on the homemac Apple Silicon Mac mini. Linux jobs use GitHub-hosted Ubuntu. Fork pull requests use GitHub-hosted macos-26 and never reach the mini.
Temporary hosted topology
The mini is currently unavailable, so the same-repo macOS lane runs on GitHub-hosted macos-26 for now. The lane's jobs keep their same-repo guards, cache keys, and structure; only runs-on: changed, so restoring the mini is the reverse label swap in swift-ci.yml and docs.yml, nothing more. Three things follow from the hosted period:
- Hosted VMs start cold, so the jobs that leaned on the mini's persistent disk (
lint-swift,compile-fail,build-weather) carryactions/cachesteps the mini never needed. Leave the caches in place after the swap back; they are harmless on the mini. - The benchmark and UI-performance timing ceilings were recorded on the pinned mini, and a shared VM's p90 measures the neighbors — the first hosted run proved it when the state-graph comparison blew its ceiling with no allocation change anywhere. The hosted
bench-buildjob therefore runsbench:thresholds:check --allocations-only: exact allocation and ARC counts still gate every change (they are deterministic across hosts on one toolchain), while the wall-clock ceilings are loudly skipped and resume when the flag is dropped with the swap back. No committed threshold changes during this period; thresholds change only after a pinned-runner session. - Release-candidate provenance records
"runner": "github-hosted-macos-26"for artifacts built in this period.
The rest of this section describes the mini so it can return exactly as it left.
The mini uses persistent bare metal, not a fresh virtual machine for each job. Job hooks clean Cog files before and after every run. A virtual-machine setup is a future option, but current personal-repo tools are not maintained well enough and would need about 210–350 GB of free storage.
Current host
- User: the runner uses the
remembotadmin account onhomemac. This is weaker than a dedicated CI account. That account can reach one SSH key, two savedghtokens, the login keychain, and/opt/homebrew. It also runs CI for a Rust repo whose dependency build scripts can run code. This risk is accepted only because outside pull-request code cannot reach the mini. - Target state: move Cog to a dedicated, non-admin
cogciaccount. The simulator needs a logged-in graphical session, so this move also requires changing macOS auto-login. - Runner:
homemacis limited toskeswa/cog, has its own_workdirectory, and runs as a launchd LaunchAgent installed withsvc.sh installingui/501. It starts at login and returns after a reboot. - Labels: jobs require
[self-hosted, macOS, ARM64, cog-mini]. The uniquecog-minilabel prevents a broadself-hostedjob from landing here. - Load: at most two jobs run at once. Benchmark jobs share a workflow concurrency group with all other mini jobs so they measure an idle host.
Revisit bare metal before adding another maintainer with push access or letting outside code pass the same-repo guard. Also check disk space and simulator speed before moving to virtual machines.
Xcode
CI uses Xcode 26.6, build 17F113, and Swift 6.3.3. The hosted macos-26 arm64 image has the same Xcode build.
tools/select-xcode.mjs finds Xcode by version and build, not by path. This is needed because xcodes installs /Applications/Xcode.app, while hosted images use a versioned path. Change COG_XCODE_VERSION, COG_XCODE_BUILD, swift-ci.yml, docs.yml, and this page together.
The script sets job-only DEVELOPER_DIR; it does not run the system-wide xcode-select. This avoids admin access and prevents two jobs from changing each other's toolchain.
Use full Xcode. Command Line Tools include swift and swift-format, but swift test fails there because SwiftPM cannot load Testing.framework.
Simulator CI uses the latest runtime in pinned Xcode. The planned iOS 17.5 floor was removed because Xcode could not download its exact build without a personal Apple account or untrusted artifact URL. To restore floor testing, first prove a safe install, import, boot, reboot, and focused boundary test on homemac. Keep any recovery copy outside runner work and temp folders.
Cleanup hooks
The runner loads these scripts through its .env file:
actions-runner-cog/hooks/job-started.sh
actions-runner-cog/hooks/job-completed.shCheckout must use persist-credentials: false and clean: true.
After a job, the completed hook removes only:
- that job's
RUNNER_WORKSPACE; RUNNER_TEMP; andDerivedData/cog-*.
It must not clear all of $TMPDIR or ~/Library/Caches/org.swift.swiftpm, because another repo may be using them. Cache uploads finish before this hook. The hook never fails an otherwise good job.
Before checkout, the started hook fails if GITHUB_WORKSPACE is not empty. The runner always creates the workspace folders, so check that the checkout directory is empty instead of checking whether it exists. This next-run check makes a missed cleanup visible before new code runs.
Fork boundary
Every self-hosted job uses this same-repo check:
if: >-
github.repository == 'skeswa/cog'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)Every hosted fork job uses the opposite check:
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name != github.repositoryThe event-name test matters because push and schedule events have no pull_request object.
Manual release candidates are the only extra path into the same-repo lane. Its arm64 job builds both CogLint macOS executables with Xcode 26.6, checksums the archive, records its source and tools, and tests arm64. A hosted macos-15-intel job downloads those exact bytes, verifies them, selects Xcode 26.3 build 17C529, and tests the x86_64 file without rebuilding it. Pull-request code still needs to come from this repository.
GitHub Actions settings
These repository settings are part of the security boundary:
# All outside contributors need approval before their workflow runs.
gh api repos/skeswa/cog/actions/permissions/fork-pr-contributor-approval
# => {"approval_policy":"all_external_contributors"}
# Actions must use full commit SHAs.
gh api repos/skeswa/cog/actions/permissions --jq '.sha_pinning_required'
# => true
# Tokens are read-only by default; Actions may create release PRs.
gh api repos/skeswa/cog/actions/permissions/workflow
# => {"default_workflow_permissions":"read","can_approve_pull_request_reviews":true}Workflows must also use clear permissions blocks, timeouts, full action SHAs, persist-credentials: false, and the same-repo guard on every job in the same-repo lane — enforced structurally for any self-hosted label, and kept on the lane's jobs through the hosted period so the topology snaps back. mise run workflows:check enforces these rules.
The release contract also checks that a job which is supposed to run can. A job whose if holds no status-check function inherits an implicit success() over its whole upstream graph, so one skipped ancestor skips it silently — no runner, no log, no failed check. That is not hypothetical: it skipped the Docs and coglint-plugins dispatches for two releases. Both release dispatch jobs must therefore gate on !cancelled() && needs.publish.result == 'success', which keeps the safety property while overriding the implicit check. Writing success() is not a remedy — it is that same check.
Branch, tag, and environment rules
- GitHub allows rebase merging only. Merge commits and squash merging are off.
- The
Protect mainruleset requires a pull request, linear history, rebase merging, and theConventional Commitscheck. - The
Protect immutable release tagsruleset blocks changes and deletion for bare release tags, with no bypass. Release Please may create a tag, but no person or workflow may later move or delete it. - The sibling
coglint-pluginsrepo uses the same tag rule and read-only default token permissions. cog-releaseandcoglint-releasehold no required reviewer. They exist to scopecontents: writeto their hosted publisher jobs, which verify every fact a reviewer would check before they write.
During first setup, let the parent PR land under the old branch rules. Register the new Conventional Commits check with the release-management PR, then add that check to Protect main. A ruleset cannot require a check that GitHub has not seen.
Allowed write jobs
PERMISSION_EXCEPTIONS in tools/lib/workflows/checks.mjs lists every allowed write grant by workflow, job, permission, and value:
| Job | Allowed write access |
|---|---|
| Pages deploy | pages, id-token |
| Release Please | contents, pull-requests, issues, actions |
| Release recovery | actions |
| Docs handoff | actions |
| Protected Cog publisher | contents |
| Protected sibling publisher | contents in the sibling workflow fixture |
| Plugins handoff | none; a sibling-scoped secret, not the token |
These exceptions work only on GitHub-hosted jobs. A write token must never reach the Mac mini. The workflow checker also tests the protected environments, action SHAs, exact candidate identity, source records, recovery, docs dispatch, unchanged sibling main, safe tag creation, and final public plugin use.
Main workflows
Commit messages
conventional-commits.yml is a required hosted check with contents: read and no path filters. It checks every commit in the pull-request or push range. See change management for message rules and local checks.
Swift CI and release candidates
swift-ci.yml accepts a Release Please PR number for a candidate, dispatched by release.yml at each PR update or by hand. The dispatch ref must equal that PR's current head. Recovery instead uses an existing tag whose tree matches the merged release PR.
The arm64 job creates a versioned CogLint archive and JSON record. The hosted Intel job tests the same bytes and keeps the publication artifact for 90 days. The final Release candidate job requires all commit, format, host, simulator, example, Storefront, lint, docs, task, benchmark, and artifact jobs. Its hosted commit check also supplies the required result for a bot-created release PR.
Benchmark thresholds are dispatch-only, so they run for a release candidate and nowhere else. The committed ceilings are deterministic rather than flaky — --allocations-only already defers every wall-clock ceiling to the pinned runner, and allocation and ARC counts hold across hosts on one toolchain — so this is a decision about what an ordinary push should cost, not a statement about the gate. The trade is that an allocation regression surfaces at the candidate rather than at the merge that caused it, with more commits to bisect; mise run bench:thresholds:check is the local answer, and candidate-gate still requires the job, so no candidate reaches a release without it.
Host tests run the isolation matrix, and a pull request runs its diagonal. The two axes are the consumer's defaultIsolation and its NonisolatedNonsendingByDefault, so mainactor-nnbd-on with nonisolated-nnbd-off is the smallest pair covering every value of both: a regression on either axis fails the pull request that caused it, for half the macOS cost. Pushes to main and the release candidate run all four legs, which is where LEG-01 is proven and where an interaction bug — one that needs mainactor and the feature off — is caught. Keep mainactor-nnbd-on in the pair: it is what the library ships under, and the CompactArena trait and api:check steps are gated on it.
The paths: lists on push and pull_request describe what an ordinary run compiles, lints, or tests, and nothing more. They do not bound a candidate: workflow_dispatch carries no paths:, so every dispatch-only job — including candidate-extras, which builds the complete documentation site — sees every change however the lists read. The negations exist because swift/** and tools/** were each claiming files no ordinary job reads: the DocC catalogue is prose that .oxfmtrc.json already ignores, and four scripts under tools/ are reachable only from mise run docs. Those belong to the Docs workflow, which now checks them on pull requests. Add to these lists when a new input can change what an ordinary run concludes; leave a site-only file out and let Docs cover it.
Release publication
release.yml keeps five hosted jobs and five separate credentials:
- Release Please creates or updates the release PR without checking out code, rebases it through GitHub when another merge has left it behind
main, then dispatches the Swift CI candidate at its head. - Recovery may dispatch and wait for tag-bound Swift CI.
- The
cog-releasepublisher verifies the candidate, source trees, tools, architectures, record, and checksum before publishing matching bytes. No reviewer stands in front of it, and it waits for a candidate that is still building at merge time rather than failing. - A narrow
actions: writejob dispatches Docs at the published tag. - A job with only
contents: readon its own token dispatches the siblingPublish CogLintPluginsworkflow throughCOGLINT_PLUGINS_DISPATCH_TOKEN, a fine-grained secret scoped to that repository's Actions.
The sibling repo uses the same split. Read-only preparation builds and checks the generated package, verifying the Cog provenance by identity alone; the toolchain pins live only in release.yml and the checker rejects a copy. The coglint-release writer runs no downloaded Cog code, requires sibling main to be unchanged, pushes without force, and creates the matching tag. A final read-only job uses that public tag.
Documentation workflow
docs.yml combines the VitePress site from docs/ with the DocC API reference. GitHub Pages supports one deployment, so tools/assemble-docs-site.mjs merges both outputs and checks their required routes.
| Job | Runner | Work |
|---|---|---|
docc-cache | hosted Ubuntu | Resolve the newest published release and find its DocC archive |
docc | macOS lane | Build that archive only when it is missing |
assemble | hosted Ubuntu | Build VitePress, merge both sites, and upload the Pages artifact |
deploy | hosted Ubuntu | Publish the artifact; run no repository code |
Docs also runs on pull requests that touch the site, and that lane is the only thing checking the site before it merges — Swift CI builds it too, but only in candidate-extras, which is dispatch-only. Every job above runs on a pull request exactly as it does on main, except that assemble skips the artifact upload and deploy never starts. The check is therefore the build, the merge, and the required-route assertion; publishing is the part pull requests do not get. Unlike push, the pull-request trigger is path-filtered, which is safe because no tag arrives by pull request.
The API reference and VitePress release labels always describe GitHub's newest published release, not main, a draft tag, or the workflow's source ref. A normal docs change does not wake the macOS lane when that release archive is already saved, and docc additionally refuses to build for a fork's pull request. That guard is about the macOS bill and also about trust: docc documents the release tag's sources, checked out under .build/docs-source, but it builds them with the docs:api recipe from the workflow's own ref, so a change to the archive's flags reaches the site without waiting for the next release. When those flags change, bump the -vN suffix on the cache key in docc-cache; the key otherwise names only the tag and the Xcode pin. The saved archive belongs to the ref that created it, so the first main push after a release builds it once; later pushes reuse it. The assemble job runs third-party npm code with a read-only token. Only the single-action deploy job receives pages: write and id-token: write.
Do not add a path filter to docs.yml. The release workflow must dispatch it at the tag because events made by a repository token do not usually start another workflow. If the API reference is missing, rerun Docs; the merge step will fail instead of publishing broken /documentation/cog/ links.
Keep the always() guard on every job downstream of docc. A skipped job skips whatever needs it, and that travels the whole needs chain instead of stopping at the first job to recover: assemble surviving a skipped docc is not enough to let deploy run, so both carry their own guard. Drop either one and an ordinary docs push hits the DocC cache, skips docc, builds and uploads a Pages artifact, publishes none of it, and still reports the run green. That went unnoticed for six runs between 0.6.0 and 0.6.1.
Open questions
These are unresolved operational choices, not defects. Each is written down so a future change reconsiders it deliberately.
- If the mini ever moves from bare metal to a virtual machine, measure benchmark noise again before trusting a gated threshold from the new host.
- Recheck the benchmark tool pins in
swift/Benchmarks/Runner/README.mdwhenever Swift or Xcode changes. - Move simulator checks out of pull requests if they become too slow, and run them on
mainand release candidates only.