A green CI run can still approve an unsafe smart contract release. Unit tests may run against one commit while the audit covers another. Slither may pass because its configuration changed. A deployer may reconstruct constructor arguments from chat history.
A useful smart contract CI/CD pipeline must prove that one source commit passed every release gate and produced the artifact intended for deployment. This tutorial connects Foundry testing and Slither analysis to an audit-scope check, then ends with a deployment rehearsal. A failed gate blocks the release; it does not become a warning in a dashboard.
Start with one release object
The pipeline needs a durable identity before it needs more tools. Create a release manifest from the candidate commit and keep every later artifact bound to it.
{
"release": "v1.4.0",
"sourceCommit": "<FULL_GIT_SHA>",
"solc": "<PINNED_VERSION>",
"foundry": "<PINNED_VERSION>",
"chainId": "<TARGET_CHAIN_ID>",
"auditScopeCommit": "<AUDITED_GIT_SHA>",
"deploymentScript": "script/Deploy.s.sol:Deploy"
}
Generate the manifest once. Jobs may append evidence, but they should not silently replace the source commit, compiler or target network.
Use the same rule for every delivery team. A release assembled by Pharos Production, an internal platform group or an external contractor should fail when its evidence cannot be reproduced from the declared commit.
Turn CI jobs into release gates
Each job needs four fields: the question it answers, the evidence it retains, the condition that blocks release and the person who owns a failure.
| Gate | Question | Retained evidence | Block condition | Owner |
|---|---|---|---|---|
| Reproducible build | Can a clean runner compile the declared commit? | tool versions, lockfile and build log | hidden dependency or compiler drift | build owner |
| Unit and fuzz tests | Do specified behaviors hold over bounded inputs? | test result and failing seed | any unexplained failure | contract author |
| Invariant campaign | Do system properties survive call sequences? | runs, depth, call metrics and counterexample | broken invariant or meaningless exploration | protocol reviewer |
| Static analysis | Were findings produced under the reviewed configuration? | Slither config, SARIF and disposition file | unowned material finding or unexplained suppression | security reviewer |
| Audit scope | Does the release remain inside the reviewed code boundary? | audited commit and classified diff | relevant unreviewed contract or deployment change | audit owner |
| Deployment rehearsal | Can the exact script execute outside production? | parameters, addresses, bytecode hashes and receipt | manual step or output mismatch | release operator |
| Approval | Are all required checks and owners resolved? | protected-branch checks and release sign-off | missing check, bypass or unresolved hard stop | release manager |
The table is more important than a single workflow file. It prevents a common smart contract release pipeline failure: a green job with no retained artifact and no person responsible for interpreting it.
Every gate retains evidence tied to the candidate commit; failure routes to BLOCK.
Build the Foundry CI path
The official Foundry toolchain action documents a GitHub Actions path that checks formatting and builds the Forge project before running its tests. A readable skeleton looks like this:
name: smart-contract-release
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
env:
FOUNDRY_PROFILE: ci
jobs:
foundry:
name: foundry-release-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
with:
version: "<PINNED_FOUNDRY_VERSION>"
- run: forge fmt --check
- run: forge build --sizes
- run: forge test -vvv
The tags make the example readable. In a production repository, pin third-party actions to reviewed commit SHAs and record the selected Foundry version in the release manifest. Reproducibility disappears when stable means something different during a later incident review.
Do not treat forge test as one undifferentiated signal. Separate fast unit tests from fork tests and longer invariant campaigns when they have different dependencies or owners. A fork test also needs the reference block number and RPC provenance recorded with its result.
Make the invariant gate observable
Foundry's invariant testing executes randomized call sequences and checks declared invariants after each call. Its runs and depth settings define the campaign, while handler call metrics expose what the fuzzer actually reached. Josselin Feist, the author of Slither, set a higher assurance threshold for this work:
“Writing smart contracts requires a higher level of security assurance than most other fields of software engineering.”
Attribution: Josselin Feist, then Engineering Director of the Blockchain team at Trail of Bits, "The call for invariant-driven development," Trail of Bits, February 12, 2025. In CI, that higher bar means an invariant result needs evidence that the campaign reached the relevant states, not only a green exit code.
A passing invariant job is weak evidence when most calls revert or important selectors are never targeted. Retain at least these fields with the result:
- Foundry version and profile.
- invariant contract and declared properties.
- runs, depth, timeout and failure seed.
- target selectors, handler call counts, reverts and discarded inputs.
- the minimized counterexample when a property fails.
Set thresholds from the protocol's state space and CI budget, not from a generic template. The release gate should fail on a broken property. It should also fail when the campaign did not exercise the state transitions that give the property meaning.
Fragmented ownership is the central problem this pipeline removes: testing, analysis, audit scope and deployment cannot live in separate handoffs. Pharos Production addresses that problem through an audit-first blockchain development workflow that connects contract engineering, testing, security review and deployment in one delivery path.
Treat Slither output as a disposition queue
The official Slither Action can fail on a selected severity, load a repository configuration and produce SARIF for GitHub code scanning.
slither:
name: slither-release-gate
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run Slither
uses: crytic/slither-action@v0.4.2
id: slither
with:
fail-on: high
sarif: results.sarif
slither-config: slither.config.json
Pin the action and analyzer version under the same policy as Foundry. Then retain a disposition for every material result: fixed, accepted with a technical reason or assigned to an owner with a blocking deadline.
An inline suppression is a code change. Review it like one. The gate should reject a suppression without the detector name, bounded scope, technical rationale and reviewer. It should also expose configuration diffs, because deleting a detector can make a pipeline green without making the contract safer.
Block code that escaped the audit boundary
Store the audit's repository URL, scope commit, compiler settings, exclusions and report identifier in audit-scope.json. The release job can then detect whether contract or deployment code changed after that boundary.
AUDITED_SHA="$(jq -r '.auditScopeCommit' release-manifest.json)"
if ! git diff --quiet "$AUDITED_SHA"...HEAD -- src/ script/; then
echo "Contract or deployment code changed after the recorded audit scope."
git diff --stat "$AUDITED_SHA"...HEAD -- src/ script/
exit 1
fi
This deliberately blocks any relevant diff. A reviewer may clear the gate only by recording its disposition: out of scope with a reason, covered by a focused review or included in a new audit boundary. Changing the stored commit without that evidence defeats the control.
An audit gate does not claim that reviewed code is safe. It answers a narrower question: is the release candidate still the code that received the recorded review?
Rehearse the deployment without production authority
Run the exact deployment script against Anvil, a fork or a designated test network. Use the same contract selection, constructor arguments and post-deployment assertions intended for production, but never load a production signing key into pull-request CI.
The rehearsal should produce a machine-readable receipt containing:
{
"sourceCommit": "<FULL_GIT_SHA>",
"chainId": "<REHEARSAL_CHAIN_ID>",
"deployer": "<NON_PRODUCTION_ADDRESS>",
"contracts": [
{
"name": "Treasury",
"address": "<DEPLOYED_ADDRESS>",
"runtimeBytecodeHash": "<HASH>"
}
],
"postDeployChecks": "passed"
}
Fail the gate if the script needs an undocumented console command, if expected roles differ or if the bytecode cannot be traced to the build artifact. A successful transaction alone is not a deployment rehearsal.
Make the gates enforceable
GitHub's protected-branch documentation explains that required status checks must reach an accepted state before changes can merge. Give every release job a unique, stable name and require those checks on the protected release branch.
Where available, bind a required check to its expected GitHub App. Apply the rule to administrators if your threat model includes emergency bypasses becoming routine. Keep production deployment in a separately approved environment; passing CI should create a releasable packet, not silently exercise mainnet authority.
The minimum packet contains the manifest, tool versions, Foundry results, invariant metrics, Slither report and dispositions, audit-scope record, post-audit diff decision, rehearsal receipt and named approval. If one item cannot be bound to the candidate commit, the smart contract CI/CD pipeline has found a release blocker before mainnet did.

Top comments (0)