diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9411067..008c830 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/deploy-ic.yml b/.github/workflows/deploy-ic.yml index a46a6f4..6609834 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/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 0000000..64b19f9 --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,34 @@ +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: | + pip install icp-py-core "cbor2<6" + 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 }} + + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + script: | + 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 new file mode 100644 index 0000000..60d5316 --- /dev/null +++ b/.github/workflows/preview-deployment.yml @@ -0,0 +1,95 @@ +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.cjs'); + 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.6 @icp-sdk/ic-wasm + + - run: npm ci + + - name: Build & Deploy + run: | + # Setup identity + mkdir -p ~/.local/share/icp-cli/identity/keys + 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 + + # Request preview canister from the pool + pip install icp-py-core "cbor2<6" + 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 + + echo "PREVIEW_CANISTER_ID=$canister_id" >> $GITHUB_ENV + + # Deploy (icp.yaml recipe handles the build) + icp deploy frontend -e ic --mode reinstall + + env: + POOL_CONTROLLER_IDENTITY: ${{ secrets.POOL_CONTROLLER_IDENTITY }} + 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.cjs'); + 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.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`); + } 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.cjs b/.github/workflows/scripts/comments.cjs new file mode 100644 index 0000000..1909c1c --- /dev/null +++ b/.github/workflows/scripts/comments.cjs @@ -0,0 +1,39 @@ +const MARKER = ''; + +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' && c.body.includes(MARKER) + ); +}; + +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: `${MARKER}\n${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: `${MARKER}\n${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 0000000..db1f5fd --- /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["POOL_CONTROLLER_IDENTITY"]).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 0000000..350d420 --- /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 ["POOL_CONTROLLER_IDENTITY","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 0000000..b0a4194 --- /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 ["POOL_CONTROLLER_IDENTITY","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)