diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6892fa40..5a33481b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # Web SDK team owns all examples -* @PSPDFKit/nickel +* @PSPDFKit/web diff --git a/.github/workflows/update-nutrient-sdk.yml b/.github/workflows/update-nutrient-sdk.yml new file mode 100644 index 00000000..1410e34b --- /dev/null +++ b/.github/workflows/update-nutrient-sdk.yml @@ -0,0 +1,175 @@ +name: Update Nutrient SDK + +on: + schedule: + # Daily, deliberately off the hour: GitHub deprioritises schedules that + # bunch on :00, which delays them further. + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + version: + description: "Version to bump to. Defaults to the npm latest dist-tag." + required: false + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + timeout-minutes: 90 + runs-on: ubuntu-latest + env: + # Root install runs `prepare`, which points core.hooksPath at .husky and + # would gate the bot's commit on lint-staged and the Biome version check. + HUSKY: "0" + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .tool-versions + + - name: Check for a new SDK release + id: check + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_VERSION: ${{ inputs.version }} + run: ./scripts/check-nutrient-update.sh "${REQUESTED_VERSION:-}" + + - name: Bump the SDK in every example + if: steps.check.outputs.should_update == 'true' + env: + VERSION: ${{ steps.check.outputs.version }} + run: ./scripts/update-nutrient-in-examples.sh "$VERSION" + + - name: Check every CDN reference was bumped + if: steps.check.outputs.should_update == 'true' + env: + VERSION: ${{ steps.check.outputs.version }} + run: | + # The CDN file map in update-nutrient-in-cdn.js is hand-maintained, so an + # example that gains a script tag without an entry is left behind in + # silence. examples/salesforce/README.md is exempt: its version is an + # illustration, not a pin. + stale="$(grep -rEn "pspdfkit-web@[0-9]+\.[0-9]+\.[0-9]+" examples/ \ + --exclude-dir=node_modules --exclude-dir=dist \ + --exclude-dir=.next --exclude-dir=.nuxt \ + | grep -v "pspdfkit-web@${VERSION}" \ + | grep -v '^examples/salesforce/README.md:' || true)" + + if [ -n "$stale" ]; then + echo "CDN references not updated to ${VERSION}:" >&2 + echo "$stale" >&2 + exit 1 + fi + + - name: Install root dependencies + if: steps.check.outputs.should_update == 'true' + run: pnpm install --frozen-lockfile + + - name: Format + if: steps.check.outputs.should_update == 'true' + run: pnpm run format + + - name: Install Playwright browsers + if: steps.check.outputs.should_update == 'true' + run: pnpm exec playwright install chromium --with-deps + + # A pull request opened with GITHUB_TOKEN does not trigger the Biome or + # Playwright workflows, and main requires no status checks, so the bump + # would otherwise arrive with no signal at all. + - name: Run e2e smoke tests + id: e2e + if: steps.check.outputs.should_update == 'true' + continue-on-error: true + run: pnpm run e2e-tests + + - name: Upload Playwright report + if: steps.check.outputs.should_update == 'true' && steps.e2e.outcome != 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + - name: Commit and push the bump + if: steps.check.outputs.should_update == 'true' + env: + VERSION: ${{ steps.check.outputs.version }} + BRANCH: ${{ steps.check.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + # `pnpm run format` writes unsafe Biome fixes repository-wide, and a dev + # server may leave build output behind; neither belongs in a bump. + git add -u examples/ + if git diff --cached --quiet; then + echo "Detection reported ${VERSION} was needed but nothing changed." >&2 + exit 1 + fi + git commit -m "Update examples with Nutrient SDK version $VERSION" + git push origin "$BRANCH" + + - name: Open the pull request + if: steps.check.outputs.should_update == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.check.outputs.version }} + CURRENT: ${{ steps.check.outputs.current }} + BRANCH: ${{ steps.check.outputs.branch }} + E2E_OUTCOME: ${{ steps.e2e.outcome }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + BASE: ${{ github.ref_name }} + run: | + if [ "$E2E_OUTCOME" = "success" ]; then + e2e_result="✅ passed" + draft="" + else + e2e_result="❌ failed. The failing examples are named at the end of the run log." + draft="--draft" + fi + + cat > /tmp/pr-body.md <
``` ## Adding a New Example @@ -124,6 +125,13 @@ svelte-kit, vue-composition-api. - **Biome** — Code formatting check on every push/PR - **Playwright** — Smoke tests on push/PR to main (installs all deps, runs e2e) +- **Update Nutrient SDK** — Daily check for a new `@nutrient-sdk/viewer` release. + Bumps every example listed in `update-nutrient-in-examples.sh`, formats with + Biome, runs the e2e suite inside the job, then opens a PR. A passing suite + opens it ready for review, so CODEOWNERS requests `@PSPDFKit/web`; a failing + one opens it as a draft and fails the run, because GitHub does not request + code owners on drafts. Can be run on demand via `workflow_dispatch`, + optionally against a specific version. ## Code Style diff --git a/scripts/check-nutrient-update.sh b/scripts/check-nutrient-update.sh new file mode 100755 index 00000000..9b177a5a --- /dev/null +++ b/scripts/check-nutrient-update.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Decides whether a Nutrient SDK bump is needed, refusing one already in flight. +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="${SCRIPT_DIR}/.." + +# Every example is held at the same version, so one of them reports the version +# the repository is on. +REFERENCE_EXAMPLE="react" + +DIST_TAGS_URL="https://registry.npmjs.org/-/package/@nutrient-sdk/viewer/dist-tags" + +requested="${1:-}" + +if [ -n "${requested}" ]; then + latest="${requested}" +else + latest="$(curl -fsSL --retry 3 --retry-delay 2 "${DIST_TAGS_URL}" | jq -er '.latest')" +fi + +# Anything carrying a prerelease suffix is a nightly, never a release we ship. +if ! [[ "${latest}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to act on non-stable version \"${latest}\"." >&2 + exit 1 +fi + +# jq -r prints "null" and exits 0 for a missing key, which would silently +# disable the already-on-this-version check. +if ! current="$(jq -er '.dependencies["@nutrient-sdk/viewer"]' \ + "${REPO_ROOT}/examples/${REFERENCE_EXAMPLE}/package.json")"; then + echo "examples/${REFERENCE_EXAMPLE} has no @nutrient-sdk/viewer dependency." >&2 + echo "The reference example moved or was renamed; update REFERENCE_EXAMPLE." >&2 + exit 1 +fi + +branch="update-examples-${latest}" + +if [ "${latest}" = "${current}" ]; then + should_update="false" + reason="already on ${latest}" +else + # --state all: a bump closed without merging must not be reopened on every + # scheduled run. A failed lookup must not read as "no pull request". + if ! pull_requests="$(gh pr list --state all --head "${branch}" \ + --json number --jq '.[].number')"; then + echo "Could not list pull requests for ${branch}; refusing to guess." >&2 + exit 1 + fi + + if [ -n "${pull_requests}" ]; then + should_update="false" + reason="a pull request for ${branch} already exists" + else + ls_remote_status=0 + git -C "${REPO_ROOT}" ls-remote --exit-code --heads origin "${branch}" \ + >/dev/null || ls_remote_status=$? + + case "${ls_remote_status}" in + # An earlier run pushed the branch and then failed before opening its + # pull request. Skipping quietly would bury this version for good. + 0) + echo "Branch ${branch} exists with no pull request, so an earlier run" >&2 + echo "failed part-way. Delete it or open its pull request by hand." >&2 + exit 1 + ;; + # --exit-code reserves 2 for "no matching ref"; anything else is a real + # failure that must not read as "the branch is free". + 2) + should_update="true" + reason="${current} -> ${latest}" + ;; + *) + echo "git ls-remote failed with status ${ls_remote_status}." >&2 + exit 1 + ;; + esac + fi +fi + +echo "should_update=${should_update} (${reason})" + +{ + echo "version=${latest}" + echo "current=${current}" + echo "branch=${branch}" + echo "should_update=${should_update}" +} >> "${GITHUB_OUTPUT:-/dev/stdout}" diff --git a/scripts/update-nutrient-in-cdn.js b/scripts/update-nutrient-in-cdn.js index 948bbed6..a139c843 100644 --- a/scripts/update-nutrient-in-cdn.js +++ b/scripts/update-nutrient-in-cdn.js @@ -1,40 +1,76 @@ const fs = require("node:fs"); const path = require("node:path"); -const { execSync } = require("node:child_process"); const cdnOcurrences = { - typescript: ["src/index.html"], - gatsby: ["src/templates/Viewport.js"], - salesforce: ["force-app/main/default/pages/Nutrient_InitNutrient.page"], + gatsbyjs: ["src/templates/Viewport.js"], "javascript-vite": ["index.html"], + nuxtjs: ["components/NutrientContainer.vue"], + salesforce: ["force-app/main/default/pages/Nutrient_InitNutrient.page"], + typescript: ["src/index.html"], "typescript-vite": ["index.html"], webpack: ["README.md", "src/index.html"], }; +const CDN_VERSION = /pspdfkit-web@\d+\.\d+\.\d+/g; + +const examplesDir = path.resolve(__dirname, "..", "examples"); + const example = process.argv[2]; +const version = process.argv[3]; + +if (!example || !version) { + console.error( + "Usage: node scripts/update-nutrient-in-cdn.js