Skip to content

Commit d9c1641

Browse files
marc0oloclaude
andauthored
docs(AGENTS.md): test.sh over Makefile, ic-cdk 0.20 guidance, Rust env vars (#1422)
* docs(AGENTS.md): test.sh over Makefile, ic-cdk 0.20, Rust env vars Makefile → test.sh: - Rename section and update all templates/checklist references - Use #!/usr/bin/env bash + set -e; plain $var syntax; no @/$$ - Add delta-based balance check pattern for idempotency - Update CI workflow template and README structure template Rust additions: - Always use ic-cdk 0.20; ic-ledger-types 0.16 required for 0.20 compat (0.15 + 0.20 causes ic-cdk-executor links conflict) - ic_cdk::export_candid!() required at end of every lib.rs - icp-dev-env-rust:1.0.1+ required (0.1.4 candid-extractor fails on ic0::cost_call imported by ic-cdk ≥ 0.19) - ic_cdk::api::env_var_value for runtime env vars (not env!() or std::env::var()) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(AGENTS.md): use icp canister top-up command directly in README (no script) * docs(AGENTS.md): clarify 30t is an example cycle amount, not a fixed requirement * docs(AGENTS.md): pin all dev-env images to v1.0.1 * docs(AGENTS.md): add per-environment canister config guidance and checklist item --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5736146 commit d9c1641

1 file changed

Lines changed: 118 additions & 63 deletions

File tree

‎AGENTS.md‎

Lines changed: 118 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Follow the `hello_world` layout. New examples and migrations should use this pat
5555
```
5656
<language>/<example_name>/
5757
├── icp.yaml # canister definitions (icp-cli project file)
58-
├── Makefile # must contain a `test` target
58+
├── test.sh # executable bash test script
5959
├── README.md
6060
├── package.json # npm workspaces root pointing to frontend/
6161
├── mops.toml # Motoko only
@@ -143,10 +143,47 @@ canisters:
143143
```
144144
145145
- With `candid:` specified: the recipe reads the committed `.did` file and embeds it as WASM metadata (no `candid-extractor` needed).
146-
- Without `candid:`: `candid-extractor` (available in `icp-dev-env-rust:0.3.2+`) extracts the interface directly from the compiled WASM. For backend-only examples, omit `candid:` and do not commit `backend.did`.
146+
- Without `candid:`: `candid-extractor` extracts the interface directly from the compiled WASM. For backend-only examples, omit `candid:` and do not commit `backend.did`.
147147

148148
**Canister names are always `backend` and `frontend`.** Never use names like `<example>_backend`, `internet_identity_app_backend`, etc.
149149

150+
### Per-environment canister configuration
151+
152+
When an example interacts with a well-known external canister whose principal differs by environment (e.g. the ICP ledger mainnet vs. TESTICP on staging), use the `environments` block to inject the right value per deployment rather than hardcoding it:
153+
154+
```yaml
155+
canisters:
156+
- name: backend
157+
recipe:
158+
type: "@dfinity/rust@v3.3.0"
159+
160+
environments:
161+
- name: local
162+
network: local
163+
settings:
164+
backend:
165+
environment_variables:
166+
ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai"
167+
168+
- name: staging
169+
network: ic
170+
settings:
171+
backend:
172+
environment_variables:
173+
ICP_LEDGER_CANISTER_ID: "xafvr-biaaa-aaaai-aql5q-cai" # TESTICP
174+
175+
- name: production
176+
network: ic
177+
settings:
178+
backend:
179+
environment_variables:
180+
ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai"
181+
```
182+
183+
icp-cli injects these as WASM metadata before `cargo build`. Read them at **runtime** via `ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID")` (Rust) or `Runtime.envVar<system>("ICP_LEDGER_CANISTER_ID")` (Motoko) — not via `env!()` or `std::env::var()`.
184+
185+
**When to apply this pattern**: judge whether staging/production environments make sense for the specific example. Apply it when the example hardcodes an external canister principal that has a test counterpart (TESTICP, testnet URLs, staging governance canisters, etc.). Skip it for self-contained examples that don't call external canisters.
186+
150187
### Motoko naming conventions
151188

152189
- **Top-level actor:** name it after its logical role, not generically — e.g. `actor TodoList`, `actor CanisterFactory`, not `actor Backend`.
@@ -229,6 +266,25 @@ candid = "0.10"
229266
ic-cdk = "0.20"
230267
```
231268

269+
Always use `ic-cdk = "0.20"` unless a dependency forces a lower version. When using `ic-ledger-types`, use `"0.16"` (requires `ic-cdk = "^0.19"`) — version `0.15` pins `ic-cdk = "^0.18"` which conflicts with `0.20` via an `ic-cdk-executor` `links` constraint.
270+
271+
**`ic_cdk::export_candid!()` is required** at the end of every Rust canister `lib.rs`. Without it, `candid-extractor` cannot find the `get_candid_pointer` export and the build fails.
272+
273+
**CI image**: use `ghcr.io/dfinity/icp-dev-env-rust:1.0.1` or later. Earlier images bundle `candid-extractor 0.1.4` which fails with `Error: unknown import: ic0::cost_call` for any WASM compiled with ic-cdk ≥ 0.19.
274+
275+
### Environment variables in Rust canisters
276+
277+
Use `ic_cdk::api::env_var_value("VAR_NAME")` to read icp-cli-injected variables at **runtime** from WASM metadata. This is the Rust equivalent of `Runtime.envVar<system>` in Motoko:
278+
279+
```rust
280+
fn ledger_principal() -> Principal {
281+
Principal::from_text(ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID"))
282+
.expect("invalid ICP_LEDGER_CANISTER_ID")
283+
}
284+
```
285+
286+
Do **not** use `env!()` (compile-time macro, fails if the var is not set during `cargo build`) or `std::env::var()` (no OS environment in WASM).
287+
232288
### Management canister (Rust)
233289

234290
Use the [`ic-cdk-management-canister`](https://crates.io/crates/ic-cdk-management-canister) crate instead of `ic_cdk::api::management_canister` (removed in ic-cdk 0.17+):
@@ -249,74 +305,71 @@ async fn get_randomness() -> Vec<u8> {
249305

250306
---
251307

252-
## Makefile
253-
254-
Every example must have a `Makefile` with a `test` target that exercises the deployed canister via `icp canister call`. Write a numbered test for every public function. Include state-assertion tests for mutating operations: call the mutating function, then call a read function and assert the stored value changed.
255-
256-
```makefile
257-
.PHONY: test
308+
## test.sh
258309

259-
test:
260-
@echo "=== Test 1: <description of what is tested> ==="
261-
@result=$$(icp canister call backend <method> '<args>') && \
262-
echo "$$result" && \
263-
echo "$$result" | grep -q '<expected>' && \
264-
echo "PASS" || (echo "FAIL" && exit 1)
310+
Every example must have a `test.sh` bash script that exercises the deployed canister via `icp canister call`. Write a numbered test for every public function. Include state-assertion tests for mutating operations: call the mutating function, then call a read function and assert the stored value changed.
265311

266-
@echo "=== Test 2: <mutating operation returns expected value> ==="
267-
@result=$$(icp canister call backend <mutating_method> '<args>') && \
268-
echo "$$result" && \
269-
echo "$$result" | grep -q '<expected_return>' && \
270-
echo "PASS" || (echo "FAIL" && exit 1)
312+
Using `test.sh` instead of `Makefile` avoids Make-specific syntax pitfalls (`$$`, `\\` continuations, `@` prefix) and works natively in Git Bash on Windows.
271313

272-
@echo "=== Test 3: <state persisted after mutation> ==="
273-
@result=$$(icp canister call backend <read_method> '()') && \
274-
echo "$$result" && \
275-
echo "$$result" | grep -q '<expected_persisted_value>' && \
276-
echo "PASS" || (echo "FAIL" && exit 1)
314+
```bash
315+
#!/usr/bin/env bash
316+
set -e
317+
318+
echo "=== Test 1: <description of what is tested> ==="
319+
result=$(icp canister call backend <method> '<args>')
320+
echo "$result"
321+
echo "$result" | grep -q '<expected>' && echo "PASS" || (echo "FAIL" && exit 1)
322+
323+
echo "=== Test 2: <mutating operation returns expected value> ==="
324+
result=$(icp canister call backend <mutating_method> '<args>')
325+
echo "$result"
326+
echo "$result" | grep -q '<expected_return>' && echo "PASS" || (echo "FAIL" && exit 1)
327+
328+
echo "=== Test 3: <state persisted after mutation> ==="
329+
result=$(icp canister call backend <read_method> '()')
330+
echo "$result"
331+
echo "$result" | grep -q '<expected_persisted_value>' && echo "PASS" || (echo "FAIL" && exit 1)
277332
```
278333

279334
- Tests must call the `backend` canister by that name.
280335
- Always pass explicit Candid args, including `'()'` for zero-argument calls — omitting args triggers an interactive prompt that blocks CI.
281336
- Use `grep -q` to assert on output content.
282337
- Number each test (`=== Test N: ... ===`) so CI logs are easy to scan.
283338
- For examples that create child canisters, capture the returned principal and call the child directly by ID.
284-
- **Query functions**: always pass `--query` to `icp canister call` for `public query func` and `public composite query func` methods — this matches the original dfx `--query` flag and avoids unnecessary update calls.
285-
- **Async/time-dependent behavior**: if the example's observable result depends on timers, heartbeats, or polling (e.g. a hook that fires after memory fills up), use a polling loop in the Makefile rather than a one-shot call:
286-
287-
```makefile
288-
test:
289-
@echo "=== Polling for <condition> (up to 60s) ==="
290-
@secs=0; \
291-
while [ $$secs -lt 60 ]; do \
292-
result=$$(icp canister call --query backend <method> '()'); \
293-
echo "$$result"; \
294-
echo "$$result" | grep -q '<expected>' && echo "PASS" && exit 0; \
295-
sleep 3; secs=$$((secs + 3)); \
296-
done; \
297-
echo "FAIL: condition not met within 60s"; exit 1
298-
```
339+
- **Query functions**: always pass `--query` to `icp canister call` for `public query func` and `public composite query func` methods.
340+
- **Balance checks**: use delta-based assertions (record before, act, assert delta) rather than checking absolute values — this keeps tests idempotent across re-runs regardless of prior state:
299341

300-
- **Canister settings**: if the example requires non-default canister settings (e.g. `wasm_memory_limit`, `wasm_memory_threshold`), apply them via `icp canister settings update` in a `configure` target and make `test` depend on it:
301-
302-
```makefile
303-
.PHONY: configure test
342+
```bash
343+
before=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1)
344+
icp token transfer 1 "$account_hex"
345+
after=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1)
346+
delta=$((after - before))
347+
[ "$delta" -eq 100000000 ] && echo "PASS" || (echo "FAIL: expected +100000000 e8s" && exit 1)
348+
```
304349

305-
configure:
306-
icp canister settings update backend --<flag> <value> -f
350+
- **Async/time-dependent behavior**: if the observable result depends on timers, heartbeats, or polling, use a polling loop:
307351

308-
test: configure
309-
...
352+
```bash
353+
echo "=== Polling for <condition> (up to 60s) ==="
354+
secs=0
355+
while [ "$secs" -lt 60 ]; do
356+
result=$(icp canister call --query backend <method> '()')
357+
echo "$result"
358+
echo "$result" | grep -q '<expected>' && echo "PASS" && exit 0
359+
sleep 3
360+
secs=$((secs + 3))
361+
done
362+
echo "FAIL: condition not met within 60s"; exit 1
310363
```
311364

312-
- If `icp deploy --cycles 30t` is required (see below), add a `topup` target too:
365+
- **Canister settings**: if the example requires non-default canister settings (e.g. `wasm_memory_limit`, `wasm_memory_threshold`), apply them at the top of `test.sh` before the tests:
313366

314-
```makefile
315-
.PHONY: topup
316-
topup:
317-
icp canister top-up --amount 30t backend
367+
```bash
368+
icp canister settings update backend --<flag> <value> -f
318369
```
319370

371+
- If `icp deploy --cycles 30t` is required (see below), document `icp canister top-up --amount 30t backend` in the README so users can replenish cycles when needed. (`30t` is an example amount — adjust to the example's actual consumption.)
372+
320373
---
321374

322375
## Devcontainer config
@@ -348,7 +401,7 @@ concurrency:
348401
jobs:
349402
motoko-<example_name>:
350403
runs-on: ubuntu-24.04
351-
container: ghcr.io/dfinity/icp-dev-env-motoko:<version>
404+
container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1
352405
env:
353406
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
354407
steps:
@@ -358,11 +411,11 @@ jobs:
358411
run: |
359412
icp network start -d
360413
icp deploy
361-
make test
414+
bash test.sh
362415
363416
rust-<example_name>:
364417
runs-on: ubuntu-24.04
365-
container: ghcr.io/dfinity/icp-dev-env-rust:<version>
418+
container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1
366419
env:
367420
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
368421
steps:
@@ -372,7 +425,7 @@ jobs:
372425
run: |
373426
icp network start -d
374427
icp deploy
375-
make test
428+
bash test.sh
376429
```
377430

378431
- Linux only, no macOS runners.
@@ -412,10 +465,10 @@ Each example's README should follow this structure:
412465
<git clone + cd>
413466

414467
### Deploy and test
415-
<icp network start -d && icp deploy && make test && icp network stop>
468+
<icp network start -d && icp deploy && bash test.sh && icp network stop>
416469
If the example has a frontend with a Vite dev server: `npm run dev` (hot reload during frontend development)
417470

418-
> If tests fail with an out-of-cycles error, run `make topup` to add 30 trillion cycles to the backend canister and retry. Only relevant for examples that create child canisters.
471+
> If tests fail with an out-of-cycles error, run `icp canister top-up --amount 30t backend` to replenish cycles and retry (`30t` is an example amount). Only relevant for examples that create child canisters.
419472

420473
## Updating the Candid interface
421474
<instructions to regenerate backend.did>
@@ -438,7 +491,7 @@ Rust: `icp build backend && candid-extractor target/wasm32-unknown-unknown/relea
438491
## Pending items (do not resolve prematurely)
439492

440493
### Container images
441-
Images are published at `ghcr.io/dfinity/icp-dev-env-{motoko,rust,all}`. All devcontainer configs and CI workflows reference the pinned tag (e.g. `0.3.2`). When a new release is cut, update the tag in:
494+
Images are published at `ghcr.io/dfinity/icp-dev-env-{motoko,rust,all}`. Current pinned version: **`1.0.1`**. All devcontainer configs and CI workflows reference the pinned tag. When a new release is cut, update the tag in:
442495
- `.devcontainer/devcontainer.json`
443496
- `.github/workflows/*.yml`
444497

@@ -469,15 +522,17 @@ When migrating an existing example:
469522
- [ ] Run `mops check --fix` in the example directory and commit any auto-fixes (Motoko)
470523
- [ ] If the example uses the management canister: add `ic = "4.0.0"` dependency and replace `ic:aaaaa-aa` / `actor("aaaaa-aa")` with `import { ic } "mo:ic"` (Motoko)
471524
- [ ] If the Rust example uses the management canister: add `ic-cdk-management-canister = "0.1.1"` dependency and replace `ic_cdk::api::management_canister` with the appropriate function from that crate
472-
- [ ] If the example creates child canisters: use `icp deploy --cycles 30t` in the CI workflow and README, and add a `make topup` target
473-
- [ ] If the example requires non-default canister settings (memory limits, freezing threshold, etc.): add a `configure` target in the Makefile and make `test` depend on it
525+
- [ ] **Judge whether per-environment configuration makes sense**: if the example calls an external canister whose principal differs by environment (e.g. ICP ledger vs. TESTICP), add an `environments` block to `icp.yaml`. Skip for self-contained examples.
526+
- [ ] If the example creates child canisters: use `icp deploy --cycles 30t` in the CI workflow and README
527+
- [ ] If the example requires non-default canister settings (memory limits, freezing threshold, etc.): apply them via `icp canister settings update` at the top of `test.sh`
474528
- [ ] Delete `dfx.json`, `BUILD.md`, `.dfx/`, `.env` (dfx-generated)
475529
- [ ] Delete `.devcontainer/` inside the example folder if one exists (only the repo-root devcontainer is kept)
476530

477-
### Makefile
478-
- [ ] Add `Makefile` with `test` target
531+
### test.sh
532+
- [ ] Add `test.sh` (executable, `#!/usr/bin/env bash`, `set -e`) with numbered tests
479533
- [ ] Use `--query` for all `public query func` and `public composite query func` calls
480534
- [ ] Use a polling loop for any behavior that depends on timers, heartbeats, or async system hooks
535+
- [ ] Use delta-based balance assertions (before/after) rather than absolute values for idempotency
481536
- [ ] For backend-only examples, no `## Updating the Candid interface` section is needed
482537

483538
### CI workflow

0 commit comments

Comments
 (0)