diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml index 4e995259..f616ce9b 100644 --- a/.github/workflows/deploy-storybook.yml +++ b/.github/workflows/deploy-storybook.yml @@ -5,26 +5,37 @@ on: branches: [ main ] pull_request: branches: [ main ] + types: [opened, synchronize, reopened, closed] + +permissions: + contents: write + pull-requests: write jobs: build-and-deploy: runs-on: ubuntu-latest + # PR previews publish unreviewed HTML to the live Pages origin, so they + # are limited to same-repo branches (fork PRs also have a read-only + # GITHUB_TOKEN and could never deploy — this skips the red-noise run). + if: >- + github.event.action != 'closed' && + (github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 with: version: 9.7.0 - - name: Authenticate npm registry - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.TEST_NPM_TOKEN }}" > ~/.npmrc + # No npm auth: all dependencies are public. - name: Get pnpm store directory shell: bash @@ -32,7 +43,7 @@ jobs: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -51,7 +62,7 @@ jobs: # Get the GitHub Pages URL dynamically - name: Get Pages URL id: pages - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { data } = await github.rest.repos.getPages({ @@ -63,7 +74,7 @@ jobs: # Main branch → deploy to root - name: Deploy (main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3.9.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./packages/react/storybook-static @@ -76,7 +87,7 @@ jobs: # PR → deploy to /pr// subdirectory (doesn't touch main) - name: Deploy (PR preview) if: github.event_name == 'pull_request' - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3.9.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./packages/react/storybook-static @@ -89,7 +100,7 @@ jobs: - name: Add Storybook link to PR description if: github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const url = `${{ steps.pages.outputs.url }}pr/${{ github.event.pull_request.number }}/`; @@ -114,3 +125,30 @@ jobs: pull_number: context.issue.number, body: newBody, }); + + # Remove the pr// preview from gh-pages when the PR closes so the + # live origin doesn't accumulate stale unreviewed HTML. + cleanup-preview: + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout gh-pages + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: gh-pages + + - name: Remove preview directory + run: | + PR_DIR="pr/${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git rm -r --quiet "$PR_DIR" + git commit -m "chore: remove Storybook preview for closed PR #${{ github.event.pull_request.number }}" + git push origin gh-pages + else + echo "No preview directory $PR_DIR — nothing to clean up" + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb5c38b3..1b6a1f16 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,12 @@ # Release & Publish # # Gated publish flow: -# 1. PR merged to main → triggers build & test +# 1. PR merged to main → the FULL quality-gate suite runs (reusable +# test.yml: typecheck, bundle budget, tests, CSP, browser E2E, +# Storybook smoke/visual, Next.js integration) # 2. A reviewer approves the publish in the Actions UI (environment gate) -# 3. Package is published to npm via trusted publishing (OIDC) +# 3. Package is published to npm via trusted publishing (OIDC) with +# provenance, from the exact SHA the gates ran against # # Version: reads the latest published version from npm and bumps from that. # Defaults to patch. Add a `release:minor` or `release:major` PR label to @@ -36,48 +39,18 @@ on: default: patch jobs: - build-and-test: - name: Build & Test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.7.0 - - - name: Get pnpm store directory - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build packages - run: pnpm build - - - name: Run tests - run: pnpm test + # The FULL quality-gate suite (typecheck, bundle budget, unit tests, CSP + # gate, browser E2E, Storybook smoke + visual drift, Next.js integration) + # is a hard dependency of publishing — a publish cannot be approved while + # any advertised gate is red. + gates: + name: Quality gates + uses: ./.github/workflows/test.yml + secrets: inherit publish: name: Publish to npm - needs: build-and-test + needs: gates runs-on: ubuntu-latest concurrency: group: publish @@ -89,33 +62,37 @@ jobs: pull-requests: read steps: + # Check out the SHA the gates ran against — main may have advanced + # between the test run and the environment approval. - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: main + ref: ${{ github.sha }} - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 with: version: 9.7.0 # setup-node with registry-url enables OIDC token exchange for npm publish. # All @unlayer deps are public — no NPM_TOKEN needed. - name: Setup Node.js 20 (with npm registry for OIDC) - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 registry-url: https://registry.npmjs.org + # Pinned major — the publish path must not drift with whatever npm + # ships the day of a release. - name: Update npm for trusted publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Get pnpm store directory shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -185,13 +162,14 @@ jobs: # Two-step publish: pnpm pack resolves catalog: protocol to real # version numbers, then npm publish handles OIDC trusted publishing. # npm publish alone does NOT understand catalog: and would ship broken deps. - # No --provenance flag — not supported for private source repos. + # --provenance: the repo is public, so consumers get a verifiable + # supply-chain attestation linking the tarball to this workflow run. - name: Publish to npm (trusted publishing) working-directory: packages/react run: | pnpm pack --pack-destination /tmp TARBALL=$(ls /tmp/unlayer-react-elements-*.tgz) - npm publish "$TARBALL" --access public + npm publish "$TARBALL" --access public --provenance - name: Tag release run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c47009b..5b7dd584 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,32 +1,38 @@ name: Test & Coverage +# Also callable as a reusable workflow (workflow_call) so publish.yml can make +# the FULL gate suite a hard dependency of publishing — not a parallel, +# non-blocking run. on: push: - branches: [ main, develop ] + branches: [ main ] pull_request: - branches: [ main, develop ] + branches: [ main ] + workflow_call: + +permissions: + contents: read jobs: test: name: Run Tests & Coverage runs-on: ubuntu-latest - + steps: - name: Checkout code - uses: actions/checkout@v4 - + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup Node.js 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - + - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 with: version: 9.7.0 - - name: Authenticate npm registry - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.TEST_NPM_TOKEN }}" > ~/.npmrc + # No npm auth: all dependencies are public. - name: Get pnpm store directory shell: bash @@ -34,7 +40,7 @@ jobs: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -55,16 +61,16 @@ jobs: run: pnpm --filter @unlayer/react-elements typecheck # ── Bundle size budget ────────────────────────────────────── - # Fail if the react-elements ESM bundle exceeds 75KB. - # Current size: ~70KB (14+ components + image width-pinning geometry - # + per-mode full-document shells for renderToHtml). + # Fail if the react-elements ESM bundle exceeds 100KB. + # Current size: ~82KB (14+ components + image width-pinning geometry, + # per-mode full-document shells, escaping + validation hardening). # The budget tracks the unminified ESM output as a proxy for code volume; # it still flags accidental dependency bundling (any real dep is 10KB+). - name: Check bundle size run: | BUNDLE="packages/react/dist/index.js" SIZE=$(wc -c < "$BUNDLE" | tr -d ' ') - MAX_SIZE=75000 + MAX_SIZE=100000 echo "Bundle: $BUNDLE" echo "Size: $SIZE bytes (budget: $MAX_SIZE bytes)" if [ "$SIZE" -gt "$MAX_SIZE" ]; then @@ -134,14 +140,14 @@ jobs: npm run build - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false verbose: true - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-reports path: | diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index dbe995f0..ee0f22d9 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -2,9 +2,17 @@ # # Checks npm for new versions of @unlayer/exporters and @unlayer/types # and opens a PR to update the pnpm catalog if newer versions exist. -# Runs build + tests first and includes the result in the PR body. +# Runs build + tests first and includes the result in the PR body; a failed +# verification also fails this workflow run so the bump is visibly red. # # Runs daily and on manual dispatch. +# +# KNOWN LIMITATION: the PR is created with GITHUB_TOKEN, and GitHub does not +# trigger `pull_request` workflows for events created by GITHUB_TOKEN — so +# test.yml does NOT run on these PRs automatically. Until PR creation moves +# to a GitHub App / fine-grained PAT token, protect against silent merges by +# requiring the "Run Tests & Coverage" check in branch protection (a re-run +# can be triggered by closing/reopening the PR or pushing to the branch). name: Update Dependencies @@ -28,15 +36,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node.js 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 with: version: 9.7.0 @@ -70,10 +78,11 @@ jobs: if: steps.check.outputs.changed == 'true' run: pnpm install --no-frozen-lockfile + # The script always exits 0 here so the PR (with the failure status in + # its body) is still created; the final step fails the run afterwards. - name: Build and test id: verify if: steps.check.outputs.changed == 'true' - continue-on-error: true run: | set +e @@ -158,3 +167,11 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Surface a failed verification as a red workflow run — the PR body + # already carries the ❌, but the run itself must not look green. + - name: Fail the run if verification failed + if: steps.check.outputs.changed == 'true' && steps.verify.outputs.status != 'passed' + run: | + echo "::error::Dependency bump failed build/tests — see the PR body and logs above" + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index d0cf2a7f..bd26de65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Example: `); + expect(fromChildren).toContain("a < b & "c""); + + const fromTextProp = inEmail(