From a6398afc1aaf10319ad826cfc58a34c60152cccf Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 7 May 2026 15:43:20 +0200 Subject: [PATCH 1/5] infra: add PR preview deployment via canister pool Adds preview-deployment.yml and pr-cleanup.yml to automatically deploy each non-fork PR to a temporary canister from the preview pool and post the URL as a PR comment. Adapts the dfinity/portal pattern for this repo: secret renamed (IDENTITY_PREVIEW), canister name changed to 'frontend'. build.yml now only runs for fork PRs; non-fork PRs get their build check through the preview deployment job. --- .github/workflows/build.yml | 2 + .github/workflows/pr-cleanup.yml | 35 +++++++ .github/workflows/preview-deployment.yml | 96 +++++++++++++++++++ .github/workflows/scripts/comments.js | 37 +++++++ .github/workflows/scripts/pool.py | 30 ++++++ .github/workflows/scripts/release-canister.py | 22 +++++ .github/workflows/scripts/request-canister.py | 25 +++++ 7 files changed, 247 insertions(+) create mode 100644 .github/workflows/pr-cleanup.yml create mode 100644 .github/workflows/preview-deployment.yml create mode 100644 .github/workflows/scripts/comments.js create mode 100644 .github/workflows/scripts/pool.py create mode 100644 .github/workflows/scripts/release-canister.py create mode 100644 .github/workflows/scripts/request-canister.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94110674..008c8307 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ on: jobs: build: + # only run in forks — non-fork PRs get a build via preview-deployment.yml + if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 00000000..c9984685 --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,35 @@ +name: PR Cleanup +on: + pull_request: + types: [closed] + +jobs: + release_preview_canister: + # do not run in forks + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: pr-${{ github.event.pull_request.number || github.event.number }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 + with: + python-version: "3.10" + - run: | + pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + pip install icp-py-core "cbor2<6" + python3 .github/workflows/scripts/release-canister.py $pull_number + env: + IDENTITY_PREVIEW: ${{ secrets.IDENTITY_PREVIEW }} + POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.js'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.delete(context, github, maybeComment.id); + } diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml new file mode 100644 index 00000000..8e050366 --- /dev/null +++ b/.github/workflows/preview-deployment.yml @@ -0,0 +1,96 @@ +name: PR Preview Deployment +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build_and_deploy: + # do not run in forks + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: pr-${{ github.event.pull_request.number || github.event.number }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Initialize examples submodule + run: | + git config --global url."https://github.com/".insteadOf "git@github.com:" + git submodule update --init --depth 1 .sources/examples + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.js'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Your PR preview is being built...`); + } else { + await comments.create(context, github, `🤖 Your PR preview is being built...`); + } + + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 + with: + python-version: "3.10" + + - name: Install icp-cli + run: npm i -g @icp-sdk/icp-cli@0.2.0 @icp-sdk/ic-wasm + + - run: npm ci + + - name: Build & Deploy + run: | + # Setup identity + mkdir -p ~/.local/share/icp-cli/identity/keys + echo $IDENTITY_PREVIEW | base64 -d > ~/.local/share/icp-cli/identity/keys/preview-deploy.pem + sed -i 's/\\r\\n/\r\n/g' ~/.local/share/icp-cli/identity/keys/preview-deploy.pem + icp identity import preview-deploy --from-pem ~/.local/share/icp-cli/identity/keys/preview-deploy.pem --storage plaintext + icp identity default preview-deploy + + # Request preview canister from the pool + pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + pip install icp-py-core "cbor2<6" + canister_id=$(python3 .github/workflows/scripts/request-canister.py $pull_number) + + # Override canister ID mapping for ic environment + echo "{\"frontend\":\"$canister_id\"}" > .icp/data/mappings/ic.ids.json + + echo "PREVIEW_CANISTER_ID=$canister_id" >> $GITHUB_ENV + + # Deploy (icp.yaml recipe handles the build) + icp deploy frontend -e ic --mode reinstall + + env: + IDENTITY_PREVIEW: ${{ secrets.IDENTITY_PREVIEW }} + POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} + + - name: Report build error + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + if: ${{ failure() }} + with: + script: | + const comments = require('./.github/workflows/scripts/comments.js'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Preview build failed.`); + } else { + await comments.create(context, github, `🤖 Preview build failed.`); + } + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + const comments = require('./.github/workflows/scripts/comments.js'); + const maybeComment = await comments.get(context, github); + if (maybeComment) { + await comments.update(context, github, maybeComment.id, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`); + } else { + await comments.create(context, github, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`); + } diff --git a/.github/workflows/scripts/comments.js b/.github/workflows/scripts/comments.js new file mode 100644 index 00000000..6dfb6852 --- /dev/null +++ b/.github/workflows/scripts/comments.js @@ -0,0 +1,37 @@ +exports.get = async function (context, github) { + const comments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + repo: context.repo.repo, + owner: context.repo.owner, + }); + + return comments.data.find( + (c) => c.user.login === 'github-actions[bot]' && c.user.type === 'Bot' + ); +}; + +exports.create = function (context, github, body) { + return github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); +}; + +exports.update = function (context, github, id, body) { + return github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: id, + body, + }); +}; + +exports.delete = function (context, github, id) { + return github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: id, + }); +}; diff --git a/.github/workflows/scripts/pool.py b/.github/workflows/scripts/pool.py new file mode 100644 index 00000000..67146114 --- /dev/null +++ b/.github/workflows/scripts/pool.py @@ -0,0 +1,30 @@ +from icp_core import Agent, Client, Identity, encode, Types +import os +import sys +import base64 + + +# +# Interact with preview canister pool: https://github.com/dfinity/preview-canister-pool +# + +private_key = base64.b64decode(os.environ["IDENTITY_PREVIEW"]).decode("utf-8") +pool_id = os.environ["POOL_CANISTER_ID"] + +identity = Identity.from_pem(private_key) +client = Client() +agent = Agent(identity, client) + +def release_canister(): + res = agent.update_raw( + pool_id, "release_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]), + verify_certificate=False) + return res + + +def request_canister(): + res = agent.update_raw( + pool_id, "request_canister", encode([{'type': Types.Text, 'value': sys.argv[1]}]), + return_type=Types.Principal, + verify_certificate=False) + return res diff --git a/.github/workflows/scripts/release-canister.py b/.github/workflows/scripts/release-canister.py new file mode 100644 index 00000000..a9d4f13b --- /dev/null +++ b/.github/workflows/scripts/release-canister.py @@ -0,0 +1,22 @@ +import os +import sys +import traceback + +if len(sys.argv) != 2: + print("Usage: python3 release-canister.py ") + exit(1) + +for v in ["IDENTITY_PREVIEW","POOL_CANISTER_ID"]: + if not v in os.environ: + print(f"release-canister.py: {v} env variable missing") + exit(1) + + +from pool import release_canister + +try: + release_canister() +except Exception as e: + print(f"release-canister.py: failed to release canister: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + exit(1) diff --git a/.github/workflows/scripts/request-canister.py b/.github/workflows/scripts/request-canister.py new file mode 100644 index 00000000..db591684 --- /dev/null +++ b/.github/workflows/scripts/request-canister.py @@ -0,0 +1,25 @@ +import os +import sys +import traceback + +if len(sys.argv) != 2: + print("Usage: python3 request_canister.py ") + exit(1) + +for v in ["IDENTITY_PREVIEW","POOL_CANISTER_ID"]: + if not v in os.environ: + print(f"request-canister.py: {v} env variable missing") + exit(1) + +from pool import request_canister + +try: + result = request_canister() + canister_id = result[0]['value'].to_str() + print(canister_id) +except Exception as e: + print(f"request-canister.py: failed to request canister: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + if 'result' in dir(): + print(f"request-canister.py: raw result: {result}", file=sys.stderr) + exit(1) From ca55cd183e0857595b4bb5e22c2aea98175c10f9 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 7 May 2026 15:49:34 +0200 Subject: [PATCH 2/5] fix(infra): rename secret to POOL_CONTROLLER_IDENTITY, fix comments ESM error Renames IDENTITY_PREVIEW -> POOL_CONTROLLER_IDENTITY across all workflow files and scripts. Renames comments.js -> comments.cjs so Node does not treat it as an ES module (package.json has "type": "module"). --- .github/workflows/pr-cleanup.yml | 4 ++-- .github/workflows/preview-deployment.yml | 10 +++++----- .../workflows/scripts/{comments.js => comments.cjs} | 0 .github/workflows/scripts/pool.py | 2 +- .github/workflows/scripts/release-canister.py | 2 +- .github/workflows/scripts/request-canister.py | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) rename .github/workflows/scripts/{comments.js => comments.cjs} (100%) diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml index c9984685..9ab6b6a2 100644 --- a/.github/workflows/pr-cleanup.yml +++ b/.github/workflows/pr-cleanup.yml @@ -22,13 +22,13 @@ jobs: pip install icp-py-core "cbor2<6" python3 .github/workflows/scripts/release-canister.py $pull_number env: - IDENTITY_PREVIEW: ${{ secrets.IDENTITY_PREVIEW }} + POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 with: script: | - const comments = require('./.github/workflows/scripts/comments.js'); + const comments = require('./.github/workflows/scripts/comments.cjs'); const maybeComment = await comments.get(context, github); if (maybeComment) { await comments.delete(context, github, maybeComment.id); diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml index 8e050366..0e02755e 100644 --- a/.github/workflows/preview-deployment.yml +++ b/.github/workflows/preview-deployment.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 with: script: | - const comments = require('./.github/workflows/scripts/comments.js'); + const comments = require('./.github/workflows/scripts/comments.cjs'); const maybeComment = await comments.get(context, github); if (maybeComment) { await comments.update(context, github, maybeComment.id, `🤖 Your PR preview is being built...`); @@ -49,7 +49,7 @@ jobs: run: | # Setup identity mkdir -p ~/.local/share/icp-cli/identity/keys - echo $IDENTITY_PREVIEW | base64 -d > ~/.local/share/icp-cli/identity/keys/preview-deploy.pem + echo $POOL_CONTROLLER_IDENTITY | base64 -d > ~/.local/share/icp-cli/identity/keys/preview-deploy.pem sed -i 's/\\r\\n/\r\n/g' ~/.local/share/icp-cli/identity/keys/preview-deploy.pem icp identity import preview-deploy --from-pem ~/.local/share/icp-cli/identity/keys/preview-deploy.pem --storage plaintext icp identity default preview-deploy @@ -68,7 +68,7 @@ jobs: icp deploy frontend -e ic --mode reinstall env: - IDENTITY_PREVIEW: ${{ secrets.IDENTITY_PREVIEW }} + POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} - name: Report build error @@ -76,7 +76,7 @@ jobs: if: ${{ failure() }} with: script: | - const comments = require('./.github/workflows/scripts/comments.js'); + const comments = require('./.github/workflows/scripts/comments.cjs'); const maybeComment = await comments.get(context, github); if (maybeComment) { await comments.update(context, github, maybeComment.id, `🤖 Preview build failed.`); @@ -87,7 +87,7 @@ jobs: - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 with: script: | - const comments = require('./.github/workflows/scripts/comments.js'); + const comments = require('./.github/workflows/scripts/comments.cjs'); const maybeComment = await comments.get(context, github); if (maybeComment) { await comments.update(context, github, maybeComment.id, `🤖 Here's your preview: https://${process.env.PREVIEW_CANISTER_ID}.icp0.io`); diff --git a/.github/workflows/scripts/comments.js b/.github/workflows/scripts/comments.cjs similarity index 100% rename from .github/workflows/scripts/comments.js rename to .github/workflows/scripts/comments.cjs diff --git a/.github/workflows/scripts/pool.py b/.github/workflows/scripts/pool.py index 67146114..db1f5fd9 100644 --- a/.github/workflows/scripts/pool.py +++ b/.github/workflows/scripts/pool.py @@ -8,7 +8,7 @@ # Interact with preview canister pool: https://github.com/dfinity/preview-canister-pool # -private_key = base64.b64decode(os.environ["IDENTITY_PREVIEW"]).decode("utf-8") +private_key = base64.b64decode(os.environ["POOL_CONTROLLER_IDENTITY"]).decode("utf-8") pool_id = os.environ["POOL_CANISTER_ID"] identity = Identity.from_pem(private_key) diff --git a/.github/workflows/scripts/release-canister.py b/.github/workflows/scripts/release-canister.py index a9d4f13b..350d4205 100644 --- a/.github/workflows/scripts/release-canister.py +++ b/.github/workflows/scripts/release-canister.py @@ -6,7 +6,7 @@ print("Usage: python3 release-canister.py ") exit(1) -for v in ["IDENTITY_PREVIEW","POOL_CANISTER_ID"]: +for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]: if not v in os.environ: print(f"release-canister.py: {v} env variable missing") exit(1) diff --git a/.github/workflows/scripts/request-canister.py b/.github/workflows/scripts/request-canister.py index db591684..b0a4194c 100644 --- a/.github/workflows/scripts/request-canister.py +++ b/.github/workflows/scripts/request-canister.py @@ -6,7 +6,7 @@ print("Usage: python3 request_canister.py ") exit(1) -for v in ["IDENTITY_PREVIEW","POOL_CANISTER_ID"]: +for v in ["POOL_CONTROLLER_IDENTITY","POOL_CANISTER_ID"]: if not v in os.environ: print(f"request-canister.py: {v} env variable missing") exit(1) From a3cdcf666f3df626cad24a5894381d02e7630e3a Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 7 May 2026 15:51:28 +0200 Subject: [PATCH 3/5] chore: bump icp-cli to 0.2.6 --- .github/workflows/deploy-ic.yml | 2 +- .github/workflows/preview-deployment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-ic.yml b/.github/workflows/deploy-ic.yml index a46a6f40..66098346 100644 --- a/.github/workflows/deploy-ic.yml +++ b/.github/workflows/deploy-ic.yml @@ -38,7 +38,7 @@ jobs: - run: npm run build - name: Install icp-cli - run: npm i -g @icp-sdk/icp-cli@0.2.0 @icp-sdk/ic-wasm + run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm - name: Import deploy identity run: | diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml index 0e02755e..a6df2f5e 100644 --- a/.github/workflows/preview-deployment.yml +++ b/.github/workflows/preview-deployment.yml @@ -41,7 +41,7 @@ jobs: python-version: "3.10" - name: Install icp-cli - run: npm i -g @icp-sdk/icp-cli@0.2.0 @icp-sdk/ic-wasm + run: npm i -g @icp-sdk/icp-cli@0.2.6 @icp-sdk/ic-wasm - run: npm ci From 513b3da8f88f1418e2b3fd19e506423bac204fa7 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 7 May 2026 16:04:25 +0200 Subject: [PATCH 4/5] fix(infra): scope bot comment lookup to pr-preview marker --- .github/workflows/scripts/comments.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scripts/comments.cjs b/.github/workflows/scripts/comments.cjs index 6dfb6852..1909c1c1 100644 --- a/.github/workflows/scripts/comments.cjs +++ b/.github/workflows/scripts/comments.cjs @@ -1,3 +1,5 @@ +const MARKER = ''; + exports.get = async function (context, github) { const comments = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -6,7 +8,7 @@ exports.get = async function (context, github) { }); return comments.data.find( - (c) => c.user.login === 'github-actions[bot]' && c.user.type === 'Bot' + (c) => c.user.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(MARKER) ); }; @@ -15,7 +17,7 @@ exports.create = function (context, github, body) { issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body, + body: `${MARKER}\n${body}`, }); }; @@ -24,7 +26,7 @@ exports.update = function (context, github, id, body) { owner: context.repo.owner, repo: context.repo.repo, comment_id: id, - body, + body: `${MARKER}\n${body}`, }); }; From 6fe3f5637527df484b843e469225a5eb5ec67a99 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 7 May 2026 16:09:20 +0200 Subject: [PATCH 5/5] fix(infra): use github.event.pull_request.number instead of jq --- .github/workflows/pr-cleanup.yml | 3 +-- .github/workflows/preview-deployment.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml index 9ab6b6a2..64b19f97 100644 --- a/.github/workflows/pr-cleanup.yml +++ b/.github/workflows/pr-cleanup.yml @@ -18,9 +18,8 @@ jobs: with: python-version: "3.10" - run: | - pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") pip install icp-py-core "cbor2<6" - python3 .github/workflows/scripts/release-canister.py $pull_number + python3 .github/workflows/scripts/release-canister.py ${{ github.event.pull_request.number }} env: POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} POOL_CANISTER_ID: ${{ secrets.POOL_CANISTER_ID }} diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml index a6df2f5e..60d53160 100644 --- a/.github/workflows/preview-deployment.yml +++ b/.github/workflows/preview-deployment.yml @@ -55,9 +55,8 @@ jobs: icp identity default preview-deploy # Request preview canister from the pool - pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") pip install icp-py-core "cbor2<6" - canister_id=$(python3 .github/workflows/scripts/request-canister.py $pull_number) + canister_id=$(python3 .github/workflows/scripts/request-canister.py ${{ github.event.pull_request.number }}) # Override canister ID mapping for ic environment echo "{\"frontend\":\"$canister_id\"}" > .icp/data/mappings/ic.ids.json