diff --git a/.github/workflows/browsers.yml b/.github/workflows/browsers.yml index 2a3b5da..8dfbab9 100644 --- a/.github/workflows/browsers.yml +++ b/.github/workflows/browsers.yml @@ -8,15 +8,94 @@ on: permissions: contents: read +concurrency: + group: browsers-${{ github.ref }} + cancel-in-progress: true + jobs: - e2e: - runs-on: ubuntu-latest + chromium: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Run Chromium tests + run: npm run test:e2e -- --project=chromium --reporter=line,html + - name: Upload failure evidence + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-chromium + path: | + playwright-report + test-results + if-no-files-found: ignore + retention-days: 7 + + firefox: + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 with: - node-version-file: .nvmrc + node-version: 24 cache: npm - - run: npm ci - - run: npx playwright install --with-deps chromium firefox webkit - - run: npm run test:e2e + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Install Firefox + run: npx playwright install --with-deps firefox + - name: Run Firefox tests + run: npm run test:e2e -- --project=firefox --reporter=line,html + - name: Upload failure evidence + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-firefox + path: | + playwright-report + test-results + if-no-files-found: ignore + retention-days: 7 + + webkit: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Install WebKit + run: npx playwright install --with-deps webkit + - name: Run WebKit tests + run: npm run test:e2e -- --project=webkit --reporter=line,html + - name: Upload failure evidence + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-webkit + path: | + playwright-report + test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a5f1bba..3457e15 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -8,14 +8,24 @@ on: permissions: contents: read +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + jobs: check: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 with: - node-version-file: .nvmrc + node-version: 24 cache: npm - - run: npm ci - - run: npm run check + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Run quality gate + run: npm run check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e2711f..5215cd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,115 @@ name: Release on: - workflow_dispatch: + release: + types: + - published permissions: contents: read - id-token: write + +concurrency: + group: npm-nipe-solutions-react-pull-to-refresh-stable + cancel-in-progress: false jobs: + verify: + if: github.event.release.prerelease == false + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + tarball: ${{ steps.release.outputs.tarball }} + channel: ${{ steps.release.outputs.channel }} + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + - name: Install dependencies + run: npm ci + - name: Run quality gate + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Run complete browser suite + run: npm run test:e2e + - name: Verify and preserve release artifact + id: release + run: npm run release:check -- --dry-run --output release-artifact + - name: Upload verified release artifact + uses: actions/upload-artifact@v7 + with: + name: npm-package-stable + path: release-artifact + if-no-files-found: error + retention-days: 3 + publish: + needs: verify environment: npm - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + id-token: write steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - name: Set up Node.js for trusted publishing + uses: actions/setup-node@v7 with: - node-version-file: .nvmrc + node-version: 24 registry-url: https://registry.npmjs.org - - run: npm ci - - run: npm run check - - run: npm publish --tag alpha --access public + - name: Download verified release artifact + uses: actions/download-artifact@v7 + with: + name: npm-package-stable + path: release-artifact + - name: Validate and publish verified artifact + env: + RELEASE_TARBALL: ${{ needs.verify.outputs.tarball }} + RELEASE_CHANNEL: ${{ needs.verify.outputs.channel }} + working-directory: release-artifact + run: | + set -euo pipefail + + if [[ "$RELEASE_CHANNEL" != "latest" ]]; then + echo "Release channel must be latest" >&2 + exit 1 + fi + + expected_tarball="nipe-solutions-react-pull-to-refresh-1.0.0.tgz" + if [[ ! "$RELEASE_TARBALL" =~ ^[A-Za-z0-9._-]+$ ]] || [[ "$RELEASE_TARBALL" != "$expected_tarball" ]]; then + echo "Unsafe release tarball: expected $expected_tarball" >&2 + exit 1 + fi + + manifest="${RELEASE_TARBALL}.sha512" + shopt -s nullglob + manifests=(*.sha512) + if (( ${#manifests[@]} != 1 )) || [[ "${manifests[0]}" != "$manifest" ]]; then + echo "Checksum manifest name must exactly match $manifest" >&2 + exit 1 + fi + if [[ ! -f "$RELEASE_TARBALL" ]] || [[ ! -f "$manifest" ]]; then + echo "Verified tarball or checksum manifest is missing" >&2 + exit 1 + fi + if [[ "$(wc -l < "$manifest")" -ne 1 ]]; then + echo "Checksum manifest must contain exactly one entry" >&2 + exit 1 + fi + + manifest_line="$(<"$manifest")" + if [[ ! "$manifest_line" =~ ^([[:xdigit:]]{128})[[:space:]][[:space:]]([A-Za-z0-9._-]+)$ ]]; then + echo "Checksum manifest must contain one SHA-512 entry" >&2 + exit 1 + fi + if [[ "${BASH_REMATCH[2]}" != "$RELEASE_TARBALL" ]]; then + echo "Checksum manifest filename must match the release tarball" >&2 + exit 1 + fi + + sha512sum --check --strict -- "$manifest" + npm publish --ignore-scripts --provenance --access public --tag "$RELEASE_CHANNEL" -- "$RELEASE_TARBALL" diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a5649..c1180dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes follow Keep a Changelog. ## [Unreleased] +## [1.0.0] - 2026-09-29 + +### Added + +- Verify packed ESM, CommonJS, TypeScript, CSS, and server-rendering contracts in isolated React 18 and React 19 consumers. +- Publish through a protected workflow that checks the exact tarball and SHA-512 manifest before npm provenance publishing. + +### Changed + +- Promote the established compound API and gesture lifecycle to the stable release channel. +- Run release policy checks alongside the existing unit, API, size, package, website, and three-engine browser suites. + ## [0.1.0-alpha.1] - 2026-09-06 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd2bb22..fb20e6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,12 @@ Thanks for helping make pull-to-refresh boringly reliable. -1. Use Node 24 and install with npm ci. +1. Use Node 24 and install with `npm ci`. 2. Create a focused branch. 3. Add a failing behavior test before changing mechanics. -4. Run npm run check and npm run test:e2e. -5. Describe browser and device validation accurately. +4. Run `npm run check` and `npm run test:e2e`. +5. Distinguish automated browser results from physical-device and human + assistive-technology validation. Do not combine application data behavior with gesture mechanics. New public configuration needs a demonstrated composition use case. diff --git a/README.md b/README.md index 180a6fb..3f4e0e5 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,10 @@ keyboard gesture, or focus movement. Directional-capable browsers use Pointer Events with `pan-x pan-down pinch-zoom`. Firefox/Safari use a session-scoped Touch Events compatibility adapter. Automated -engine tests do not verify physical iOS or Android behavior. Mobile QA remains -beta-blocking; use an application refresh button as the accessible alternative. +Chromium, Firefox, and WebKit tests cover mechanics, keyboard use, and axe rules. +They do not verify physical iOS or Android behavior. No physical-device or human +screen-reader session was performed for 1.0.0. Always provide an application +refresh button as the accessible alternative. See [browser behavior](docs/BROWSER_BEHAVIOR.md), [integrations](docs/INTEGRATIONS.md), and @@ -121,8 +123,8 @@ npm run test:e2e Requires Node 24. React 18.3 and React 19 are peer-supported. Runtime dependencies are limited to React peers. -Current status: 0.1.0-alpha.1. Automated checks do not replace the manual device -matrix. +Current status: 1.0.0 stable. Automated checks do not replace the documented +manual device and assistive-technology matrix. Part of [NIPE Open Source](https://opensource.nipesolutions.com). diff --git a/SECURITY.md b/SECURITY.md index b7040d9..560c931 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,5 +3,6 @@ Report vulnerabilities privately through GitHub Security Advisories for this repository. Do not open a public issue containing exploit details. -Pre-release versions receive fixes on the latest alpha line. No version is -stable until explicitly announced. +Security fixes are provided for the current 1.x release line. Earlier +prerelease versions are unsupported; upgrade to the latest stable version before +reporting an issue. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea122ae..9b69f63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,7 +61,8 @@ UI and telemetry ownership. Safari/Firefox mobile fallback and actual browser chrome interactions. Directly invoking React handlers or `page.mouse` cannot establish mobile touch -support. Beta requires meaningful physical iOS and Android evidence. +support. The 1.0 release has no physical iOS/Android or human screen-reader +evidence; those remain separate manual validation activities. Native listeners route through handlers published at React commit, so owner and refresh callback changes are observed without resubscribing active sessions. diff --git a/docs/BROWSER_BEHAVIOR.md b/docs/BROWSER_BEHAVIOR.md index 719fd44..9e62dd9 100644 --- a/docs/BROWSER_BEHAVIOR.md +++ b/docs/BROWSER_BEHAVIOR.md @@ -11,13 +11,14 @@ configuration. Safari may retain native browser Pull-to-Refresh ownership.** | Environment | Logic / E2E | Real touch | Native PTR conflict | | -------------------- | ----------------------------------- | --------------------------- | --------------------------- | | Chromium desktop | Automated + trusted CDP touch input | Physical mobile not implied | Desktop n/a | -| Android Chrome | Chromium engine coverage | Manual required | Test page refresh | +| Android Chrome | Chromium engine coverage | Not run | Not run | | Firefox desktop | Automated DOM and mouse traces | n/a | Desktop n/a | -| Firefox Android | Firefox engine coverage | Manual required | Test | +| Firefox Android | Firefox engine coverage | Not run | Not run | | WebKit desktop | Automated DOM and mouse traces | n/a | Desktop n/a | -| iPhone / iPad Safari | WebKit evidence only | Manual required | Native page refresh may win | +| iPhone / iPad Safari | WebKit evidence only | Not run | Native page refresh may win | -See [real-device QA](REAL_DEVICE_QA.md) for actual run status and beta gates. +See [real-device QA](REAL_DEVICE_QA.md) for actual run status and the manual +evidence checklist. `page.mouse` validates state mechanics, not mobile touch arbitration. Synthetic DOM Touch Events validate adapter cancellation and cleanup; they do not start native scrolling. Chromium CDP input exercises the browser's touch pipeline, @@ -124,8 +125,8 @@ commits; mouse/pen still use Pointer Events. Touch cancellation on the first relevant move is a compatibility strategy, not proof that every Safari/Firefox mobile configuration will surrender its stream. -Real-device validation remains beta-blocking. No retry of a browser-owned stream -and no early `preventDefault()` are used. +Physical-device validation was not run for 1.0.0. No retry of a browser-owned +stream and no early `preventDefault()` are used. ## Composition limits and troubleshooting diff --git a/docs/HARDENING_REPORT.md b/docs/HARDENING_REPORT.md index 98d69a7..9dbe23a 100644 --- a/docs/HARDENING_REPORT.md +++ b/docs/HARDENING_REPORT.md @@ -1,7 +1,7 @@ # Touch and scroll hardening report -Date: 2026-09-06. Release classification: **ALPHA READY**. This report records the initial -hardening snapshot. Release follow-up results are recorded below. +Date: 2026-09-06. This report records the initial prerelease hardening snapshot. +Stable release follow-up results are recorded below. ## 1. Original bugs reproduced @@ -53,7 +53,8 @@ alone prevents default. Touchmove is non-passive only on Root during a candidate Cancellation, additional touches, abandonment, blur, disable and unmount clean up. Compatibility pointer events do not create a second touch session. Forced fallback passes trusted Chromium input with auto CSS; synthetic DOM integration -passes Firefox/WebKit. Physical mobile fallback reliability is still pending. +passes Firefox/WebKit. Physical mobile fallback reliability was not established +by a device run. ## 5. Root overflow/overscroll changes @@ -134,12 +135,12 @@ The application still owns data, errors, UI meaning and its accessible button. The compatible WebKit runner was installed outside the repository, with the same final E2E files copied into it. No dependency/lockfile downgrade was made. -A current supported CI OS must rerun the normal three-engine command before beta. +The later GitHub Actions run recorded below closed this local runner gap. -## 14. Real-device QA completed/pending +## 14. Real-device QA status None completed in this pass. iPhone Safari, iPad Safari, Android Chrome and -Firefox Android are **manual pending**. [QA](REAL_DEVICE_QA.md) now includes +Firefox Android were **not run**. [QA](REAL_DEVICE_QA.md) includes separate page/element matrices, directional/fallback checks, editing/selection, zoom, nested scrollers, native PTR conflicts, and a result-recording template. The noindex `/qa/` website entry provides actual test surfaces. @@ -174,16 +175,16 @@ containers may govern CSS touch-action before Root; browser-owned/uncancelable streams remain untouched. Pre-contact CSS can be stale after purely imperative layout/ref changes without render. Ignoring an origin in JS does not override ancestor CSS policy. Root's content transform still affects fixed/absolute -positioning. The local current-runner WebKit environment needs a supported OS. -Website changes are local source changes; deployment was not performed. +positioning. Automated desktop-engine coverage does not remove the need to +reproduce device-specific reports on the affected hardware. Website deployment +is outside this report. -## 19. Release-readiness classification +## 19. Historical release classification -**ALPHA READY**, not BETA READY or STABLE READY. The cross-browser input strategy -and automated regressions are implemented. Beta remains blocked on meaningful -physical iOS/Android evidence, the supported mobile device matrix, and a normal -three-engine run on a current supported environment. No release version or tag -was changed. +This 2026-09-06 snapshot did not promote the package. The cross-browser input +strategy and automated regressions were implemented, while physical iOS/Android +evidence and a current three-engine CI run were still absent. No release version +or tag was changed during that pass. ## Release follow-up @@ -196,4 +197,17 @@ content did not overflow, so auto resolution correctly used Window and refused pulls after the page scrolled. Hero, Gesture Lab default and Swipe Actions demos now explicitly select their local surface. Three added browser regressions cover this scenario. The lab retains automatic mode with an -explanation of short-content behavior. Physical-device QA remains pending. +explanation of short-content behavior. Physical-device QA was not run. + +## Stable release follow-up — 2026-09-29 + +The 1.0.0 candidate retains the established public API and gesture mechanics. +The current automated evidence is 71 unit tests plus 103 passing browser tests +across Chromium, Firefox, and WebKit, with 8 explicitly skipped trusted-touch +cases that are Chromium-only. Packed-package checks cover separate React 18 and +React 19 consumers, and the release path verifies the exact tarball before +protected npm provenance publishing. + +No physical iOS/Android or human screen-reader session was performed for 1.0.0. +Stable promotion is based on the automated evidence above and the documented +limitations; it does not relabel unperformed manual checks as passed. diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 14702ee..3b666c5 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -51,9 +51,9 @@ element explicitly: ``` -Make the root itself scrollable. Do not place a default PTR root—with its -mechanical overflow containment—inside a second element that is expected to -receive chained scrolling. +Make the root itself scrollable. Avoid placing it inside a competing scroll +surface when the root is expected to receive the gesture; pass the intended +owner explicitly when the layout is ambiguous. Stop pointer-down propagation from the feed when the sheet handle is the only surface that should drag the sheet. This leaves the browser default untouched, @@ -62,5 +62,5 @@ from racing to capture the same downward gesture at the top boundary. The documentation proof uses the published v5 package. Desktop automation verifies that the sheet opens, scrolls the dedicated root, and closes. -Combined drag feel on iOS and Android remains manual pending; the alpha does not -claim physical-device verification. +Combined drag behavior was not tested on physical iOS or Android devices for +1.0.0; desktop automation is not presented as physical-device verification. diff --git a/docs/REAL_DEVICE_QA.md b/docs/REAL_DEVICE_QA.md index abbb3f7..9a1faf8 100644 --- a/docs/REAL_DEVICE_QA.md +++ b/docs/REAL_DEVICE_QA.md @@ -1,15 +1,16 @@ -# Real-device QA — beta gate +# Real-device QA checklist -**No physical-device QA was performed by this automated hardening pass.** -Status is ALPHA READY only after automated checks; BETA READY requires recorded -physical iOS + Android results, including the supported device matrix below. +**No physical-device or human screen-reader QA was performed for 1.0.0.** The +stable release evidence is automated and does not turn desktop engine coverage +into a physical mobile claim. Use this checklist to add reproducible manual +evidence or diagnose a device-specific report. | Device and browser | Status | | ------------------ | ----------------------------------------------------------------------- | -| iPhone Safari | Manual pending — beta blocking | -| Android Chrome | Manual pending — beta blocking | -| Firefox Android | Manual pending — beta blocking for supported mobile policy | -| iPad Safari | Manual pending — beta blocking | +| iPhone Safari | Not run | +| Android Chrome | Not run | +| Firefox Android | Not run | +| iPad Safari | Not run | | Desktop Chromium | Automated browser mechanics + trusted CDP touch | | Desktop Firefox | Automated browser mechanics + synthetic adapter events | | Desktop WebKit | Automated browser mechanics + synthetic adapter events; see runner note | @@ -35,7 +36,7 @@ large-list and 1000-idle-instance modes. Query options: The button is application-owned and intentionally does not command the PTR state machine. Use an actual drag to test PTR promise/hold/settling state. -## Required on each mobile device, separately for page and element +## Manual scenarios for each mobile device - Finger down at top: custom pull, threshold arms, release calls once. - Finger up at top: content scrolls down normally. @@ -76,16 +77,17 @@ verified or add early/global cancellation to make the checklist green. **Page-level custom Pull to Refresh on iOS Safari is not a guaranteed configuration. Safari may retain native browser Pull-to-Refresh ownership.** -## Automated runner note +## Automated runner history -The local macOS 14 environment has a frozen WebKit binary. Installed Playwright -1.63 fails before page creation (`Unknown setting: PushAPIEnabled`). An isolated -Playwright 1.58.2 runner can execute the same tests against the compatible local -WebKit. This does not change repository dependencies and is older-engine evidence. -Run the normal three-engine suite on a current supported CI OS before beta. +During the original hardening pass, the local macOS 14 WebKit binary was frozen: +Playwright 1.63 failed before page creation (`Unknown setting: +PushAPIEnabled`). An isolated Playwright 1.58.2 runner supplied older-engine +evidence without changing repository dependencies. The current GitHub Actions +suite runs Chromium, Firefox, and WebKit on Ubuntu; all remain desktop-engine +evidence rather than physical-device results. ## Result template -| Commit | Device / OS / browser | Date / tester | Surface / scenario | Expected | Actual | Evidence | Status | -| ------ | --------------------- | ------------- | ------------------ | -------- | ------ | -------- | -------------- | -| — | — | — | — | — | — | — | Manual pending | +| Commit | Device / OS / browser | Date / tester | Surface / scenario | Expected | Actual | Evidence | Status | +| ------ | --------------------- | ------------- | ------------------ | -------- | ------ | -------- | ------- | +| — | — | — | — | — | — | — | Not run | diff --git a/docs/adr/0001-touch-ownership.md b/docs/adr/0001-touch-ownership.md index 56afdfd..8dad58c 100644 --- a/docs/adr/0001-touch-ownership.md +++ b/docs/adr/0001-touch-ownership.md @@ -1,6 +1,6 @@ # ADR 0001: browser-aware input ownership -Status: implemented; mobile physical validation pending (2026-09-06). +Status: implemented; mobile physical validation not run for 1.0.0. The old `pan-x pan-up` rule allowed the wrong native direction. Pointer Events Level 3 explicitly uses `pan-x pan-down` for a top-boundary PTR example. The @@ -27,7 +27,8 @@ Compatibility data reports all four directional values (`pan-up`, `pan-down`, `pan-left`, `pan-right`) in Chromium since 55, but not Firefox or Safari/WebKit. `CSS.supports` accurately distinguishes the tested engines' accepted syntax; it cannot certify native mobile gesture behavior. Native iOS page refresh is -not guaranteed suppressible. The fallback must pass physical QA before beta. +not guaranteed suppressible. Physical QA is required before claiming verified +support for a specific mobile browser and device combination. Sources checked 2026-09-06: diff --git a/e2e/gesture.spec.ts b/e2e/gesture.spec.ts index b709467..a5e4b8d 100644 --- a/e2e/gesture.spec.ts +++ b/e2e/gesture.spec.ts @@ -19,12 +19,10 @@ test('page is accessible and has no horizontal overflow', async ({ page }) => { expect(sizes.scroll).toBe(sizes.client) }) -test('hero exposes prerelease status and live pull mechanics', async ({ - page, -}) => { +test('hero exposes stable status and live pull mechanics', async ({ page }) => { await page.goto('/') - await expect(page.getByText('0.1 alpha', { exact: true })).toBeVisible() + await expect(page.getByText('1.0 stable', { exact: true })).toBeVisible() const root = page.getByTestId('main-demo') const metrics = page.getByTestId('hero-metrics') await expect(metrics.getByText('72 px', { exact: true })).toBeVisible() @@ -74,7 +72,7 @@ test('documents ownership, native refresh, and truthful device status', async ({ await expect( browser.getByText('overscroll-behavior-y: contain;'), ).toBeVisible() - await expect(browser.getByText(/Manual pending/).first()).toBeVisible() + await expect(browser.getByText(/Not run/).first()).toBeVisible() }) test('renders real sibling primitive integration proofs', async ({ page }) => { diff --git a/package-lock.json b/package-lock.json index c367e4e..a216643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "@nipe-solutions/react-pull-to-refresh", - "version": "0.1.0-alpha.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nipe-solutions/react-pull-to-refresh", - "version": "0.1.0-alpha.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@axe-core/playwright": "^4.10.2", "@eslint/js": "^10.0.1", "@fontsource-variable/instrument-sans": "^5.3.0", "@fontsource/fraunces": "^5.3.0", - "@nipe-solutions/react-spring-bottom-sheet": "^5.0.1", - "@nipe-solutions/react-swipe-actions": "^0.1.0-alpha.2", + "@nipe-solutions/react-spring-bottom-sheet": "^5.0.2", + "@nipe-solutions/react-swipe-actions": "^1.0.0", "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.0", @@ -31,7 +31,8 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", - "vitest": "^5.0.0" + "vitest": "^5.0.0", + "yaml": "^2.9.1" }, "engines": { "node": ">=24 <25" @@ -593,14 +594,11 @@ "license": "MIT" }, "node_modules/@nipe-solutions/react-spring-bottom-sheet": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@nipe-solutions/react-spring-bottom-sheet/-/react-spring-bottom-sheet-5.0.1.tgz", - "integrity": "sha512-RbFHSHwynxp/CHEahh1BYO8DPdhMgPSOyHcamh6SOaJIWGyivYeTGRUbFiupRx0Yqv8NazWAeodcmSGrjCYMiQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@nipe-solutions/react-spring-bottom-sheet/-/react-spring-bottom-sheet-5.0.2.tgz", + "integrity": "sha512-UFKEmgz5NPo6EGfMzz+9cUD7P6LxIv1vVXBv6lw4asxdOGTBVNaW2VsXfb5CFi7mtkGTPWl1/wFvG7nWV85RJA==", "dev": true, "license": "MIT", - "dependencies": { - "motion": "^13.2.0" - }, "engines": { "node": ">=24 <25" }, @@ -610,9 +608,9 @@ } }, "node_modules/@nipe-solutions/react-swipe-actions": { - "version": "0.1.0-alpha.3", - "resolved": "https://registry.npmjs.org/@nipe-solutions/react-swipe-actions/-/react-swipe-actions-0.1.0-alpha.3.tgz", - "integrity": "sha512-dndcScyD7/aCcbBhsOOB1crNngeVsCOyUp38vqFbak3pnUX+RLknbsiKTaKHgs4BIVxLpvTaeLtsSnr+pIzWbQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nipe-solutions/react-swipe-actions/-/react-swipe-actions-1.0.0.tgz", + "integrity": "sha512-08hDlmflIqp2c0tgfJnYwnmIpZ9gIWJSH3hNjZgLdycOu2bMM/hNYkgN/u4l9EE998VnvAbeI40tOVPrNsMiSw==", "dev": true, "license": "MIT", "engines": { @@ -2004,30 +2002,6 @@ "dev": true, "license": "ISC" }, - "node_modules/framer-motion": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz", - "integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "motion-dom": "^13.2.0", - "motion-utils": "^13.0.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2629,46 +2603,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/motion": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-13.2.0.tgz", - "integrity": "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "framer-motion": "^13.2.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz", - "integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "motion-utils": "^13.0.0" - } - }, - "node_modules/motion-utils": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", - "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3279,13 +3213,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3677,6 +3604,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 3d0e5b7..cf87413 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nipe-solutions/react-pull-to-refresh", - "version": "0.1.0-alpha.1", + "version": "1.0.0", "description": "Accessible, native-feeling pull-to-refresh for React.", "license": "MIT", "repository": { @@ -14,7 +14,7 @@ "publishConfig": { "access": "public", "provenance": true, - "tag": "alpha" + "tag": "latest" }, "type": "module", "engines": { @@ -53,7 +53,9 @@ "test:api": "node scripts/check-public-api.mjs", "test:size": "node scripts/check-bundle-size.mjs", "test:package": "npm run build:dist && node --test test/package/verify-package.test.mjs", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm run build:dist && npm run test:api && npm run test:size && npm run test:package && npm run build:website", + "test:workflows": "node --test scripts/verify-workflows.test.mjs", + "release:check": "node scripts/verify-release.mjs", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm run build:dist && npm run test:api && npm run test:size && npm run test:package && npm run test:workflows && npm run build:website", "clean": "rm -rf dist coverage" }, "prettier": { @@ -69,8 +71,8 @@ "@eslint/js": "^10.0.1", "@fontsource-variable/instrument-sans": "^5.3.0", "@fontsource/fraunces": "^5.3.0", - "@nipe-solutions/react-spring-bottom-sheet": "^5.0.1", - "@nipe-solutions/react-swipe-actions": "^0.1.0-alpha.2", + "@nipe-solutions/react-spring-bottom-sheet": "^5.0.2", + "@nipe-solutions/react-swipe-actions": "^1.0.0", "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.0", @@ -87,6 +89,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.69.0", "vite": "^8.2.2", - "vitest": "^5.0.0" + "vitest": "^5.0.0", + "yaml": "^2.9.1" } } diff --git a/scripts/package-files.json b/scripts/package-files.json new file mode 100644 index 0000000..7c2d9e9 --- /dev/null +++ b/scripts/package-files.json @@ -0,0 +1,14 @@ +[ + "CHANGELOG.md", + "LICENSE", + "README.md", + "dist/core.css", + "dist/index.cjs", + "dist/index.cjs.map", + "dist/index.d.ts", + "dist/index.js", + "dist/index.js.map", + "dist/styles.css", + "dist/theme.css", + "package.json" +] diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..2beb503 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { createRequire } from 'node:module' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const packageName = '@nipe-solutions/react-pull-to-refresh' +const approvedPackedFiles = JSON.parse( + await readFile(path.join(import.meta.dirname, 'package-files.json'), 'utf8'), +) +const lanes = [ + { react: '18', reactDom: '18', reactTypes: '18', reactDomTypes: '18' }, + { react: '19', reactDom: '19', reactTypes: '19', reactDomTypes: '19' }, +] + +const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'react-ptr-package-')) + +try { + const packDirectory = path.join(temporaryRoot, 'pack') + await mkdir(packDirectory) + const { stdout } = await run('npm', [ + 'pack', + '--json', + '--pack-destination', + packDirectory, + ]) + const [pack] = JSON.parse(stdout) + assert.ok(pack, 'npm pack did not report an artifact') + validatePackedFiles(pack.files.map(({ path: file }) => file)) + + const tarballPath = path.join(packDirectory, pack.filename) + for (const lane of lanes) { + const consumer = path.join(temporaryRoot, `react-${lane.react}`) + await mkdir(consumer) + await writeFile( + path.join(consumer, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, + ) + + await run( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--no-package-lock', + '--save-exact', + '--install-strategy=hoisted', + tarballPath, + `react@${lane.react}`, + `react-dom@${lane.reactDom}`, + `@types/react@${lane.reactTypes}`, + `@types/react-dom@${lane.reactDomTypes}`, + ], + consumer, + ) + + const installedReact = JSON.parse( + await readFile( + path.join(consumer, 'node_modules/react/package.json'), + 'utf8', + ), + ) + assert.match( + installedReact.version, + new RegExp(`^${lane.react}\\.`), + `React ${lane.react} consumer installed the wrong major`, + ) + + await verifyModulesAndSsr(consumer) + await verifyTypes(consumer) + await verifyStyles(consumer) + + console.log( + `React ${installedReact.version}: ESM, CJS, types, CSS, SSR passed`, + ) + } + + console.log( + `Packed package verification passed (${pack.entryCount} files, ${pack.size} bytes)`, + ) +} finally { + await rm(temporaryRoot, { recursive: true, force: true }) +} + +function validatePackedFiles(files) { + const actual = [...files].sort() + const expected = [...approvedPackedFiles].sort() + const unexpected = actual.filter((file) => !expected.includes(file)) + const missing = expected.filter((file) => !actual.includes(file)) + const messages = [] + + if (unexpected.length > 0) { + messages.push(`Unexpected packed files: ${unexpected.join(', ')}`) + } + if (missing.length > 0) { + messages.push(`Missing packed files: ${missing.join(', ')}`) + } + + assert.deepEqual( + actual, + expected, + messages.join('\n') || 'Packed file inventory differs from its allowlist', + ) +} + +async function verifyModulesAndSsr(consumer) { + const esm = await run( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import React from 'react' + import { renderToString } from 'react-dom/server' + import { PullToRefresh } from '${packageName}' + + assertComponent(PullToRefresh.Root) + const html = renderToString( + React.createElement( + PullToRefresh.Root, + { onRefresh() {} }, + React.createElement(PullToRefresh.Content, null, 'SSR content'), + ), + ) + if (!html.includes('data-state="idle"') || !html.includes('SSR content')) { + throw new Error('ESM server render did not preserve the public contract') + } + + function assertComponent(component) { + if (!component) throw new Error('ESM export is missing PullToRefresh.Root') + } + `, + ], + consumer, + ) + assert.equal(esm.stderr, '') + + const cjs = await run( + process.execPath, + [ + '--input-type=commonjs', + '--eval', + ` + const { PullToRefresh } = require('${packageName}') + if (!PullToRefresh.Root) { + throw new Error('CommonJS export is missing PullToRefresh.Root') + } + `, + ], + consumer, + ) + assert.equal(cjs.stderr, '') +} + +async function verifyTypes(consumer) { + await writeFile( + path.join(consumer, 'index.ts'), + ` + import { + PullToRefresh, + type PullToRefreshRootProps, + } from '${packageName}' + + const props: PullToRefreshRootProps = { + children: null, + onRefresh: async () => {}, + } + void PullToRefresh.Root + void props + `, + ) + await writeFile( + path.join(consumer, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + }, + include: ['index.ts'], + }, + null, + 2, + )}\n`, + ) + + await run( + process.execPath, + [ + path.join(repositoryRoot, 'node_modules/typescript/bin/tsc'), + '--project', + 'tsconfig.json', + ], + consumer, + ) +} + +async function verifyStyles(consumer) { + const require = createRequire(path.join(consumer, 'package.json')) + for (const stylesheet of ['core.css', 'theme.css', 'styles.css']) { + const stylesheetPath = require.resolve(`${packageName}/${stylesheet}`) + assert.ok( + (await readFile(stylesheetPath, 'utf8')).trim().length > 0, + `${stylesheet} must contain CSS`, + ) + } +} + +function run(command, args, cwd = repositoryRoot) { + const environment = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name.toLowerCase() !== 'npm_config_allow_scripts', + ), + ) + Object.assign(environment, { + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', + }) + + return execFileAsync(command, args, { + cwd, + env: environment, + maxBuffer: 10 * 1024 * 1024, + }) +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..7e16753 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,347 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { createHash } from 'node:crypto' +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const expectedName = '@nipe-solutions/react-pull-to-refresh' +const expectedRepository = + 'git+https://github.com/NIPE-Solutions/react-pull-to-refresh.git' +const stableSemver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function validateReleaseMetadata(packageJson, changelog) { + assert.equal(packageJson.name, expectedName, 'Unexpected package name') + assert.deepEqual( + packageJson.repository, + { type: 'git', url: expectedRepository }, + 'Unexpected package repository', + ) + assert.match( + packageJson.version, + stableSemver, + `Package version ${packageJson.version} must be a stable semantic version`, + ) + assert.match( + changelog, + new RegExp( + `^## \\[${escapeRegExp(packageJson.version)}\\] - \\d{4}-\\d{2}-\\d{2}$`, + 'm', + ), + `CHANGELOG.md must contain a dated release entry for ${packageJson.version}`, + ) + assert.equal( + packageJson.publishConfig?.access, + 'public', + 'publishConfig must enforce public access', + ) + assert.equal( + packageJson.publishConfig?.provenance, + true, + 'publishConfig must enable provenance', + ) + assert.equal( + packageJson.publishConfig?.tag, + 'latest', + 'Stable releases must use the latest dist-tag', + ) + + return { + name: packageJson.name, + version: packageJson.version, + channel: 'latest', + } +} + +export function validateRepositoryContext({ + branch, + dirtyEntries, + dryRun, + githubActions, + eventName, + refName, + refType, + version, +}) { + const messages = [] + + if (githubActions) { + assert.equal( + dirtyEntries.length, + 0, + 'GitHub release tracked state must be clean', + ) + assert.equal( + eventName, + 'release', + 'GitHub verification requires a release event', + ) + assert.equal(refType, 'tag', 'GitHub release verification requires a tag') + assert.equal( + refName, + `v${version}`, + `Release tag ${refName} must exactly match v${version}`, + ) + return messages + } + + assert.equal(dryRun, true, 'Local release verification is dry-run only') + if (dirtyEntries.length > 0) { + messages.push( + `Local dry-run: allowing ${dirtyEntries.length} working-tree changes for inspection`, + ) + } + if (branch && branch !== 'main') { + messages.push(`Local dry-run: running from feature branch ${branch}`) + } + return messages +} + +export function assertRegistryVersionAbsent(publishedVersion, version) { + assert.notEqual( + publishedVersion, + version, + `${expectedName}@${version} already exists on the npm registry`, + ) +} + +export function validatePackedFiles(actualFiles, expectedFiles) { + const actual = [...actualFiles].sort() + const expected = [...expectedFiles].sort() + const unexpected = actual.filter((file) => !expected.includes(file)) + const missing = expected.filter((file) => !actual.includes(file)) + const messages = [] + + if (unexpected.length > 0) { + messages.push(`Unexpected packed files: ${unexpected.join(', ')}`) + } + if (missing.length > 0) { + messages.push(`Missing packed files: ${missing.join(', ')}`) + } + + assert.deepEqual( + actual, + expected, + messages.join('\n') || 'Packed file inventory differs from its allowlist', + ) +} + +export function parseArguments(args) { + const usage = 'Usage: verify-release.mjs --dry-run [--output directory]' + assert.equal(args[0], '--dry-run', usage) + if (args.length === 1) { + return { dryRun: true, outputDirectory: undefined } + } + assert.equal(args.length, 3, usage) + assert.equal(args[1], '--output', usage) + assert.ok(args[2], 'Release output directory must not be empty') + return { dryRun: true, outputDirectory: args[2] } +} + +export async function writeArtifactChecksum(tarballPath) { + const digest = createHash('sha512') + .update(await readFile(tarballPath)) + .digest('hex') + const checksumPath = `${tarballPath}.sha512` + await writeFile(checksumPath, `${digest} ${path.basename(tarballPath)}\n`, { + flag: 'wx', + }) + return { checksumPath, digest } +} + +export async function verifyRelease({ dryRun = false, outputDirectory } = {}) { + assert.equal(dryRun, true, 'Release verification requires --dry-run') + + const packageJson = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ) + const expectedFiles = JSON.parse( + await readFile( + path.join(import.meta.dirname, 'package-files.json'), + 'utf8', + ), + ) + const release = validateReleaseMetadata(packageJson, changelog) + assert.match( + packageJson.scripts?.check ?? '', + /npm run test:workflows/, + 'The quality gate must include workflow policy tests', + ) + assert.doesNotMatch( + packageJson.scripts?.check ?? '', + /release:check/, + 'The normal quality gate must not invoke release verification', + ) + + const [{ stdout: status }, { stdout: branch }] = await Promise.all([ + run('git', ['status', '--porcelain', '--untracked-files=no']), + run('git', ['branch', '--show-current']), + ]) + const dirtyEntries = status.trim() ? status.trimEnd().split('\n') : [] + const messages = validateRepositoryContext({ + branch: branch.trim(), + dirtyEntries, + dryRun, + githubActions: process.env.GITHUB_ACTIONS === 'true', + eventName: process.env.GITHUB_EVENT_NAME, + refName: process.env.GITHUB_REF_NAME, + refType: process.env.GITHUB_REF_TYPE, + version: release.version, + }) + for (const message of messages) console.log(message) + + console.log(`Release candidate: ${release.name}@${release.version}`) + console.log(`Release channel: ${release.channel}`) + await runVisible('npm', ['run', 'build:dist']) + await runVisible('npm', ['run', 'test:size']) + await runVisible('npm', ['run', 'test:package']) + + const packDirectory = outputDirectory + ? path.resolve(repositoryRoot, outputDirectory) + : await mkdtemp(path.join(tmpdir(), 'react-ptr-release-')) + if (outputDirectory) await mkdir(packDirectory) + + try { + const { stdout } = await run('npm', [ + 'pack', + '--json', + '--pack-destination', + packDirectory, + ]) + const [pack] = JSON.parse(stdout) + assert.ok(pack, 'npm pack did not report an artifact') + const expectedFilename = `${release.name + .replace(/^@/, '') + .replaceAll('/', '-')}-${release.version}.tgz` + assert.equal( + pack.filename, + expectedFilename, + 'npm pack returned an unexpected artifact filename', + ) + validatePackedFiles( + pack.files.map(({ path: file }) => file), + expectedFiles, + ) + console.log( + `Artifact inventory verified (${pack.entryCount} files, ${pack.size} bytes)`, + ) + + const tarballPath = path.join(packDirectory, pack.filename) + const checksum = await writeArtifactChecksum(tarballPath) + await runVisible('npm', [ + 'publish', + '--dry-run', + '--ignore-scripts', + '--provenance', + '--access', + 'public', + '--tag', + release.channel, + tarballPath, + ]) + + if (outputDirectory) { + await writeGitHubOutputs({ + tarball: pack.filename, + channel: release.channel, + }) + console.log(`Verified artifact preserved at ${tarballPath}`) + console.log(`SHA-512: ${checksum.digest}`) + } + } finally { + if (!outputDirectory) { + await rm(packDirectory, { recursive: true, force: true }) + } + } + + const publishedVersion = await readRegistryVersion( + release.name, + release.version, + ) + assertRegistryVersionAbsent(publishedVersion, release.version) + console.log(`Registry version is unpublished: ${release.version}`) + console.log('Release dry-run verification passed; nothing was published') +} + +async function writeGitHubOutputs(outputs) { + if (!process.env.GITHUB_OUTPUT) return + const lines = Object.entries(outputs) + .map(([name, value]) => `${name}=${value}`) + .join('\n') + await appendFile(process.env.GITHUB_OUTPUT, `${lines}\n`) +} + +async function readRegistryVersion(name, version) { + try { + const { stdout } = await run('npm', [ + 'view', + `${name}@${version}`, + 'version', + '--json', + ]) + const parsed = JSON.parse(stdout) + return typeof parsed === 'string' ? parsed : undefined + } catch (error) { + const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}` + if (/E404|404 Not Found/.test(output)) return undefined + throw error + } +} + +async function runVisible(command, args) { + const { stdout, stderr } = await run(command, args) + if (stdout) process.stdout.write(stdout) + if (stderr) process.stderr.write(stderr) +} + +function run(command, args) { + const environment = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name.toLowerCase() !== 'npm_config_allow_scripts', + ), + ) + Object.assign(environment, { + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', + }) + return execFileAsync(command, args, { + cwd: repositoryRoot, + env: environment, + maxBuffer: 10 * 1024 * 1024, + }) +} + +function escapeRegExp(value) { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url + +if (isMain) { + try { + await verifyRelease(parseArguments(process.argv.slice(2))) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} diff --git a/scripts/verify-workflows.test.mjs b/scripts/verify-workflows.test.mjs new file mode 100644 index 0000000..11c909b --- /dev/null +++ b/scripts/verify-workflows.test.mjs @@ -0,0 +1,445 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { parse } from 'yaml' +import { + assertRegistryVersionAbsent, + parseArguments, + validatePackedFiles, + validateReleaseMetadata, + validateRepositoryContext, + writeArtifactChecksum, +} from './verify-release.mjs' + +const repositoryRoot = path.resolve(import.meta.dirname, '..') +const stablePackage = { + name: '@nipe-solutions/react-pull-to-refresh', + version: '1.0.0', + repository: { + type: 'git', + url: 'git+https://github.com/NIPE-Solutions/react-pull-to-refresh.git', + }, + publishConfig: { access: 'public', provenance: true, tag: 'latest' }, +} +const stableChangelog = '## [1.0.0] - 2026-09-29\n' +const releaseTarball = 'nipe-solutions-react-pull-to-refresh-1.0.0.tgz' + +test('stable release metadata returns the latest channel', () => { + assert.deepEqual(validateReleaseMetadata(stablePackage, stableChangelog), { + name: stablePackage.name, + version: '1.0.0', + channel: 'latest', + }) +}) + +test('repository metadata satisfies the stable release policy', async () => { + const packageJson = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ) + + assert.deepEqual(validateReleaseMetadata(packageJson, changelog), { + name: stablePackage.name, + version: '1.0.0', + channel: 'latest', + }) +}) + +test('normal quality gate includes workflow policy without release access', async () => { + const packageJson = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) + + assert.match(packageJson.scripts.check, /npm run test:workflows/) + assert.doesNotMatch(packageJson.scripts.check, /release:check/) +}) + +test('stable metadata rejects invalid versions and incomplete release records', () => { + for (const version of ['1.0.0-alpha.1', '1.0.0+build.1', '01.0.0', '1.0']) { + assert.throws( + () => + validateReleaseMetadata( + { ...stablePackage, version }, + `## [${version}] - 2026-09-29\n`, + ), + /stable semantic version/, + ) + } + + const invalidCases = [ + [{ ...stablePackage, name: '@example/wrong' }, stableChangelog], + [ + { + ...stablePackage, + repository: { type: 'git', url: 'https://example.com/wrong' }, + }, + stableChangelog, + ], + [ + { + ...stablePackage, + publishConfig: { ...stablePackage.publishConfig, access: 'restricted' }, + }, + stableChangelog, + ], + [ + { + ...stablePackage, + publishConfig: { ...stablePackage.publishConfig, provenance: false }, + }, + stableChangelog, + ], + [ + { + ...stablePackage, + publishConfig: { ...stablePackage.publishConfig, tag: 'alpha' }, + }, + stableChangelog, + ], + [stablePackage, '## [Unreleased]\n'], + ] + + for (const [packageJson, changelog] of invalidCases) { + assert.throws(() => validateReleaseMetadata(packageJson, changelog)) + } +}) + +test('repository context requires an exact GitHub release tag', () => { + const validContext = { + branch: '', + dirtyEntries: [], + dryRun: true, + githubActions: true, + eventName: 'release', + refName: 'v1.0.0', + refType: 'tag', + version: '1.0.0', + } + + assert.deepEqual(validateRepositoryContext(validContext), []) + assert.throws( + () => validateRepositoryContext({ ...validContext, refName: '1.0.0' }), + /must exactly match/, + ) + assert.throws( + () => + validateRepositoryContext({ ...validContext, refName: 'v1.0.0-alpha.1' }), + /must exactly match/, + ) + assert.throws( + () => + validateRepositoryContext({ + ...validContext, + dirtyEntries: [' M package.json'], + }), + /must be clean/, + ) +}) + +test('local release inspection is dry-run only', () => { + const messages = validateRepositoryContext({ + branch: 'release/stable-1.0.0', + dirtyEntries: [' M package.json'], + dryRun: true, + githubActions: false, + version: '1.0.0', + }) + assert.equal(messages.length, 2) + assert.throws( + () => + validateRepositoryContext({ + branch: 'main', + dirtyEntries: [], + dryRun: false, + githubActions: false, + version: '1.0.0', + }), + /dry-run only/, + ) +}) + +test('registry and packed-file guards reject duplicate or altered artifacts', () => { + assert.doesNotThrow(() => assertRegistryVersionAbsent(undefined, '1.0.0')) + assert.throws( + () => assertRegistryVersionAbsent('1.0.0', '1.0.0'), + /already exists/, + ) + + assert.doesNotThrow(() => + validatePackedFiles( + ['package.json', 'dist/index.js'], + ['dist/index.js', 'package.json'], + ), + ) + assert.throws( + () => + validatePackedFiles( + ['package.json', 'dist/index.js', 'src/internal.ts'], + ['package.json', 'dist/index.js'], + ), + /Unexpected packed files: src\/internal\.ts/, + ) + assert.throws( + () => + validatePackedFiles(['package.json'], ['dist/index.js', 'package.json']), + /Missing packed files: dist\/index\.js/, + ) +}) + +test('release arguments and checksum output are deterministic', async () => { + assert.deepEqual(parseArguments(['--dry-run']), { + dryRun: true, + outputDirectory: undefined, + }) + assert.deepEqual(parseArguments(['--dry-run', '--output', 'artifact']), { + dryRun: true, + outputDirectory: 'artifact', + }) + assert.throws(() => parseArguments([]), /Usage:/) + assert.throws(() => parseArguments(['--publish']), /Usage:/) + + const directory = await mkdtemp(path.join(tmpdir(), 'ptr-checksum-')) + const tarball = path.join(directory, releaseTarball) + try { + await writeFile(tarball, 'verified artifact\n') + const { checksumPath, digest } = await writeArtifactChecksum(tarball) + const manifest = await readFile(checksumPath, 'utf8') + assert.match(digest, /^[a-f0-9]{128}$/) + assert.equal(manifest, `${digest} ${releaseTarball}\n`) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('quality and browser workflows enforce the supported CI matrix', async () => { + validateQualityWorkflow(await readWorkflow('.github/workflows/quality.yml')) + validateBrowserWorkflow(await readWorkflow('.github/workflows/browsers.yml')) +}) + +test('release workflow separates verification from protected publication', async () => { + validateReleaseWorkflow(await readWorkflow('.github/workflows/release.yml')) +}) + +test('release workflow validator rejects weakened trust boundaries', async () => { + const workflow = await readWorkflow('.github/workflows/release.yml') + const mutations = [ + (copy) => (copy.on.release.types = ['created']), + (copy) => delete copy.jobs.verify.if, + (copy) => (copy.permissions = { 'id-token': 'write' }), + (copy) => (copy.concurrency.group = 'release-${{ github.ref }}'), + (copy) => (copy.jobs.publish.environment = 'unprotected'), + (copy) => (copy.jobs.publish.permissions = { contents: 'write' }), + (copy) => copy.jobs.publish.steps.unshift({ uses: 'actions/checkout@v7' }), + (copy) => { + const upload = copy.jobs.verify.steps.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ) + upload.with.name = 'different-artifact' + }, + (copy) => { + const finalStep = copy.jobs.publish.steps.at(-1) + finalStep.run = finalStep.run.replaceAll('latest', 'alpha') + }, + (copy) => { + const finalStep = copy.jobs.publish.steps.at(-1) + finalStep.run = finalStep.run.replace( + releaseTarball, + 'nipe-solutions-react-pull-to-refresh-1.0.1.tgz', + ) + }, + ] + + for (const mutate of mutations) { + const copy = structuredClone(workflow) + mutate(copy) + assert.throws(() => validateReleaseWorkflow(copy)) + } +}) + +async function readWorkflow(relativePath) { + return parse(await readFile(path.join(repositoryRoot, relativePath), 'utf8')) +} + +function stepsFor(job) { + assert.ok(Array.isArray(job?.steps), 'job must define executable steps') + return job.steps +} + +function runCommands(job) { + return stepsFor(job) + .map((step) => step.run) + .filter((command) => typeof command === 'string') +} + +function assertPinnedActions(workflow) { + for (const job of Object.values(workflow.jobs)) { + for (const step of stepsFor(job)) { + if (step.uses) { + assert.match( + step.uses, + /^[\w.-]+\/[\w.-]+@v\d+$/, + `${step.uses} must use an explicit major action version`, + ) + } + } + } +} + +function assertCommonWorkflowPolicy(workflow) { + assert.deepEqual(workflow.permissions, { contents: 'read' }) + assertPinnedActions(workflow) + assert.equal(workflow.concurrency['cancel-in-progress'], true) +} + +function validateQualityWorkflow(workflow) { + assertCommonWorkflowPolicy(workflow) + assert.equal(workflow.concurrency.group, 'quality-${{ github.ref }}') + assert.deepEqual(Object.keys(workflow.jobs), ['check']) + const job = workflow.jobs.check + assert.equal(job['runs-on'], 'ubuntu-24.04') + assert.ok(job['timeout-minutes'] <= 30) + const setupNode = stepsFor(job).find((step) => + step.uses?.startsWith('actions/setup-node@'), + ) + assert.equal(String(setupNode?.with?.['node-version']), '24') + assert.equal(setupNode?.with?.cache, 'npm') + assert.deepEqual(runCommands(job), ['npm ci', 'npm run check']) +} + +function validateBrowserWorkflow(workflow) { + assertCommonWorkflowPolicy(workflow) + assert.equal(workflow.concurrency.group, 'browsers-${{ github.ref }}') + assert.deepEqual(Object.keys(workflow.jobs).sort(), [ + 'chromium', + 'firefox', + 'webkit', + ]) + + for (const browser of ['chromium', 'firefox', 'webkit']) { + const job = workflow.jobs[browser] + assert.equal(job['runs-on'], 'ubuntu-24.04') + assert.ok(job['timeout-minutes'] <= 30) + const commands = runCommands(job) + assert.ok(commands.includes('npm ci')) + assert.ok( + commands.includes(`npx playwright install --with-deps ${browser}`), + ) + assert.ok( + commands.includes( + `npm run test:e2e -- --project=${browser} --reporter=line,html`, + ), + ) + const artifact = stepsFor(job).find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ) + assert.equal(artifact?.if, 'failure()') + assert.equal(artifact?.with?.name, `playwright-${browser}`) + assert.match(artifact?.with?.path ?? '', /playwright-report/) + assert.match(artifact?.with?.path ?? '', /test-results/) + assert.equal(artifact?.with?.['if-no-files-found'], 'ignore') + assert.ok(artifact?.with?.['retention-days'] <= 7) + } +} + +function validateReleaseWorkflow(workflow) { + assert.deepEqual(workflow.permissions, { contents: 'read' }) + assertPinnedActions(workflow) + assert.deepEqual(workflow.on, { release: { types: ['published'] } }) + assert.equal( + workflow.concurrency.group, + 'npm-nipe-solutions-react-pull-to-refresh-stable', + ) + assert.equal(workflow.concurrency['cancel-in-progress'], false) + + const verify = workflow.jobs.verify + const publish = workflow.jobs.publish + assert.ok(verify) + assert.ok(publish) + assert.equal(verify.if, 'github.event.release.prerelease == false') + assert.equal(verify['runs-on'], 'ubuntu-24.04') + assert.ok(verify['timeout-minutes'] <= 60) + assert.deepEqual(verify.outputs, { + tarball: '${{ steps.release.outputs.tarball }}', + channel: '${{ steps.release.outputs.channel }}', + }) + + const verifyCommands = runCommands(verify) + assert.ok(verifyCommands.includes('npm ci')) + assert.ok(verifyCommands.includes('npm run check')) + assert.ok( + verifyCommands.includes( + 'npx playwright install --with-deps chromium firefox webkit', + ), + ) + assert.ok(verifyCommands.includes('npm run test:e2e')) + assert.ok( + verifyCommands.includes( + 'npm run release:check -- --dry-run --output release-artifact', + ), + ) + const releaseStep = stepsFor(verify).find((step) => step.id === 'release') + const upload = stepsFor(verify).find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ) + assert.ok(releaseStep) + assert.equal(upload?.with?.name, 'npm-package-stable') + assert.equal(upload?.with?.path, 'release-artifact') + assert.equal(upload?.with?.['if-no-files-found'], 'error') + assert.ok(upload?.with?.['retention-days'] <= 3) + assert.ok( + stepsFor(verify).indexOf(upload) > stepsFor(verify).indexOf(releaseStep), + ) + + assert.equal(publish.needs, 'verify') + assert.equal(publish.environment, 'npm') + assert.equal(publish['runs-on'], 'ubuntu-24.04') + assert.ok(publish['timeout-minutes'] <= 10) + assert.deepEqual(publish.permissions, { 'id-token': 'write' }) + const publishSteps = stepsFor(publish) + assert.equal( + publishSteps.some((step) => step.uses?.startsWith('actions/checkout@')), + false, + ) + assert.equal( + runCommands(publish).some((command) => /npm (ci|install)/.test(command)), + false, + ) + + const setupNode = publishSteps.find((step) => + step.uses?.startsWith('actions/setup-node@'), + ) + assert.equal(String(setupNode?.with?.['node-version']), '24') + assert.equal(setupNode?.with?.['registry-url'], 'https://registry.npmjs.org') + assert.equal(setupNode?.with?.cache, undefined) + const download = publishSteps.find((step) => + step.uses?.startsWith('actions/download-artifact@'), + ) + assert.equal(download?.with?.name, 'npm-package-stable') + assert.equal(download?.with?.path, 'release-artifact') + + const finalStep = publishSteps.at(-1) + assert.equal(finalStep.name, 'Validate and publish verified artifact') + assert.equal( + finalStep.env.RELEASE_TARBALL, + '${{ needs.verify.outputs.tarball }}', + ) + assert.equal( + finalStep.env.RELEASE_CHANNEL, + '${{ needs.verify.outputs.channel }}', + ) + assert.match(finalStep.run, /RELEASE_CHANNEL" != "latest"/) + assert.match(finalStep.run, new RegExp(releaseTarball.replaceAll('.', '\\.'))) + assert.match(finalStep.run, /sha512sum --check --strict/) + assert.match(finalStep.run, /manifests=\(\*\.sha512\)/) + assert.match(finalStep.run, /\$\{#manifests\[@\]\} != 1/) + assert.equal((finalStep.run.match(/npm publish/g) ?? []).length, 1) + assert.match( + finalStep.run, + /npm publish --ignore-scripts --provenance --access public --tag "\$RELEASE_CHANNEL" -- "\$RELEASE_TARBALL"/, + ) +} diff --git a/test/package/verify-package.test.mjs b/test/package/verify-package.test.mjs index cb124c7..66fadfc 100644 --- a/test/package/verify-package.test.mjs +++ b/test/package/verify-package.test.mjs @@ -1,83 +1,83 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import path from 'node:path' +import process from 'node:process' import { promisify } from 'node:util' import test from 'node:test' -import { createRequire } from 'node:module' const execFileAsync = promisify(execFile) +const repositoryRoot = path.resolve(import.meta.dirname, '../..') + +test('package metadata exposes the stable release identity', async () => { + const packageJson = JSON.parse( + await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ) + + assert.equal(packageJson.version, '1.0.0') + assert.equal(packageJson.publishConfig.tag, 'latest') + assert.match(changelog, /^## \[1\.0\.0\] - 2026-09-29$/m) +}) + +test('packed file allowlist rejects an unexpected dist file', async () => { + const unexpectedFile = path.join(repositoryRoot, 'dist/unexpected.tmp') -test('packed package exposes import, require, types, CSS, and SSR', async () => { - const workspace = await mkdtemp(join(tmpdir(), 'react-ptr-package-')) try { - const { stdout } = await execFileAsync( - 'npm', - ['pack', '--json', '--pack-destination', workspace], - { cwd: new URL('../..', import.meta.url) }, + await writeFile(unexpectedFile, 'must not ship\n') + await assert.rejects( + execFileAsync(process.execPath, ['scripts/verify-package.mjs'], { + cwd: repositoryRoot, + maxBuffer: 10 * 1024 * 1024, + }), + (error) => { + assert.match( + error.stderr, + /Unexpected packed files: dist\/unexpected\.tmp/, + ) + return true + }, ) - const [{ filename, files }] = JSON.parse(stdout) - const names = files.map((file) => file.path) - for (const required of [ - 'dist/index.js', - 'dist/index.cjs', - 'dist/index.d.ts', - 'dist/core.css', - 'dist/theme.css', - 'package.json', - ]) { - assert.ok(names.includes(required), 'tarball is missing ' + required) - } + } finally { + await rm(unexpectedFile, { force: true }) + } +}) - await execFileAsync('npm', ['init', '--yes'], { cwd: workspace }) - await execFileAsync( - 'npm', - [ - 'install', - '--ignore-scripts', - join(workspace, filename), - 'react@19', - 'react-dom@19', - ], - { cwd: workspace }, - ) - const packageDir = join( - workspace, - 'node_modules', - '@nipe-solutions', - 'react-pull-to-refresh', - ) - const imported = await import(join(packageDir, 'dist/index.js')) - const require = createRequire(import.meta.url) - const required = require(join(packageDir, 'dist/index.cjs')) - assert.ok(imported.PullToRefresh.Root) - assert.ok(required.PullToRefresh.Root) +test('packed package passes isolated React 18 and React 19 consumers', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'react-ptr-policy-')) + const userConfig = path.join(directory, 'npmrc') - const declarations = await readFile( - join(packageDir, 'dist/index.d.ts'), - 'utf8', + try { + await writeFile(userConfig, 'allow-scripts=@example/unrelated-tool\n') + const { stdout, stderr } = await execFileAsync( + process.execPath, + ['scripts/verify-package.mjs'], + { + cwd: repositoryRoot, + env: { + ...process.env, + NPM_CONFIG_USERCONFIG: userConfig, + npm_config_allow_scripts: '@example/unrelated-tool', + }, + maxBuffer: 10 * 1024 * 1024, + }, ) - assert.match(declarations, /PullToRefreshRootProps/) - const React = await import(join(workspace, 'node_modules/react/index.js')) - const { renderToString } = await import( - join(workspace, 'node_modules/react-dom/server.node.js') + assert.equal(stderr, '') + assert.match( + stdout, + /React 18(?:\.\d+){2}: ESM, CJS, types, CSS, SSR passed/, ) - const html = renderToString( - React.createElement( - imported.PullToRefresh.Root, - { onRefresh() {} }, - React.createElement( - imported.PullToRefresh.Content, - null, - 'SSR content', - ), - ), + assert.match( + stdout, + /React 19(?:\.\d+){2}: ESM, CJS, types, CSS, SSR passed/, ) - assert.match(html, /data-state="idle"/) - assert.match(html, /SSR content/) + assert.match(stdout, /Packed package verification passed/) } finally { - await rm(workspace, { recursive: true, force: true }) + await rm(directory, { recursive: true, force: true }) } }) diff --git a/website/public/og-image.svg b/website/public/og-image.svg index 67f2d32..ebbbf86 100644 --- a/website/public/og-image.svg +++ b/website/public/og-image.svg @@ -10,7 +10,7 @@ - 0.1 ALPHA + 1.0 STABLE Pull to refresh without owning your feed. diff --git a/website/src/main.tsx b/website/src/main.tsx index a2bfa3a..8a15804 100644 --- a/website/src/main.tsx +++ b/website/src/main.tsx @@ -139,19 +139,14 @@ function BrowserBehavior() { 'Mobile not implied', 'Desktop n/a', ], - [ - 'Android Chrome', - 'Engine coverage', - 'Manual pending', - 'Test page refresh', - ], + ['Android Chrome', 'Engine coverage', 'Not run', 'Not run'], ['Firefox desktop', 'Automated', 'n/a', 'Desktop n/a'], - ['Firefox Android', 'Engine coverage', 'Manual pending', 'Test'], + ['Firefox Android', 'Engine coverage', 'Not run', 'Not run'], ['WebKit desktop', 'Automated', 'n/a', 'Desktop n/a'], [ 'iOS / iPadOS Safari', 'WebKit evidence only', - 'Manual pending', + 'Not run', 'Native conflict', ], ].map(([environment, logic, touch, conflict]) => ( @@ -169,8 +164,8 @@ function BrowserBehavior() {

iOS Safari. Page-level custom Pull to Refresh on iOS Safari is not a guaranteed configuration. Safari may retain native - browser Pull-to-Refresh ownership. Physical iOS and Android QA is - required before beta. + browser Pull-to-Refresh ownership. Physical iOS and Android QA was not + run for 1.0.0.

Android Chrome. Browser-native refresh commonly owns @@ -220,8 +215,8 @@ function IntegrationProofs() {

- Automated desktop evidence only. Combined physical-device testing - remains pending for this alpha. + Automated desktop evidence only. Combined physical-device behavior + was not tested for 1.0.0.

@@ -273,9 +268,9 @@ function App() {
- 0.1 alpha + 1.0 stable - Real-device QA pending + Automated engine coverage

Pull to refresh without owning your feed.

@@ -351,6 +346,8 @@ function App() {

Use the same application-owned function from a visible button. Core injects no hidden control, live region, or focus behavior. + Automated keyboard and axe checks passed; a human screen-reader + session was not run for 1.0.0.