| name | github-actions |
| description | GitHub Actions Best Practices guidance for Fortress Rollback. Use when Writing GitHub Actions workflows, CI debugging, actionlint, caching. |
GitHub Actions Best Practices
Mandatory: Run actionlint After Every Change
actionlint
actionlint .github/workflows/ci-security.yml
actionlint validates: YAML syntax, ${{ }} expressions, step/job references, contexts, and shell scripts via shellcheck.
Shellcheck Rules for run: Blocks
SC2193: Template Expressions in Conditionals
Assign ${{ }} to variables before using in conditionals (required for glob patterns, recommended for all):
- run: |
if [[ "${{ matrix.test_filter }}" == MULTI:* ]]; then echo "match"; fi
- run: |
test_filter="${{ matrix.test_filter }}"
if [[ "$test_filter" == MULTI:* ]]; then echo "match"; fi
Applies to all contexts: matrix.*, inputs.*, env.*, github.*, steps.*.outputs.*, needs.*.result.
Other Common Shellcheck Fixes
| Code | Issue | Fix |
|---|
| SC2086 | Unquoted variable | Quote: "$VAR" |
| SC2129 | Multiple individual redirects | { echo "a"; echo "b"; } >> file |
| SC2155 | export x=$(cmd) masks exit | x=$(cmd); export x |
| SC2028 | echo with escapes | Use printf |
| SC2035 | Unsafe glob patterns | Prefix with ./ |
| SC2016 | Variables in single quotes | Use double quotes, or suppress if intentional |
Intentional Suppressions
git config --global credential.helper '!f() { echo "password=${GITHUB_TOKEN}"; }; f'
Always add a comment explaining WHY. Be specific -- disable only the specific code.
GITHUB_OUTPUT and GITHUB_ENV
- name: Set outputs
id: step_id
run: |
{
echo "name=value"
echo "multi_line<<EOF"
echo "line 1"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set env vars for subsequent steps
run: echo "MY_VAR=value" >> "$GITHUB_ENV"
GitHub API Retry Patterns
- uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400 401 403 404 422
script: |
await github.rest.issues.createComment({ ... });
Never combine built-in retries with custom withRetry() functions (creates N*M retries).
Use continue-on-error: true only for non-critical steps (comments, labels, status updates).
Security
- run: echo "$PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
- run: |
git config --global credential.helper '!f() { echo "password=${GITHUB_TOKEN}"; }; f'
git config --global credential.https://github.com.username "x-access-token"
git clone "https://github.com/owner/repo.wiki.git" _wiki_clone
Directory Conflicts in Clone Operations
Clone directories must not conflict with existing repo directories. Use _ prefix:
| Avoid | Use Instead | Why |
|---|
wiki | _wiki_clone | wiki/ common in repos |
docs | _docs_clone | docs/ exists everywhere |
build | _build_temp | build/ used by many tools |
Or use $RUNNER_TEMP for complete isolation.
Workflow Structure
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
Rust Toolchain Reproducibility
Required jobs must not install a floating Rust nightly. Use the repository's
canonical dated nightly pin through its local retrying installer. Keep
component-sensitive Miri and Godot pins separate and dated. Release preparation
and publication likewise use the exact repository-pinned release compiler.
The installer owns bounded retries and emits the selected version; workflows
must not duplicate version literals or add ad hoc retry wrappers. Update a pin
only in a reviewed PR that exercises every consuming job.
Timeout Guidelines
| Job Type | Timeout | Rationale |
|---|
| Quick checks (fmt, clippy) | 5-10 min | Should be fast |
| Build & Test | 30 min | Standard compile + test |
| Miri (parallelized) | 20 min | ~1000-7000x slower |
| Kani verification | 60-120 min | SMT solving is slow |
| WASM builds | 15 min | Cross-compilation overhead |
| Docker builds | 15-30 min | Slow without cache |
jobs:
build:
timeout-minutes: 30
Caching
- uses: Swatinem/rust-cache@v2
with:
shared-key: "ci"
Or manual:
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
Miri in CI
Split tests across jobs for reasonable times. Use -Zmiri-many-seeds=0..4 for CI:
miri:
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- { name: "protocol", test_filter: "network::protocol" }
- { name: "sessions", test_filter: "sessions::" }
- { name: "core", test_filter: "MULTI:hash::|rng::|checksum::" }
Handle MULTI: prefix for multiple test groups:
- run: |
test_filter="${{ matrix.test_filter }}"
if [[ "$test_filter" == MULTI:* ]]; then
filters="${test_filter#MULTI:}"
IFS='|' read -ra FILTER_ARRAY <<< "$filters"
for filter in "${FILTER_ARRAY[@]}"; do
cargo miri test --lib -- "$filter"
done
else
cargo miri test --lib -- "$test_filter"
fi
Skip Miri-incompatible tests with #[cfg_attr(miri, ignore)] for tests using real time, sleep, or FFI.
Cross-Platform Shell Compatibility
- Use
$RUNNER_TEMP (not /tmp) for temp directories
- Use step-level
timeout-minutes: (not timeout command)
- Use
shell: bash explicitly for consistent behavior
- Use forward slashes in paths (work everywhere in bash)
Debugging
env:
ACTIONS_STEP_DEBUG: true
RUST_BACKTRACE: 1
- run: echo '${{ toJSON(github) }}'
- if: failure()
uses: actions/upload-artifact@v4
with:
name: debug-logs
path: test-output.log
Pre-Commit Checklist
Dependabot auto-merge gating
Auto-merge polls gh pr checks --required for the PR head SHA (the primary path). When required-check metadata is unavailable -- no required checks are configured for the branch, or the only required check is the auto-merge job itself -- it falls back to repos/{owner}/{repo}/commits/{sha}/check-runs and …/status. Both paths exclude the auto-merge job's own entries by filtering link/details_url/target_url against GITHUB_RUN_ID.
Failure classification uses an allow-list: only success, skipped, and neutral proceed; everything else (including failure, timed_out, cancelled, action_required, startup_failure, stale, missing/null, or future GitHub-added states) blocks the merge. skipped is allowed because matrix builds and conditional jobs legitimately produce it; neutral is allowed because GitHub-native advisory checks (e.g. dependency-review-action) emit it for non-failure findings.
Three regression guardrails in scripts/tests/test_enable_dependabot_automerge.py lock in this policy:
test_workflow_does_not_set_one_shot_env -- fails CI if the bypass env var is reintroduced.
test_workflow_run_command_is_pure_script_invocation -- fails CI if the script is wrapped in timeout, xargs, or any prefix command.
test_workflow_timeout_is_sufficient_for_polling -- fails CI if the workflow timeout-minutes falls below 32 minutes (the polling settle ceiling + a 2-minute buffer).
Branch protection is a defense layer, not a substitute. Configure main to require the relevant CI checks so GitHub's native auto-merge respects them too -- but the script's own gating remains the source of truth.