CI with GitHub Actions
This guide sets up CodeGuard for pull requests, pushes and nightly runs in GitHub Actions. It requires self-hosted macOS runners that are ephemeral and isolated, so that CodeGuard may build and test on them.
How CodeGuard works in CI
- Every invocation gets
--ci. This profile is non-interactive, local trust tickets do not apply, andtrust grantis forbidden. compileandtestsonly start if the runner carries an organization attestation (see Organization policy). Without it, only the static checks run and the run ends with exit 6.- The job status follows CodeGuard's exit code. The reports are stored as an artifact.
Prerequisites
- A self-hosted runner on macOS 15 or later that is ephemeral (every job in a fresh environment) and filesystem- and network-isolated. Only then may the attestation file be placed on it.
- Admin access to the runner image to place files as
root. - The project lives in the root directory of the repository.
check-diffaborts if the project root and the Git root differ.
Step 1: Prepare the runner image
These steps belong in the creation of the runner image, not in the workflow.
-
Install and select Xcode.
xcodebuildmust have a version>= 26.0.0and< 28.0.0. Install the simulator runtimes for all platforms you want to test. Without a runtime, a run for a watchOS-only or tvOS-only app ends with exit 3. -
Install CodeGuard:
git clone <REPOSITORY-URL> /tmp/CodeGuard # placeholder: enter the repository URLcd /tmp/CodeGuardScripts/install.sh # to /usr/local/bin, root:wheel -
Provide SwiftLint as a root-owned copy, for example:
sudo install -o root -g wheel -m 0755 "$(realpath "$(command -v swiftlint)")" /usr/local/bin/swiftlint -
Install the organization policy:
# /Library/Application Support/CodeGuard/policy.ymlpolicy_version: 1configuration_versions: [1]enforcement:tools:trusted_roots:- /usr/bin- /usr/local/bin- /Applications/Xcode.app/Contents/Developer/usr/bin- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/binexecution_environment:declaration_path: /Library/Application Support/CodeGuard/execution-environment.ymlrequire_for_untrusted_builds: truesudo mkdir -p "/Library/Application Support/CodeGuard"sudo install -o root -g wheel -m 0644 policy.yml "/Library/Application Support/CodeGuard/policy.yml"Adjust
trusted_rootsif Xcode is not located at/Applications/Xcode.app. -
Place the attestation. The content must be exactly this, line by line:
printf 'version: 1\nrunner_class: ephemeral\nephemeral: true\nfilesystem_isolated: true\nnetwork_isolated: true\n' > execution-environment.ymlsudo install -o root -g wheel -m 0644 execution-environment.yml \"/Library/Application Support/CodeGuard/execution-environment.yml"dangerWith this file, you guarantee that the runner is ephemeral and isolated. CodeGuard then runs project code from pull requests without human approval. Never place it on a persistent or networked runner.
-
Check the image (as the user the runner runs as):
codeguard versioncodeguard --ci config validatecodeguard --ci doctorconfig validatemust showpolicy: /Library/Application Support/CodeGuard/policy.yml (sha256:…), anddoctormust showready: true,swift-format: activeandswiftlint: active.
doctor and check-* use a key from the runner user's keychain. If the entry does not exist, CodeGuard creates it. If there is an entry from a differently signed CodeGuard binary (for example after a rebuild), a run without a graphical session can wait indefinitely. So build the image in such a way that CodeGuard is not rebuilt after the last build, or delete the entry com.codeguard.control-state/hmac-key-v1 before the first run. The doctor call in step 6 checks whether the runner user's keychain is usable in your environment. A job timeout additionally protects you against a hang.
Step 2: Prepare the repository
- Commit
.codeguard.ymland, if needed,.swift-formatand.swiftlint.ymlto the root directory. CodeGuard loads.codeguard.ymlautomatically. - Note:
.github/workflows/**,.codeguard.yml,Package.swift,Package.resolved,*.xcconfig,project.pbxprojand entitlements are protected files. A pull request that changes one of them ends with exit 6 (protected_file). This is intended: such changes need human review.
Step 3: Create the workflow
File .github/workflows/codeguard.yml:
name: CodeGuard
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 2 * * *" # nightly project run
jobs:
codeguard-diff:
if: github.event_name == 'pull_request'
runs-on: [self-hosted, macOS] # placeholder: labels of your ephemeral runners
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the base revision exists
- name: CodeGuard check-diff
run: |
set +e
mkdir -p codeguard-reports
codeguard --ci --format json --output codeguard-reports/codeguard.json \
check-diff --base "${{ github.event.pull_request.base.sha }}"
rc=$?
echo "CodeGuard exit code: $rc"
exit $rc
- name: Berichte ablegen
if: always()
uses: actions/upload-artifact@v4
with:
name: codeguard-diff
path: codeguard-reports/
codeguard-project:
if: github.event_name != 'pull_request'
runs-on: [self-hosted, macOS] # placeholder: labels of your ephemeral runners
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- name: CodeGuard check-project
run: |
set +e
mkdir -p codeguard-reports
codeguard --ci --format sarif --output codeguard-reports/codeguard.sarif check-project
rc=$?
echo "CodeGuard exit code: $rc"
exit $rc
- name: Berichte ablegen
if: always()
uses: actions/upload-artifact@v4
with:
name: codeguard-project
path: codeguard-reports/
Explanations:
fetch-depth: 0: By default,actions/checkoutonly clones the latest commit. Without the base revision,check-diffends with exit 2 (unknown reference) or exit 3 (commit object missing).- Base: For
pull_request,actions/checkoutchecks out the pull request's merge commit by default.check-diff --base <base.sha>therefore checks exactly the changes of the pull request. set +eandexit $rc: The step runs to the end, prints the exit code and uses it as the job result. Any code other than 0 fails the job.if: always(): The report is stored even if CodeGuard reports findings.timeout-minutes: Builds for several platforms and simulator tests take time. CodeGuard has no overall time limit for the whole run.
Every CodeGuard invocation is a complete run including build and tests. So generate only one format per run (JSON for your own evaluations, SARIF for tools that read SARIF). You can summarise the report afterwards with jq, if jq is available in the image.
Check the result
For a run with a valid attestation, the JSON report contains:
"run": { "build_authorization": "operator_attested", "mode": "check", "scope": "diff", … }
If the attestation is missing or invalid, only the static checks run. Real example of a --ci run on a machine with a policy but without an attestation file:
CODEGUARD_BLOCKED
1 blocking violations found in 1 changed files.
Validation: file (partial)
Exit: 6 (security_policy)
Requested: compile, format, security, swiftlint, swiftlint_metrics, tests
Completed: format, security, swiftlint, swiftlint_metrics
Skipped: compile, tests
Konfiguration: .codeguard.yml (auto, sha256 5967f32a…)
Tool: swift-format main [tool_swift_format] completed, 14ms, truncated: false
Tool: swiftlint 0.65.1 [tool_swiftlint] completed, 70ms, truncated: false
Tool: swiftlint 0.65.1 [tool_swiftlint_metrics] completed, 61ms, truncated: false
Run: cg_6259708c-32a9-4208-8dac-f8da3309cbcb
1. ERROR
Rule: codeguard-build-execution-denied
Build and test execution was not authorized: ci-untrusted execution requires an attested isolated runner: no declaration is installed at the organisation's execution-environment path.
Grant project trust, or run inside an attested isolated environment.
Next action: Request human review for the reported findings.
The message names the reason. Then check the path, owner, permissions and exact content of the attestation, as well as the policy.
Exit codes in the workflow
| Exit | Meaning for the pull request |
|---|---|
| 0 | passed |
| 1 | blocking findings, see the report in the artifact |
| 2 | configuration or base reference wrong (e.g. fetch-depth missing) |
| 3 | tool, SDK, simulator runtime or dependency missing on the runner |
| 4 | tool error, see the report (codeguard-tool-output-unparseable) |
| 5 | time limit of a phase exceeded |
| 6 | attestation missing, protected file changed or path outside the allowed scope |
| 9 | incomplete installation or internal error |
| 64 | usage error in the invocation (unknown option) |
Simulators on the runner
For tests, CodeGuard creates disposable simulators in the runner user's default device set and deletes them afterwards. On an ephemeral runner, the remaining logs also disappear with the environment. If the job is aborted hard (timeout, cancellation), a device can be left behind. The next run on the same runner cleans it up. See Trust and security.