Skip to main content

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, and trust grant is forbidden.
  • compile and tests only 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-diff aborts 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.

  1. Install and select Xcode. xcodebuild must have a version >= 26.0.0 and < 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.

  2. Install CodeGuard:

    git clone <REPOSITORY-URL> /tmp/CodeGuard # placeholder: enter the repository URL
    cd /tmp/CodeGuard
    Scripts/install.sh # to /usr/local/bin, root:wheel
  3. 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
  4. Install the organization policy:

    # /Library/Application Support/CodeGuard/policy.yml
    policy_version: 1
    configuration_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/bin
    execution_environment:
    declaration_path: /Library/Application Support/CodeGuard/execution-environment.yml
    require_for_untrusted_builds: true
    sudo mkdir -p "/Library/Application Support/CodeGuard"
    sudo install -o root -g wheel -m 0644 policy.yml "/Library/Application Support/CodeGuard/policy.yml"

    Adjust trusted_roots if Xcode is not located at /Applications/Xcode.app.

  5. 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.yml
    sudo install -o root -g wheel -m 0644 execution-environment.yml \
    "/Library/Application Support/CodeGuard/execution-environment.yml"
    danger

    With 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.

  6. Check the image (as the user the runner runs as):

    codeguard version
    codeguard --ci config validate
    codeguard --ci doctor

    config validate must show policy: /Library/Application Support/CodeGuard/policy.yml (sha256:…), and doctor must show ready: true, swift-format: active and swiftlint: active.

Keychain on the runner

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.yml and, if needed, .swift-format and .swiftlint.yml to the root directory. CodeGuard loads .codeguard.yml automatically.
  • Note: .github/workflows/**, .codeguard.yml, Package.swift, Package.resolved, *.xcconfig, project.pbxproj and 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/checkout only clones the latest commit. Without the base revision, check-diff ends with exit 2 (unknown reference) or exit 3 (commit object missing).
  • Base: For pull_request, actions/checkout checks out the pull request's merge commit by default. check-diff --base <base.sha> therefore checks exactly the changes of the pull request.
  • set +e and exit $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.
Multiple formats

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​

ExitMeaning for the pull request
0passed
1blocking findings, see the report in the artifact
2configuration or base reference wrong (e.g. fetch-depth missing)
3tool, SDK, simulator runtime or dependency missing on the runner
4tool error, see the report (codeguard-tool-output-unparseable)
5time limit of a phase exceeded
6attestation missing, protected file changed or path outside the allowed scope
9incomplete installation or internal error
64usage 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.

See also​