diff --git a/.github/workflows/template-compatibility-comment.yml b/.github/workflows/template-compatibility-comment.yml new file mode 100644 index 00000000..1f66df7c --- /dev/null +++ b/.github/workflows/template-compatibility-comment.yml @@ -0,0 +1,44 @@ +name: Template Compatibility Comment + +# Triggered when the unprivileged "Template Compatibility Check" workflow +# completes. This workflow has pull-requests: write so it can post PR comments. +# It checks out the default branch (only) to load scripts/template-compatibility-comment.js, +# then reads the artifact produced by the check workflow. +# +# SECURITY: workflow_run always runs on the default branch, so this workflow +# definition and checked-out script cannot be tampered with by a PR contributor. +# Artifact contents are treated as untrusted strings and sanitized before use. + +on: + workflow_run: + workflows: ["Template Compatibility Check"] + types: [completed] + +permissions: + contents: read + pull-requests: write + actions: read # required to download artifacts from another workflow run + +jobs: + post-comment: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download results artifact + uses: actions/download-artifact@v8 + with: + name: template-compat-results + path: /tmp/compat-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post or remove PR comment + uses: actions/github-script@v8 + with: + script: | + const path = require('path'); + const run = require(path.join(process.env.GITHUB_WORKSPACE, 'scripts', 'template-compatibility-comment.js')); + await run({ github, context }); diff --git a/.github/workflows/template-compatibility.yml b/.github/workflows/template-compatibility.yml new file mode 100644 index 00000000..95b41b76 --- /dev/null +++ b/.github/workflows/template-compatibility.yml @@ -0,0 +1,158 @@ +name: Template Compatibility Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: template-compat-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +# This job is informational — it is intentionally NOT a required status check +# so that breaking SDK changes (major version bumps) can still be merged. +# When templates break, a comment is posted on the PR with details. +# For intentional breaking changes, create a matching branch in cre-templates +# named compat/ with the template fixes applied. +# The job will automatically detect and test against that branch. + +permissions: + contents: read + +jobs: + template-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + shell: bash {0} + + env: + TEMPLATES_REPO: smartcontractkit/cre-templates + + steps: + - name: Checkout SDK + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Setup Rust (1.85.0) with wasm target + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: 1.85.0 + target: wasm32-wasip1 + override: true + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.12 + + - name: Cache Bun dependencies + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + + - name: Cache cargo + Javy + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/javy + key: ${{ runner.os }}-cre-plugin-v8.1.0-${{ hashFiles('packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/Cargo.lock', 'packages/cre-sdk-javy-plugin/src/cre_generated_host.Cargo.lock') }} + + - name: Install SDK dependencies + run: bun install --frozen-lockfile + + # Detect whether a matching compat branch exists in cre-templates. + # If it does, we test against it (coordinated breaking change). + # If not, we fall back to main. + - name: Detect cre-templates ref to test against + id: detect-ref + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.head_ref }} + run: | + SAFE_HEAD_REF="${HEAD_REF//[^a-zA-Z0-9._\/-]/}" + COMPAT_BRANCH="compat/$SAFE_HEAD_REF" + + if gh api "repos/$TEMPLATES_REPO/branches/$COMPAT_BRANCH" &>/dev/null; then + echo "ref=$COMPAT_BRANCH" >> "$GITHUB_OUTPUT" + echo "Using compat branch: $COMPAT_BRANCH" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + echo "No compat branch found, using: main" + fi + + - name: Checkout cre-templates (${{ steps.detect-ref.outputs.ref }}) + uses: actions/checkout@v6 + with: + repository: ${{ env.TEMPLATES_REPO }} + ref: ${{ steps.detect-ref.outputs.ref }} + path: cre-templates + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node (for npm) + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Run template compatibility check + id: template-check + env: + TEMPLATES_DIR: cre-templates + run: | + set +e + OUTPUT=$(./scripts/test-templates.sh 2>&1) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" > /tmp/template-check-output.txt + + # Surface it in the action log regardless + echo "$OUTPUT" + + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + + # Bundle everything the comment workflow needs into an artifact. + # The comment workflow runs with pull-requests: write but must never + # execute external code — it only reads these files. + - name: Save results for comment workflow + if: always() + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + TEMPLATES_REF: ${{ steps.detect-ref.outputs.ref }} + HEAD_REF: ${{ github.head_ref }} + EXIT_CODE: ${{ steps.template-check.outputs.exit_code }} + run: | + mkdir -p /tmp/compat-results + printf '%s' "$PR_NUMBER" > /tmp/compat-results/pr-number.txt + printf '%s' "$TEMPLATES_REF" > /tmp/compat-results/templates-ref.txt + printf '%s' "$HEAD_REF" > /tmp/compat-results/head-ref.txt + printf '%s' "$EXIT_CODE" > /tmp/compat-results/exit-code.txt + if [ -f /tmp/template-check-output.txt ]; then + cp /tmp/template-check-output.txt /tmp/compat-results/output.txt + fi + + - name: Upload results artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: template-compat-results + path: /tmp/compat-results/ + retention-days: 7 + + # Always exit 0 — this job is informational, not a merge gate + - name: Report result (non-blocking) + if: always() + run: | + EXIT_CODE="${{ steps.template-check.outputs.exit_code }}" + if [ "$EXIT_CODE" = "0" ]; then + echo "✅ All templates are compatible with this SDK change." + else + echo "⚠️ Some templates failed — see PR comment for details." + echo "This check is informational and does not block merging." + fi + exit 0 diff --git a/package.json b/package.json index aee36150..87a28c9b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "check:ci": "turbo run check:ci", "format": "turbo run format", "full-checks": "./scripts/full-checks.sh", + "test:templates": "./scripts/test-templates.sh", "lint": "turbo run lint", "typecheck": "turbo run typecheck" }, diff --git a/scripts/template-compatibility-comment.js b/scripts/template-compatibility-comment.js new file mode 100644 index 00000000..428c72db --- /dev/null +++ b/scripts/template-compatibility-comment.js @@ -0,0 +1,100 @@ +/** + * Used by .github/workflows/template-compatibility-comment.yml. + * Reads /tmp/compat-results from the prior workflow's artifact and posts or + * removes a PR comment via the GitHub API. + */ +module.exports = async ({ github, context }) => { + const fs = require('fs'); + + const read = (filename) => { + try { + return fs.readFileSync(`/tmp/compat-results/${filename}`, 'utf8').trim(); + } catch { + return ''; + } + }; + + // Validate PR number — must be a positive integer. + const prNumber = parseInt(read('pr-number.txt'), 10); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + console.log('Invalid or missing PR number in artifact; skipping comment.'); + return; + } + + const exitCode = read('exit-code.txt'); + const fullOutput = read('output.txt'); + // Sanitize values read from the artifact before embedding in markdown + // to prevent injection (e.g. a malicious branch name or script output + // containing markdown syntax that escapes a code fence). + const templatesRef = read('templates-ref.txt').replace(/[^a-zA-Z0-9._\/-]/g, ''); + const headRef = read('head-ref.txt').replace(/[^a-zA-Z0-9._\/-]/g, ''); + + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find((c) => c.body.includes(marker)); + + if (exitCode === '0') { + // Templates pass — remove any stale failure comment. + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + return; + } + + // Extract just the "Results" and "Failure Details" sections from output. + const resultsMatch = fullOutput.match(/={8,}\nResults:.*\n={8,}[\s\S]*/); + const failureSummary = resultsMatch ? resultsMatch[0].trim() : fullOutput.trim(); + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.payload.workflow_run.id}`; + const refNote = + templatesRef === 'main' + ? 'tested against `cre-templates:main`' + : `tested against \`cre-templates:${templatesRef}\` (compat branch)`; + + const body = [ + '## ⚠️ Template Compatibility Failures', + '', + `This PR breaks one or more templates in [cre-templates](https://github.com/smartcontractkit/cre-templates) (${refNote}).`, + '', + '```', + failureSummary, + '```', + '', + `[View full output →](${runUrl})`, + '', + '
', + 'What should I do?', + '', + '- **Accidental break:** Fix the SDK change so existing templates continue to compile.', + `- **Intentional breaking change:** Create a branch in \`cre-templates\` named \`compat/${headRef}\` with the template fixes applied. This job will automatically retest against that branch.`, + '', + '
', + ].join('\n'); + + const commentBody = `${marker}\n${body}`; + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody, + }); + } +}; diff --git a/scripts/test-templates.sh b/scripts/test-templates.sh new file mode 100755 index 00000000..26f91fc3 --- /dev/null +++ b/scripts/test-templates.sh @@ -0,0 +1,344 @@ +#!/bin/bash +# test-templates.sh +# Builds the local SDK, packs it into a tarball, and runs it against all +# TypeScript templates in the cre-templates repository. +# The cre-templates directory is fully restored on exit, even if the script crashes. +# +# Usage: +# bun run test:templates [--verbose|-v] +# +# Environment variables: +# TEMPLATES_DIR Path to the cre-templates repo (default: ../cre-templates) +# VERBOSE Set to 1 to enable verbose output (same as --verbose) + +# -------------------------------------------------------------------------- +# Flags + +VERBOSE=false +for arg in "$@"; do + case "$arg" in + -v|--verbose) VERBOSE=true ;; + esac +done +[ "${VERBOSE:-0}" = "1" ] && VERBOSE=true + +# -------------------------------------------------------------------------- +# Logging helpers + +# Always printed +info() { echo "$@"; } +# Only printed in verbose mode +vlog() { $VERBOSE && echo "$@" || true; } + +# Run a command, streaming output in verbose mode or capturing it silently. +# Usage: run_captured [args...] +# Sets $output_var to the combined stdout+stderr of the command. +# Returns the command's exit code. +run_captured() { + local _outvar="$1"; shift + local _tmpfile; _tmpfile=$(mktemp) + if $VERBOSE; then + "$@" 2>&1 | tee "$_tmpfile"; local _rc="${PIPESTATUS[0]}" + else + "$@" > "$_tmpfile" 2>&1; local _rc=$? + fi + # shellcheck disable=SC2086 + printf -v "$_outvar" '%s' "$(cat "$_tmpfile")" + rm -f "$_tmpfile" + return "$_rc" +} + +# -------------------------------------------------------------------------- +# Cleanup tracking + +LOCKFILE_BACKUPS=() # "backup_path:original_path" pairs +GENERATED_FILES=() # files to delete on exit +SDK_TARBALLS=() # tarball files to delete on exit +TEMP_FILES=() # misc temp files + +MONOREPO_ROOT="" # set after cd below + +cleanup() { + info "" + info "Cleaning up..." + + local sdk_pkg_bak="$MONOREPO_ROOT/packages/cre-sdk/package.json.bak" + if [ -f "$sdk_pkg_bak" ]; then + mv "$sdk_pkg_bak" "$MONOREPO_ROOT/packages/cre-sdk/package.json" + vlog " Restored: packages/cre-sdk/package.json" + fi + + for entry in "${LOCKFILE_BACKUPS[@]+"${LOCKFILE_BACKUPS[@]}"}"; do + local backup="${entry%%:*}" + local original="${entry##*:}" + if [ -f "$backup" ]; then + mv "$backup" "$original" + vlog " Restored: $original" + fi + done + + for f in "${GENERATED_FILES[@]+"${GENERATED_FILES[@]}"}"; do + if [ -f "$f" ]; then + rm "$f" + vlog " Removed: $f" + fi + done + + for f in "${SDK_TARBALLS[@]+"${SDK_TARBALLS[@]}"}"; do + if [ -f "$f" ]; then + rm "$f" + vlog " Removed: $f" + fi + done + + for f in "${TEMP_FILES[@]+"${TEMP_FILES[@]}"}"; do + rm -f "$f" + done +} + +trap cleanup EXIT INT TERM + +# -------------------------------------------------------------------------- +# Setup + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MONOREPO_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$MONOREPO_ROOT" + +TEMPLATES_DIR="${TEMPLATES_DIR:-../cre-templates}" + +# -------------------------------------------------------------------------- +# 1. Build + +info "Building SDK..." + +# Back up the compiled wasm artifact before the build overwrites it. +# Using a file backup (not git restore) so we restore whatever state the +# developer had — including uncommitted changes — not just the last commit. +# IMPORTANT: the backup must live outside the dist/ directory because the +# build's clean step runs `rm -rf dist`, which would delete an in-place backup. +WASM_FILE="$MONOREPO_ROOT/packages/cre-sdk-javy-plugin/dist/javy-chainlink-sdk.plugin.wasm" +if [ -f "$WASM_FILE" ]; then + WASM_BACKUP=$(mktemp) + cp "$WASM_FILE" "$WASM_BACKUP" + LOCKFILE_BACKUPS+=("$WASM_BACKUP:$WASM_FILE") +fi + +_build_out="" +if ! run_captured _build_out bun run build; then + info "❌ SDK build failed." + info "" + info "$_build_out" + exit 1 +fi +info "✅ SDK built." +info "" + +# -------------------------------------------------------------------------- +# 2. Pack + +info "Packing SDK..." + +cd packages/cre-sdk-javy-plugin +_pack_log=$(mktemp) +if ! bun pm pack --quiet >"$_pack_log" 2>&1; then + info "❌ Failed to pack Javy plugin." + info "$(cat "$_pack_log")" + rm -f "$_pack_log" + exit 1 +fi +JAVY_TARBALL=$(grep -oE '[^[:space:]]+\.tgz' "$_pack_log" | tail -n1 | tr -d '\r\n') +if [ -z "$JAVY_TARBALL" ] || [ ! -f "$(pwd)/$JAVY_TARBALL" ]; then + info "❌ Failed to pack Javy plugin (no .tgz produced or name not found)." + info "$(cat "$_pack_log")" + rm -f "$_pack_log" + exit 1 +fi +rm -f "$_pack_log" +JAVY_TARBALL_PATH="$(pwd)/$JAVY_TARBALL" +SDK_TARBALLS+=("$JAVY_TARBALL_PATH") +vlog " Javy plugin: $JAVY_TARBALL_PATH" + +cd ../cre-sdk +cp package.json package.json.bak +bun pm pkg set "dependencies.@chainlink/cre-sdk-javy-plugin=file:$JAVY_TARBALL_PATH" + +_pack_log=$(mktemp) +if ! bun pm pack --quiet >"$_pack_log" 2>&1; then + info "❌ Failed to pack SDK." + info "$(cat "$_pack_log")" + rm -f "$_pack_log" + exit 1 +fi +TARBALL=$(grep -oE '[^[:space:]]+\.tgz' "$_pack_log" | tail -n1 | tr -d '\r\n') +if [ -z "$TARBALL" ] || [ ! -f "$(pwd)/$TARBALL" ]; then + info "❌ Failed to pack SDK (no .tgz produced or name not found)." + info "$(cat "$_pack_log")" + rm -f "$_pack_log" + exit 1 +fi +rm -f "$_pack_log" +TARBALL_PATH="$(pwd)/$TARBALL" +SDK_TARBALLS+=("$TARBALL_PATH") +vlog " SDK: $TARBALL_PATH" + +mv package.json.bak package.json +cd "$MONOREPO_ROOT" + +info "✅ SDK packed." +info "" + +# -------------------------------------------------------------------------- +# 3. Discover templates + +if [ ! -d "$TEMPLATES_DIR" ]; then + info "❌ Templates directory not found: $TEMPLATES_DIR" + info "Override with: TEMPLATES_DIR=/path/to/cre-templates bun run test:templates" + exit 1 +fi + +TEMPLATES_ABS_PATH="$(cd "$TEMPLATES_DIR" && pwd)" + +# Collect all package.json files that depend on @chainlink/cre-sdk +ALL_PKGS=() +while IFS= read -r pkg; do + grep -q '"@chainlink/cre-sdk"' "$pkg" && ALL_PKGS+=("$pkg") +done < <(/usr/bin/find "$TEMPLATES_ABS_PATH" -name "package.json" -not -path "*/node_modules/*") + +TOTAL=${#ALL_PKGS[@]} +info "Found $TOTAL TypeScript templates to test." +info "" + +# -------------------------------------------------------------------------- +# 4. Test each template + +FAILED_TEMPLATES=() +PASSED_TEMPLATES=() +# Parallel arrays: failure reason and captured output for each failed template +FAILURE_STEPS=() +FAILURE_OUTPUTS=() + +IDX=0 +for pkg in "${ALL_PKGS[@]}"; do + IDX=$((IDX + 1)) + WORKFLOW_DIR=$(dirname "$pkg") + WORKFLOW_DIR_NAME=$(basename "$WORKFLOW_DIR") + PROJECT_NAME=$(basename "$(dirname "$WORKFLOW_DIR")") + DISPLAY_NAME="$PROJECT_NAME/$WORKFLOW_DIR_NAME" + PREFIX="[$IDX/$TOTAL]" + + vlog "--------------------------------------------------------" + vlog "$PREFIX $DISPLAY_NAME" + vlog "--------------------------------------------------------" + + cd "$WORKFLOW_DIR" + + # Track lock files for restoration + for lockfile in package-lock.json bun.lock; do + if [ -f "$lockfile" ]; then + cp "$lockfile" "$lockfile.bak" + LOCKFILE_BACKUPS+=("$WORKFLOW_DIR/$lockfile.bak:$WORKFLOW_DIR/$lockfile") + else + GENERATED_FILES+=("$WORKFLOW_DIR/$lockfile") + fi + done + + # Install dependencies + vlog " Installing dependencies..." + _out="" + if ! run_captured _out bun install; then + info " ❌ $PREFIX $DISPLAY_NAME" + FAILED_TEMPLATES+=("$DISPLAY_NAME") + FAILURE_STEPS+=("bun install") + FAILURE_OUTPUTS+=("$_out") + continue + fi + + # Inject local SDK tarball + vlog " Installing local SDK..." + if ! run_captured _out bun install --no-save "@chainlink/cre-sdk@file:$TARBALL_PATH"; then + info " ❌ $PREFIX $DISPLAY_NAME" + FAILED_TEMPLATES+=("$DISPLAY_NAME") + FAILURE_STEPS+=("sdk install") + FAILURE_OUTPUTS+=("$_out") + continue + fi + + FAILED_THIS=false + + # Typecheck + if grep -q '"typecheck"' package.json; then + vlog " Running typecheck..." + if ! run_captured _out bun run typecheck; then + info " ❌ $PREFIX $DISPLAY_NAME" + FAILED_TEMPLATES+=("$DISPLAY_NAME") + FAILURE_STEPS+=("typecheck") + FAILURE_OUTPUTS+=("$_out") + FAILED_THIS=true + else + vlog " ✅ typecheck passed" + fi + else + vlog " ⚠️ No typecheck script, skipping" + fi + + if $FAILED_THIS; then continue; fi + + # Compile to WASM + if [ -f "main.ts" ]; then + GENERATED_FILES+=("$WORKFLOW_DIR/main.js" "$WORKFLOW_DIR/main.wasm") + vlog " Running cre-compile..." + if ! run_captured _out bunx cre-compile main.ts; then + info " ❌ $PREFIX $DISPLAY_NAME" + FAILED_TEMPLATES+=("$DISPLAY_NAME") + FAILURE_STEPS+=("cre-compile") + FAILURE_OUTPUTS+=("$_out") + continue + fi + vlog " ✅ compile passed" + info " ✅ $PREFIX $DISPLAY_NAME" + PASSED_TEMPLATES+=("$DISPLAY_NAME") + else + vlog " ⚠️ No main.ts, skipping compile" + info " ✅ $PREFIX $DISPLAY_NAME (typecheck only)" + PASSED_TEMPLATES+=("$DISPLAY_NAME (typecheck only)") + fi +done + +# -------------------------------------------------------------------------- +# 5. Summary + +PASS_COUNT=${#PASSED_TEMPLATES[@]} +FAIL_COUNT=${#FAILED_TEMPLATES[@]} + +info "" +info "========================================================" +info "Results: $PASS_COUNT passed, $FAIL_COUNT failed" +info "========================================================" + +if [ $FAIL_COUNT -gt 0 ]; then + info "" + info "Failed templates:" + for t in "${FAILED_TEMPLATES[@]}"; do + info " ❌ $t" + done + + info "" + info "========================================================" + info "Failure Details" + info "========================================================" + + for i in "${!FAILED_TEMPLATES[@]}"; do + info "" + info "❌ ${FAILED_TEMPLATES[$i]} — ${FAILURE_STEPS[$i]}" + info "--------" + # Print the captured output, stripping common PM noise to highlight real errors + echo "${FAILURE_OUTPUTS[$i]}" | grep -v "^npm warn" | grep -v "^bun install v" | grep -v "^$" || true + done + + exit 1 +else + info "" + info "All templates passed!" + exit 0 +fi