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 ", + ); + process.exit(1); +} + +if (!/^\d+\.\d+\.\d+$/.test(version)) { + console.error(`Expected a semver version, got "${version}".`); + process.exit(1); +} -if (cdnOcurrences[example]) { +// A key matching no directory is never looked up, so a typo in one is silent. +const strayKeys = Object.keys(cdnOcurrences).filter( + (key) => !fs.existsSync(path.resolve(examplesDir, key)), +); + +if (strayKeys.length > 0) { + console.error(`Keys matching no example directory: ${strayKeys.join(", ")}`); + process.exit(1); +} + +if (Object.hasOwn(cdnOcurrences, example)) { console.log(`Updating CDN version in ${example} example.`); - for (const relativePath of cdnOcurrences[example]) { - const template = fs.readFileSync( - path.resolve(`./examples/${example}/${relativePath}`), - "utf8", - ); - - const version = execSync("npm view @nutrient-sdk/viewer version") - .toString() - .trim(); - - const updatedTemplate = template.replace( - /pspdfkit-web@([0-9]+.[0-9]+.[0-9]+)?/g, - `pspdfkit-web@${version}`, - ); - - fs.writeFileSync( - path.resolve(`./examples/${example}/${relativePath}`), - updatedTemplate, - ); + const filePaths = cdnOcurrences[example].map((relativePath) => + path.resolve(examplesDir, example, relativePath), + ); + + // Up front: a renamed file would otherwise abort the run half-written. + const missing = filePaths.filter((filePath) => !fs.existsSync(filePath)); + + if (missing.length > 0) { + console.error(`Expected CDN files are missing:\n${missing.join("\n")}`); + process.exit(1); + } + + for (const filePath of filePaths) { + const template = fs.readFileSync(filePath, "utf8"); + let replacements = 0; + + const updated = template.replace(CDN_VERSION, () => { + replacements += 1; + return `pspdfkit-web@${version}`; + }); + + // A zero-match replace rewrites the file byte-identical, reporting success. + if (replacements === 0) { + console.error(`No versioned CDN reference found in ${filePath}.`); + process.exit(1); + } + + fs.writeFileSync(filePath, updated); } console.log(`Updated CDN version in ${example} example.`); diff --git a/scripts/update-nutrient-in-examples.sh b/scripts/update-nutrient-in-examples.sh index a2c97906..cf0eb1e8 100755 --- a/scripts/update-nutrient-in-examples.sh +++ b/scripts/update-nutrient-in-examples.sh @@ -3,6 +3,18 @@ set -euo pipefail SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +VERSION="${1:-}" + +if [ -z "${VERSION}" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Expected a semver version, got \"${VERSION}\"." >&2 + exit 1 +fi + Green='\033[0;32m' Yellow='\033[0;33m' NoColor='\033[0m' @@ -12,25 +24,28 @@ upgrade_npm_in_example() { pushd "${SCRIPT_DIR}/../examples/${directory}/" > /dev/null - echo -e "\n${Green}Upgrading npm in ${Yellow}${directory}${Green} example${NoColor}" + echo -e "\n${Green}Upgrading ${Yellow}${directory}${Green} to ${Yellow}${VERSION}${NoColor}" if [ -f "pnpm-lock.yaml" ]; then - pnpm install @nutrient-sdk/viewer@latest --save --save-exact + pnpm install "@nutrient-sdk/viewer@${VERSION}" --save --save-exact pnpm install > /dev/null pnpm audit fix > /dev/null || true elif [ -f "package-lock.json" ]; then - npm install @nutrient-sdk/viewer@latest --save --save-exact + npm install "@nutrient-sdk/viewer@${VERSION}" --save --save-exact npm install > /dev/null npm audit fix > /dev/null || true + else + echo "examples/${directory} has no lockfile, so nothing would be installed." >&2 + exit 1 fi popd > /dev/null - node ./scripts/update-nutrient-in-cdn.js "${directory}" + node "${SCRIPT_DIR}/update-nutrient-in-cdn.js" "${directory}" "${VERSION}" } upgrade_npm_in_example "webpack"