Docs/Engineering/CI Hardening
proposed

CI Hardening

GitHub Actions pipeline, static analysis, dependency pinning, review policy, and release gates for the aru-evm repo.

CI Hardening

Target repo: aru-evm · Pairs with: Invariant Test Suite, Deployment Ceremony

Why

Three of the six findings in the initial review would have been caught by CI that ran slither + forge test --match-path test/invariant/* on every PR. The cost is one GitHub Actions workflow. The benefit is preventing a high-severity bug from reaching mainnet.

GitHub Actions workflow

.github/workflows/ci.yml:

yamlname: CI

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - uses: foundry-rs/foundry-toolchain@v1
        with:
          version: stable

      - name: Forge build
        run: forge build --sizes

      - name: Forge tests
        run: forge test -vvv

      - name: Invariant tests (PR pass — 256 runs)
        if: github.event_name == 'pull_request'
        run: forge test --match-path 'test/invariant/*.t.sol' --invariant-runs 256

      - name: Invariant tests (main — 1024 runs)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: forge test --match-path 'test/invariant/*.t.sol' --invariant-runs 1024
        timeout-minutes: 30

      - name: Gas snapshot diff
        run: forge snapshot --check

  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - uses: foundry-rs/foundry-toolchain@v1

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Slither
        run: pip install slither-analyzer

      - name: Slither
        run: slither . --filter-paths 'lib|test' --fail-high

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Format check
        run: forge fmt --check

Required checks

In GitHub branch protection for main:

  • test must pass
  • static-analysis must pass
  • lint must pass
  • 2 approving reviews required
  • Dismiss stale reviews on push
  • Require branches to be up to date before merging
  • Restrict who can push to main (no direct pushes)

Dependency pinning

Submodules

lib/openzeppelin-contracts must track a specific tag, not master. Pin in .gitmodules:

ini[submodule "lib/openzeppelin-contracts"]
    path = lib/openzeppelin-contracts
    url = https://github.com/OpenZeppelin/openzeppelin-contracts
    branch = v5.0.2

Update policy: submodule bumps require their own PR, subject to the same CI, with a changelog note describing what's pulled in and why.

foundry.toml

Pin solc explicitly (already done at 0.8.24). Add EVM version:

toml[profile.default]
solc = "0.8.24"
evm_version = "cancun"
optimizer = true
optimizer_runs = 200
bytecode_hash = "none"  # reproducible bytecode

bytecode_hash = "none" makes compiled output deterministic across machines — auditors can reproduce the exact bytecode.

Gas snapshot

forge snapshot --check fails the PR if gas costs change. This catches:

  • Accidental while loops reintroduced
  • Unnecessary storage writes
  • Optimizer regressions

When a gas change is intentional, the snapshot is regenerated in the same PR and the diff is reviewed.

Slither config

slither.config.json:

json{
  "filter_paths": "lib|test|script",
  "exclude_informational": true,
  "exclude_optimization": false,
  "fail_on": "medium"
}

Any medium+ Slither finding fails CI. Low/informational findings are inspected on PR review but don't block.

Contract size check

Arbitrum inherits Ethereum's 24.576 KB contract size limit. forge build --sizes surfaces any contract approaching the limit — fail the build at 22 KB to leave headroom.

Add to CI:

yaml- name: Contract size check
  run: |
    forge build --sizes
    forge build --sizes | awk '/^\|/ && $NF+0 > 22000 { print "Contract too large: " $2 " at " $NF " bytes"; exit 1 }'

Release gates

Cutting a release tag (vX.Y.Z) requires:

  1. main branch green on all CI jobs
  2. Manual trigger of extended invariant pass: forge test --match-path 'test/invariant/*.t.sol' --invariant-runs 4096 --invariant-depth 200 — run on a CI worker with 60-minute budget
  3. Auditor sign-off if the release includes contract changes (CHANGELOG entry under "Contracts")
  4. Deployment log template filled in (see Deployment Ceremony)
  5. Tagged by release manager, not the PR author

Review policy

Change type Reviewers required
Contracts (src/*.sol) 2 engineers + 1 auditor for mainnet-bound changes
Tests 1 engineer
Scripts (script/*.sol) 2 engineers; script review before any mainnet run
foundry.toml / .gitmodules 2 engineers + explicit note in PR body on what's changing and why
CI workflows 1 engineer + 1 from ops

Auditor reviews are out-of-band (email / PDF) and referenced in the PR description.

Pre-merge checklist (PR template)

.github/pull_request_template.md:

markdown## Summary
<one paragraph>

## Contract impact
- [ ] No contract changes
- [ ] Additive-only (new function, new event — no change to existing behavior)
- [ ] Behavior change to existing function — requires auditor review
- [ ] State layout change — **requires migration plan**

## Test plan
- [ ] Added/updated unit tests
- [ ] Added/updated invariant tests (for accounting or settlement changes)
- [ ] Gas snapshot diff reviewed

## Deployment notes
<only if this requires ops action at next deploy>

What we're deliberately not adding

  • Mythril in CI. Slow (minutes per contract), high false-positive rate, noisy for a project this size. Run manually before major releases, not on every PR.
  • Foundry coverage in CI. forge coverage is slow and coverage % is a vanity metric for a contract of this size — invariant tests are the real safety net. Optional for release process.
  • Echidna. Overlaps with Foundry invariant tests. If the invariant suite stays disciplined, the marginal value of a second fuzzer is low. Revisit if we see invariant-test false negatives in practice.

Operational note

CI is one layer. It catches regressions. The Deployment Ceremony catches ceremony failures. The Threat Model documents what neither layer catches. All three must be current; drift in any one defeats the others.