A comprehensive guide for optimizing GitHub Actions workflows, with special focus on Swift/Xcode projects.
- Cost Optimization
- Caching Strategies
- Job Dependencies & Artifacts
- Conditional Execution
- Concurrency & Timeouts
- CodeQL Security Analysis
- Swift Build Plugin Issues
- Reusable Workflows
- Complete Example Workflow
| Runner | Cost Multiplier | Use Cases |
|---|---|---|
ubuntu-latest |
1x (baseline) | Changelog, docs deploy, simple scripts |
macos-15 |
10x | Swift builds, Xcode projects |
macos-15-xlarge |
20x | Heavy parallel builds only |
- Move cheap jobs to Linux: Changelog generation, GitHub Pages deployment, and simple scripts should use
ubuntu-latest - Single build, multiple consumers: Build once and share artifacts between jobs
- Cancel redundant runs: Use concurrency groups with
cancel-in-progress: truefor PRs - Skip unnecessary work: Use path filters and conditional execution
- name: Cache SPM Dependencies
uses: actions/cache@v4
with:
path: |
.build
~/Library/Developer/Xcode/DerivedData
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-- Use
Package.resolvedhash for cache key (notPackage.swift) - Include
restore-keysfor partial cache hits - Cache DerivedData for Xcode builds
- Use
save-always: trueto preserve cache even on failure
jobs:
build:
runs-on: macos-15
steps:
- name: Build
run: swift build -c release
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: build-release
path: .build/release/your-binary
retention-days: 1 # Minimize storage costs
test:
needs: build
runs-on: macos-15
steps:
- name: Download Artifact
uses: actions/download-artifact@v4
with:
name: build-release
path: .build/release
- name: Make Executable
run: chmod +x .build/release/your-binaryjobs:
prepare:
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- id: version
run: echo "version=1.0.0" >> $GITHUB_OUTPUT
release:
needs: prepare
steps:
- run: echo "Version is ${{ needs.prepare.outputs.version }}"jobs:
release:
if: startsWith(github.ref, 'refs/tags/v')
docs:
if: github.ref == 'refs/heads/main'
pr-only:
if: github.event_name == 'pull_request'on:
push:
paths:
- 'Sources/**'
- 'Tests/**'
- 'Package.swift'
paths-ignore:
- '**.md'
- 'docs/**'if: "!contains(github.event.head_commit.message, '[skip ci]')"concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}- PRs: Cancel previous runs (save resources)
- Main/Tags: Let runs complete (ensure releases finish)
jobs:
build:
timeout-minutes: 30
steps:
- name: Long Step
timeout-minutes: 10
run: ...Important: CodeQL Action v3 will be deprecated in December 2026. Use v4.
CodeQL requires manual build mode for Swift projects using Xcode 16+:
codeql:
name: CodeQL Analysis
runs-on: macos-15
permissions:
security-events: write
actions: read
contents: read
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.1.1.app
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformat
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: swift
build-mode: manual
- name: Build for CodeQL
run: swift build -c release --arch arm64
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:swift"- Swift 6.2 support: Requires CodeQL 2.23.5+ (November 2025)
- Xcode 26 support: Requires CodeQL 2.23.5+
- Single architecture: Use
--arch arm64for faster builds - Manual build mode: Required for modern Xcode versions
- Use v4: CodeQL Action v3 deprecated December 2026
Build plugins may fail to download tools in CI due to:
- Network restrictions in sandboxed environments
- DNS resolution failures
- Download server unavailability
Check if tools exist before installing to avoid redundant messages:
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformatWhy this works:
command -vchecks if executable exists in PATH- Only runs
brew installif tool is missing - No redundant "already installed" messages
- Silent success if tools exist
SwiftLint may fail in CI with errors like:
warning: Cannot create cache: You don't have permission to save the file...
Error: The folder ... doesn't exist.
Error: Process completed with exit code 1.
This happens because SwiftLint tries to write cache files in sandboxed environments where it lacks permissions.
Add --no-cache and --force-exclude flags to SwiftLint arguments:
// In your build plugin
var arguments = [
"lint",
"--quiet",
"--no-cache", // Prevents cache permission errors in CI
"--force-exclude", // Ensures exclusions work with direct file paths
"--reporter", "xcode"
]Why these flags matter:
--no-cache: Prevents SwiftLint from writing to cache directories (which may be read-only in CI sandboxes)--force-exclude: Ensures.swiftlint.ymlexclusions are respected even when files are passed directly as arguments
For build plugins that download binaries, check PATH first:
private func findInPath(_ executable: String) -> URL? {
let searchPaths = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
]
let envPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
let allPaths = searchPaths + envPath.split(separator: ":").map(String.init)
for dir in allPaths {
let fullPath = URL(fileURLWithPath: dir).appendingPathComponent(executable)
if FileManager.default.isExecutableFile(atPath: fullPath.path) {
return fullPath
}
}
return nil
}- SwiftLint: 0.62.0 (requires Swift 6+ compiler to build)
- SwiftFormat: 0.58.7
# .github/workflows/reusable-build.yml
name: Reusable Build
on:
workflow_call:
inputs:
configuration:
type: string
default: 'release'
secrets:
DEPLOY_KEY:
required: false
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: swift build -c ${{ inputs.configuration }}jobs:
call-build:
uses: ./.github/workflows/reusable-build.yml
with:
configuration: release
secrets: inheritA unified CI/CD workflow for Swift projects:
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
inputs:
release:
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
pages: write
id-token: write
security-events: write
env:
XCODE_PATH: /Applications/Xcode_26.1.1.app
jobs:
# Stage 1: Build (single build, shared artifacts)
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s ${{ env.XCODE_PATH }}
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformat
- uses: actions/cache@v4
with:
path: |
.build
~/Library/Developer/Xcode/DerivedData
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
- run: swift build -c release
- uses: actions/upload-artifact@v4
with:
name: build
path: .build/release/
retention-days: 1
# Stage 2: Parallel validation (all depend on build)
test:
needs: build
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s ${{ env.XCODE_PATH }}
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformat
- uses: actions/cache@v4
with:
path: .build
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
- run: swift test --parallel
codeql:
needs: build
runs-on: macos-15
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s ${{ env.XCODE_PATH }}
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformat
- uses: github/codeql-action/init@v4
with:
languages: swift
build-mode: manual
- run: swift build -c release --arch arm64
- uses: github/codeql-action/analyze@v4
# Stage 3: Release (only on tags)
release:
needs: [test, codeql]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: build
- uses: softprops/action-gh-release@v2
with:
files: '*.tar.gz'| Goal | Solution |
|---|---|
| Reduce costs | Use ubuntu-latest for non-Swift jobs |
| Faster builds | Cache SPM dependencies with Package.resolved hash |
| Security scanning | Use CodeQL v4 with build-mode: manual for Swift 6+ |
| Avoid duplicate work | Share artifacts between jobs via actions/upload-artifact |
| Cancel stale runs | Use concurrency groups with cancel-in-progress |
| Skip docs-only changes | Use paths and paths-ignore filters |
| Fix plugin download issues | Use command -v check before brew install |
| Avoid redundant installs | command -v tool >/dev/null 2>&1 || brew install tool |
| Fix SwiftLint cache errors | Use --no-cache --force-exclude flags in plugins |
Last updated: December 2025 Generated with Claude Code