Skip to content

Commit c9971e7

Browse files
authored
Deployment strategy (#1322)
1 parent 7ad4e8b commit c9971e7

4 files changed

Lines changed: 615 additions & 2 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Require Audit Label
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
types: [opened, labeled, unlabeled, synchronize]
7+
8+
jobs:
9+
check-label:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Get changed files
13+
id: changed
14+
uses: actions/github-script@v7
15+
with:
16+
script: |
17+
const { data: files } = await github.rest.pulls.listFiles({
18+
owner: context.repo.owner,
19+
repo: context.repo.repo,
20+
pull_number: context.issue.number,
21+
per_page: 100
22+
});
23+
24+
// Filter for .sol files, excluding tests
25+
const solFiles = files
26+
.map(f => f.filename)
27+
.filter(f => f.endsWith('.sol'))
28+
.filter(f => !f.includes('/test/'))
29+
.filter(f => !f.includes('/tests/'))
30+
.filter(f => !f.endsWith('.t.sol'));
31+
32+
console.log('Non-test Solidity files changed:', solFiles);
33+
core.setOutput('has_sol_files', solFiles.length > 0);
34+
core.setOutput('sol_files', solFiles.join('\n'));
35+
36+
- name: Check for required label
37+
if: steps.changed.outputs.has_sol_files == 'true'
38+
run: |
39+
echo "Solidity files changed (excluding tests):"
40+
echo "${{ steps.changed.outputs.sol_files }}"
41+
echo ""
42+
43+
LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}'
44+
if echo "$LABELS" | grep -q '"audited"'; then
45+
echo "✓ PR has 'audited' label"
46+
else
47+
echo "::error::This PR modifies Solidity contract files and must have the 'audited' label before merging to main."
48+
echo ""
49+
echo "If this code has been audited, add the 'audited' label to proceed."
50+
exit 1
51+
fi
52+
53+
- name: Skip check (no contract changes)
54+
if: steps.changed.outputs.has_sol_files == 'false'
55+
run: |
56+
echo "✓ No non-test Solidity files changed, skipping audit label check"

DEPLOYMENT.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Deployment Strategy
2+
3+
This document outlines the branching and deployment strategy for Solidity contracts in this repository.
4+
5+
## Overview
6+
7+
We use **per-environment deployment branches**. Each deploy to an environment gets its own `deployment/<env>/YYYY-MM-DD/<name>` branch, branched from `main`, used to run the deploy and capture artifacts, then fast-forward merged back. Every testnet and mainnet deploy is tagged as a self-contained snapshot. Testnet is staging, not development — see principle #4.
8+
9+
```mermaid
10+
flowchart LR
11+
main1["main<br/>(always audited)"]
12+
branch["deployment/&lt;env&gt;/YYYY-MM-DD/&lt;name&gt;<br/>branched from main"]
13+
deploy["deploy to &lt;env&gt;<br/>tag: deploy/&lt;env&gt;/YYYY-MM-DD/&lt;name&gt;"]
14+
merge["FF merge back to main<br/>delete branch"]
15+
main2["main"]
16+
17+
main1 -->|branch| branch
18+
branch --> deploy
19+
deploy --> merge
20+
merge --> main2
21+
```
22+
23+
A release typically flows through environments in sequence — local/scratch for development, then testnet, then mainnet — but each environment uses its own independent branch cut from `main`. There is no single long-lived branch cascading from testnet to mainnet; the audited `main` is the only shared substrate.
24+
25+
For hotfixes, branch from the tag in production instead of from `main`:
26+
27+
```
28+
deploy/mainnet/YYYY-MM-DD/<name> ──branch──► deployment/mainnet/YYYY-MM-DD/<name>-hotfix
29+
30+
├─► fix + audit
31+
├─► deploy ──► tag: deploy/mainnet/YYYY-MM-DD/<name>-hotfix
32+
└──PR──► merge back to main
33+
```
34+
35+
## Key Principles
36+
37+
1. **Work in feat branches.** Development happens in `feat/*` branches, merged to `main` when complete.
38+
39+
2. **`main` is always audited.** PRs modifying production Solidity require the `audited` label to merge to `main`.
40+
41+
3. **Deployment branches are per-environment.** Each deploy creates a `deployment/<env>/YYYY-MM-DD/<name>` branch (e.g. `deployment/testnet/2026-04-19/rewards-manager-and-subgraph-service`), branched from `main`. For testnet and mainnet the branch is short-lived (hours to days) and carries only artifacts and script tweaks — no contract changes. For scratch it may persist longer and host Solidity iteration, with any changes reaching `main` only via a `feat/*` PR.
42+
43+
4. **Testnet is staging, not development.** Testnet is a pre-production mirror of mainnet, not a place to iterate on contract design. Redeploying updated contracts to testnet pollutes its historical state and can force custom off-chain handling (e.g. subgraph code for events that were later removed pre-mainnet) that then has to be maintained indefinitely. Iterate on the local network or scratch deployments instead (see [Pre-deployment Testing](#pre-deployment-testing)); graduate to testnet only with high confidence that the same contracts will reach mainnet.
44+
45+
5. **Hotfix branches are branched from the tag they patch.** A hotfix branches from the `deploy/mainnet/YYYY-MM-DD/<name>` tag currently in production, not from `main`. Keeps the hotfix diff minimal and avoids shipping accumulated but undeployed work on `main`.
46+
47+
6. **Tag every testnet and mainnet deploy.** Each deploy to testnet or mainnet creates an immutable `deploy/<env>/YYYY-MM-DD/<name>` tag reproducing the full state at that moment: source, scripts, and artifacts. The tag is the release record. Tagging in other environments (e.g. scratch) is optional and used at the operator's discretion — useful when a scratch state is worth pinning, unnecessary for throwaway iteration.
48+
49+
7. **Prefer rebase and FF merge for testnet and mainnet.** Testnet and mainnet branches should FF back to `main` to preserve the audit-hash → deployed-bytes link. If `main` advances during the deploy window, rebase before merging.
50+
51+
## Branches
52+
53+
| Branch | Purpose | Lifetime |
54+
| ------------------------------------ | ------------------------------------------- | -------------------------------------------------------------------------- |
55+
| `feat/*` | Active development | Until merged to `main` |
56+
| `main` | Audited, deployment-ready code | Permanent |
57+
| `deployment/<env>/YYYY-MM-DD/<name>` | Workspace for one deploy to one environment | Hours to days for testnet/mainnet; may persist for scratch while iterating |
58+
59+
Environments in active use today are `testnet` (Arbitrum Sepolia) and `mainnet` (Arbitrum One). The scheme accommodates additional environments (e.g. a dedicated pre-release staging chain) by adding further `<env>` tokens — no change to the mechanics.
60+
61+
## Tags
62+
63+
Testnet and mainnet deploys are always tagged with an immutable annotated tag. Other environments may be tagged at operator discretion using the same format.
64+
65+
- `deploy/testnet/YYYY-MM-DD/<name>` — testnet deployment snapshot (Arbitrum Sepolia)
66+
- `deploy/mainnet/YYYY-MM-DD/<name>` — mainnet deployment snapshot (Arbitrum One)
67+
68+
Including a descriptive `<name>` is recommended. A short hyphenated identifier (e.g. `rewards-manager-and-subgraph-service`, `fix-activation`) makes tags self-describing, gives operators something meaningful to search on, and naturally prevents collisions when multiple deploys happen on the same day. The date segment ensures chronological sort regardless.
69+
70+
Each tag is self-contained: its tree includes the deployed `.sol` sources, the deployment scripts used, and the resulting artifacts (`addresses.json`, etc.). The annotated tag body additionally records deployer identity and the list of changed contracts. Reproducing a past deploy is `git checkout <tag>` and nothing else.
71+
72+
### Finding and working with deployed code
73+
74+
Check out what's currently on mainnet:
75+
76+
```bash
77+
git checkout "$(git tag -l 'deploy/mainnet/*' | sort | tail -1)"
78+
```
79+
80+
Check out what's currently on testnet:
81+
82+
```bash
83+
git checkout "$(git tag -l 'deploy/testnet/*' | sort | tail -1)"
84+
```
85+
86+
List all deployment tags:
87+
88+
```bash
89+
git tag -l "deploy/*"
90+
```
91+
92+
Diff between last mainnet deploy and current main:
93+
94+
```bash
95+
git diff "$(git tag -l 'deploy/mainnet/*' | sort | tail -1)"..main
96+
```
97+
98+
List active deployment branches (per environment):
99+
100+
```bash
101+
git branch -a --list 'deployment/testnet/*'
102+
git branch -a --list 'deployment/mainnet/*'
103+
```
104+
105+
## Workflows
106+
107+
### Pre-deployment Testing
108+
109+
Iteration on contract design happens on environments that don't pollute the canonical testnet state:
110+
111+
- **Local network**: a self-contained network run locally as docker containers, bundling chain node, contracts, and off-chain services. The default for development and integration testing.
112+
- **Scratch deployments**: a fresh, separate protocol instance on Arbitrum Sepolia — same chain as the canonical testnet, but distinct protocol instance. A `deployment/scratch/...` branch may persist across multiple iterations and carry contract changes as development progresses; anything worth keeping lands on `main` via a `feat/*` PR, and the scratch branch can be discarded.
113+
114+
The deployment scripts are written to be network- and instance-agnostic, so the same code path runs against local, scratch, testnet, and mainnet. A release only graduates to testnet once local and scratch testing give high confidence that no further contract changes are needed.
115+
116+
Terminology: "testnet" always refers to the canonical Graph Protocol testnet instance on Arbitrum Sepolia. A scratch deployment on Sepolia is not "testnet" — same chain, different protocol instance.
117+
118+
### Testnet and Mainnet Deployment
119+
120+
Testnet deploys happen against a release already expected to reach mainnet unchanged (principle #4). Mainnet follows with the same contract source; typical differences between the two deploys are artifact files and operational parameters that vary by environment.
121+
122+
The two deploys use the same procedure, each on its own branch cut from the current `main` (the mainnet branch is cut after the testnet merge-back, so it already includes testnet artifacts):
123+
124+
1. **Branch.** From current `main`, create `deployment/<env>/YYYY-MM-DD/<name>` and push it. Open a tracking PR back to `main`.
125+
2. **Deploy.** Run the deployment scripts against the target network. Commit artifacts, push.
126+
3. **Tag.** Run `tag-deployment.sh --network <network> --name <name> ...` to create `deploy/<env>/YYYY-MM-DD/<name>`. Push the tag.
127+
4. **Merge.** Fast-forward merge the PR back into `main`. Delete the branch. If `main` advanced during the window, rebase before merging.
128+
129+
Network mapping: testnet → `arbitrumSepolia`, mainnet → `arbitrumOne`.
130+
131+
### Emergency Hotfix
132+
133+
For critical mainnet issues:
134+
135+
1. Branch `deployment/mainnet/YYYY-MM-DD/<name>-hotfix` from the current `deploy/mainnet/YYYY-MM-DD/<name>` tag and push it.
136+
2. Apply the fix. If it touches contract source, it must be audited before deploy. Commit and push; open a PR back to `main` at this point — it stays open for the duration of the hotfix as the review/tracking thread and becomes the merge-back PR.
137+
3. Run the deployment scripts against mainnet. If the fix warrants pre-mainnet verification, run it against the local network or a scratch deployment first (per [Pre-deployment Testing](#pre-deployment-testing)) rather than cutting a separate testnet deploy, which would otherwise race the mainnet hotfix. Commit artifacts and push.
138+
4. Run `tag-deployment.sh --network arbitrumOne --name <name>-hotfix ...` to create the `deploy/mainnet/YYYY-MM-DD/<name>-hotfix` tag. Push the tag.
139+
5. Review and merge the open PR back into `main`. The `audited` label applies to any contract changes in this PR.
140+
6. Delete the hotfix branch.
141+
7. If other deployment branches are active at hotfix time, incorporate the hotfix into them (rebase or cherry-pick) before their deploys.
142+
143+
## Audit Integrity
144+
145+
Audits certify that specific files have specific content. The operational question is always:
146+
147+
> For every file in the audit scope, do its current bytes match the audited version's bytes?
148+
149+
Principles #2, #3, and #7 preserve this for testnet and mainnet by construction: audited bytes reach `main` via `feat/*` PRs, their deployment branches carry no contract changes, and FF merges keep the audit-hash → deployed-bytes link intact. Scratch branches may hold in-progress contract work, but none of it reaches testnet or mainnet without first landing on audited `main`.
150+
151+
The audit scope is a transitive closure — a reviewed contract's imports are implicitly in scope even if the PR didn't touch them — and the audit reference is a pinned commit SHA, not a PR number or label. A CI check can back up this cultural preference with a mechanical one: diff the audited paths between the last audit tag and `HEAD`, and require either an empty diff or a fresh audit. See [Appendix A: Audit Integrity CI Check](#appendix-a-audit-integrity-ci-check).
152+
153+
## Automation
154+
155+
### Tagging
156+
157+
Tag creation is a **scripted operator step**, run after the deploy. The script captures context a CI workflow couldn't — which deploy script ran, with what flags, by whom, which contracts changed — baked into an annotated tag body, optionally signed.
158+
159+
Implementation: [`packages/deployment/scripts/tag-deployment.sh`](packages/deployment/scripts/tag-deployment.sh). It takes `--deployer`, `--network`, `--name` (recommended), and `--base`; diffs each address book (`packages/horizon/addresses.json`, `packages/subgraph-service/addresses.json`, `packages/issuance/addresses.json`) against the base ref to enumerate new / updated / removed contracts; and creates the annotated tag in the `deploy/<env>/YYYY-MM-DD/<name>` format defined above (or the bare-date fallback when no name is given).
160+
161+
Typical invocation after the artifact commit is pushed:
162+
163+
```bash
164+
packages/deployment/scripts/tag-deployment.sh \
165+
--deployer "packages/deployment --tags RewardsManager,SubgraphService" \
166+
--network arbitrumSepolia \
167+
--name rewards-manager-and-subgraph-service
168+
```
169+
170+
The script prints a preview (tag name, commit, annotation body), asks for confirmation, and creates a signed annotated tag. Run `tag-deployment.sh --help` for the full option list (`--dry-run`, `--yes`, `--no-sign`, `--base`, …).
171+
172+
Then push:
173+
174+
```bash
175+
git push origin <tag>
176+
```
177+
178+
The diff against `--base` is what populates the tag body's "contracts" section. The default of the previous deploy tag for the same environment is normally correct. For an initial deploy on an environment (no prior tag exists), pass `--base` explicitly.
179+
180+
### Audit Label Requirement
181+
182+
PRs to `main` modifying Solidity contract files require an `audited` label before merging (`.github/workflows/require-audit-label.yml`).
183+
184+
- **Applies to:** `.sol` files outside of test directories
185+
- **Excludes:** Files in `/test/`, `/tests/`, or ending in `.t.sol`
186+
- **Label:** `audited`
187+
188+
This enforces principle #2: code in `main` must be audited.
189+
190+
## Appendix A: Audit Integrity CI Check
191+
192+
A future workflow to enforce the byte-equality property at CI level rather than relying on the cultural FF-preference. Sketched here; design decisions still to make before implementation.
193+
194+
### Approach
195+
196+
1. **Audit tags.** Each completed audit produces an annotated tag of the form `audit/YYYY-MM-DD/<scope-name>` pointing at the commit the auditors signed off on. The tag body records the auditor, the scope (which files/paths), and a link to the audit report.
197+
2. **Scope definition.** The "audit scope" is the set of file paths the auditors reviewed, together with the transitive closure of their Solidity imports. Stored as a path list (or glob) in the audit tag's annotation body so it can be parsed programmatically.
198+
3. **CI check.** On every PR to `main` (or every push to `deployment/*`), resolve the most recent `audit/*` tag that covers each in-scope file and compute `git diff <audit-tag> HEAD -- <scoped-paths>`. If non-empty for any in-scope file, require either:
199+
- The PR to carry the `audited` label (operator asserts the diff has been re-reviewed), or
200+
- A new `audit/*` tag to land that covers the current `HEAD` for those paths.
201+
4. **Empty diff ⇒ automatic pass.** When the audited bytes on `HEAD` match the audit tag's bytes exactly for all in-scope files, no human intervention is needed — the CI proves trivially that `HEAD` still matches what was audited.
202+
203+
### Open design decisions
204+
205+
- **Where does "audit scope" live?** Most robust: in the `audit/*` tag body as a path list. Alternative: a checked-in `audits/manifest.json`. The tag-body approach keeps the scope immutable alongside the reference commit; the file approach is easier to edit when scopes overlap or evolve.
206+
- **Multi-audit composition.** Different contracts may be covered by different audits. The CI needs a deterministic "most recent audit covering file X" lookup. Overlapping scopes require conflict resolution (most specific wins? most recent?).
207+
- **Transitive closure computation.** For `.sol` files, the importer graph is machine-derivable. A pre-commit or CI step should expand a human-declared scope (e.g. "the `IssuanceAllocator` contract") into the full transitive closure, so scope drift (an import added after audit) is caught automatically.
208+
- **Path inclusion/exclusion rules.** The current `require-audit-label.yml` excludes `/test/`, `/tests/`, and `*.t.sol`, but there are other helper, mock, and internal-only contracts that aren't audit targets (migration scaffolding, local fixtures, temporary scripts). A robust check needs either an explicit in-scope list or a clearer directory convention.
209+
210+
### Prerequisite: reorganize non-production Solidity
211+
212+
The current tree mixes production contracts with helpers, mocks, and internal tooling in the same directories. Before the CI check is meaningful:
213+
214+
- Move non-production Solidity into clearly-named directories outside any plausible audit scope (e.g. `mocks/`, `helpers/`, `scripts/`, a top-level `non-audit/` tree per package).
215+
- Make audit scope a directory-level property wherever possible ("everything under `packages/<pkg>/contracts/` is audit scope; nothing else is") so that inclusion is inferrable from path rather than requiring a bespoke filter.
216+
- Update `require-audit-label.yml`'s filter in the same pass so its exclusions match the new layout.
217+
218+
Until this reorganization lands, an audit-integrity CI check is possible but would rely on hand-maintained path lists — fragile and easy to drift from reality. The reorganization is low-risk refactoring but should be done in its own PR (itself audited for scope equivalence), separately from adopting this deployment proposal.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,10 @@ See [docs/Linting.md](docs/Linting.md) for detailed configuration, inline suppre
178178

179179
## Documentation
180180

181-
> Coming soon
181+
- [Deployment Strategy](DEPLOYMENT.md) — Branching model and deployment workflow for Solidity contracts
182+
- [Linting](docs/Linting.md) — Linting configuration and troubleshooting
182183

183-
For now, each package has its own README with more specific documentation you can check out.
184+
Each package also has its own README with package-specific documentation.
184185

185186
## Contributing
186187

0 commit comments

Comments
 (0)