Skip to content

Commit 78e3a06

Browse files
authored
infra: add PR preview deployment via canister pool (#219)
## Summary - Adds `preview-deployment.yml`: triggered on PR open/sync/reopen for non-fork PRs. Requests a canister from the pool, builds and deploys the site to it with `icp deploy frontend -e ic --mode reinstall`, and posts the preview URL as a bot comment. - Adds `pr-cleanup.yml`: triggered on PR close. Returns the canister to the pool and deletes the bot comment. - Adds `.github/workflows/scripts/`: `pool.py`, `request-canister.py`, `release-canister.py`, `comments.cjs` — adapted from `dfinity/portal`. Changes from portal originals: env var renamed `ICP_IDENTITY_PREVIEW` → `POOL_CONTROLLER_IDENTITY`; `comments.js` renamed to `comments.cjs` to avoid ESM conflict with `"type": "module"` in `package.json`. - Updates `build.yml`: now only runs for fork PRs. Non-fork PRs get their build check through `preview-deployment.yml` (which fails the job if the build or deploy fails). - Bumps `icp-cli` from `0.2.0` to `0.2.6` in both `preview-deployment.yml` and `deploy-ic.yml`. ## Differences from portal | | portal | developer-docs | |---|---|---| | Secret name | `DFX_IDENTITY_PREVIEW` | `POOL_CONTROLLER_IDENTITY` | | Canister name | `portal` | `frontend` | | icp-cli install | curl installer `v0.1.0` | `npm i -g @icp-sdk/icp-cli@0.2.6` | | Node version | 20 | 22 | | comments helper | `comments.js` | `comments.cjs` (ESM fix) | ## Prerequisites (outside this PR) - `POOL_CONTROLLER_IDENTITY` and `POOL_CANISTER_ID` secrets must be set in repo settings (confirmed done). - The identity behind `POOL_CONTROLLER_IDENTITY` must be a controller of the pool canisters. ## Sync recommendation `hand-written`
1 parent c9a6f59 commit 78e3a06

8 files changed

Lines changed: 248 additions & 1 deletion

File tree

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ on:
55

66
jobs:
77
build:
8+
# only run in forks — non-fork PRs get a build via preview-deployment.yml
9+
if: github.event.pull_request.head.repo.full_name != github.repository
810
runs-on: ubuntu-latest
911
permissions:
1012
contents: read

.github/workflows/deploy-ic.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
- run: npm run build
3939

4040
- name: Install icp-cli
41-
run: npm i -g @icp-sdk/icp-cli@0.2.0 @icp-sdk/ic-wasm
41+
run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm
4242

4343
- name: Import deploy identity
4444
run: |

.github/workflows/pr-cleanup.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: PR Cleanup
2+
on:
3+
pull_request:
4+
types: [closed]
5+
6+
jobs:
7+
release_preview_canister:
8+
# do not run in forks
9+
if: github.event.pull_request.head.repo.full_name == github.repository
10+
runs-on: ubuntu-latest
11+
concurrency:
12+
group: pr-${{ github.event.pull_request.number || github.event.number }}
13+
cancel-in-progress: true
14+
15+
steps:
16+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
17+
- uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
18+
with:
19+
python-version: "3.10"
20+
- run: |
21+
pip install icp-py-core "cbor2<6"
22+
python3 .github/workflows/scripts/release-canister.py ${{ github.event.pull_request.number }}
23+
env:
24+
POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }}
25+
POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }}
26+
27+
- uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
28+
with:
29+
script: |
30+
const comments = require('./.github/workflows/scripts/comments.cjs');
31+
const maybeComment = await comments.get(context, github);
32+
if (maybeComment) {
33+
await comments.delete(context, github, maybeComment.id);
34+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: PR Preview Deployment
2+
on:
3+
pull_request:
4+
types: [opened, synchronize, reopened]
5+
6+
jobs:
7+
build_and_deploy:
8+
# do not run in forks
9+
if: github.event.pull_request.head.repo.full_name == github.repository
10+
runs-on: ubuntu-latest
11+
concurrency:
12+
group: pr-${{ github.event.pull_request.number || github.event.number }}
13+
cancel-in-progress: true
14+
15+
steps:
16+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
17+
18+
- name: Initialize examples submodule
19+
run: |
20+
git config --global url."https://github.com/".insteadOf "git@github.com:"
21+
git submodule update --init --depth 1 .sources/examples
22+
23+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
24+
with:
25+
node-version: 22
26+
cache: npm
27+
28+
- uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
29+
with:
30+
script: |
31+
const comments = require('./.github/workflows/scripts/comments.cjs');
32+
const maybeComment = await comments.get(context, github);
33+
if (maybeComment) {
34+
await comments.update(context, github, maybeComment.id, `🤖 Your PR preview is being built...`);
35+
} else {
36+
await comments.create(context, github, `🤖 Your PR preview is being built...`);
37+
}
38+
39+
- uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1
40+
with:
41+
python-version: "3.10"
42+
43+
- name: Install icp-cli
44+
run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm
45+
46+
- run: npm ci
47+
48+
- name: Build & Deploy
49+
run: |
50+
# Setup identity
51+
mkdir -p ~/.local/share/icp-cli/identity/keys
52+
echo $POOL_CONTROLLER_IDENTITY | base64 -d > ~/.local/share/icp-cli/identity/keys/preview-deploy.pem
53+
sed -i 's/\\r\\n/\r\n/g' ~/.local/share/icp-cli/identity/keys/preview-deploy.pem
54+
icp identity import preview-deploy --from-pem ~/.local/share/icp-cli/identity/keys/preview-deploy.pem --storage plaintext
55+
icp identity default preview-deploy
56+
57+
# Request preview canister from the pool
58+
pip install icp-py-core "cbor2<6"
59+
canister_id=$(python3 .github/workflows/scripts/request-canister.py ${{ github.event.pull_request.number }})
60+
61+
# Override canister ID mapping for ic environment
62+
echo "{\"frontend\":\"$canister_id\"}" > .icp/data/mappings/ic.ids.json
63+
64+
echo "PREVIEW_CANISTER_ID=$canister_id" >> $GITHUB_ENV
65+
66+
# Deploy (icp.yaml recipe handles the build)
67+
icp deploy frontend -e ic --mode reinstall
68+
69+
env:
70+
POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }}
71+
POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }}
72+
73+
- name: Report build error
74+
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
75+
if: ${{ failure() }}
76+
with:
77+
script: |
78+
const comments = require('./.github/workflows/scripts/comments.cjs');
79+
const maybeComment = await comments.get(context, github);
80+
if (maybeComment) {
81+
await comments.update(context, github, maybeComment.id, `🤖 Preview build failed.`);
82+
} else {
83+
await comments.create(context, github, `🤖 Preview build failed.`);
84+
}
85+
86+
- uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
87+
with:
88+
script: |
89+
const comments = require('./.github/workflows/scripts/comments.cjs');
90+
const maybeComment = await comments.get(context, github);
91+
if (maybeComment) {
92+
await comments.update(context, github, maybeComment.id, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`);
93+
} else {
94+
await comments.create(context, github, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`);
95+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const MARKER = '<!-- pr-preview -->';
2+
3+
exports.get = async function (context, github) {
4+
const comments = await github.rest.issues.listComments({
5+
issue_number: context.issue.number,
6+
repo: context.repo.repo,
7+
owner: context.repo.owner,
8+
});
9+
10+
return comments.data.find(
11+
(c) => c.user.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(MARKER)
12+
);
13+
};
14+
15+
exports.create = function (context, github, body) {
16+
return github.rest.issues.createComment({
17+
issue_number: context.issue.number,
18+
owner: context.repo.owner,
19+
repo: context.repo.repo,
20+
body: `${MARKER}\n${body}`,
21+
});
22+
};
23+
24+
exports.update = function (context, github, id, body) {
25+
return github.rest.issues.updateComment({
26+
owner: context.repo.owner,
27+
repo: context.repo.repo,
28+
comment_id: id,
29+
body: `${MARKER}\n${body}`,
30+
});
31+
};
32+
33+
exports.delete = function (context, github, id) {
34+
return github.rest.issues.deleteComment({
35+
owner: context.repo.owner,
36+
repo: context.repo.repo,
37+
comment_id: id,
38+
});
39+
};

.github/workflows/scripts/pool.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from icp_core import Agent, Client, Identity, encode, Types
2+
import os
3+
import sys
4+
import base64
5+
6+
7+
#
8+
# Interact with preview canister pool: https://github.com/dfinity/preview-canister-pool
9+
#
10+
11+
private_key = base64.b64decode(os.environ["POOL_CONTROLLER_IDENTITY"]).decode("utf-8")
12+
pool_id = os.environ["POOL_CANISTER_ID"]
13+
14+
identity = Identity.from_pem(private_key)
15+
client = Client()
16+
agent = Agent(identity, client)
17+
18+
def release_canister():
19+
res = agent.update_raw(
20+
pool_id, "release_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]),
21+
verify_certificate=False)
22+
return res
23+
24+
25+
def request_canister():
26+
res = agent.update_raw(
27+
pool_id, "request_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]),
28+
return_type=Types.Principal,
29+
verify_certificate=False)
30+
return res
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import os
2+
import sys
3+
import traceback
4+
5+
if len(sys.argv) != 2:
6+
print("Usage: python3 release-canister.py <ref>")
7+
exit(1)
8+
9+
for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]:
10+
if not v in os.environ:
11+
print(f"release-canister.py: {v} env variable missing")
12+
exit(1)
13+
14+
15+
from pool import release_canister
16+
17+
try:
18+
release_canister()
19+
except Exception as e:
20+
print(f"release-canister.py: failed to release canister: {e}", file=sys.stderr)
21+
traceback.print_exc(file=sys.stderr)
22+
exit(1)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
import sys
3+
import traceback
4+
5+
if len(sys.argv) != 2:
6+
print("Usage: python3 request_canister.py <ref>")
7+
exit(1)
8+
9+
for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]:
10+
if not v in os.environ:
11+
print(f"request-canister.py: {v} env variable missing")
12+
exit(1)
13+
14+
from pool import request_canister
15+
16+
try:
17+
result = request_canister()
18+
canister_id = result[0]['value'].to_str()
19+
print(canister_id)
20+
except Exception as e:
21+
print(f"request-canister.py: failed to request canister: {e}", file=sys.stderr)
22+
traceback.print_exc(file=sys.stderr)
23+
if 'result' in dir():
24+
print(f"request-canister.py: raw result: {result}", file=sys.stderr)
25+
exit(1)

0 commit comments

Comments
 (0)