diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..79ac59c1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Release automation and integration contracts require maintainer review. +/.github/workflows/ @GoodDollar/goodbuilders-maintainers +/scripts/release/ @GoodDollar/goodbuilders-maintainers +/schemas/ @GoodDollar/goodbuilders-maintainers +/packages/*/integration-manifest.json @GoodDollar/goodbuilders-maintainers diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 23111ada..78e1767b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,17 +32,17 @@ jobs: steps: # 1. Check out the repository before running any package-manager commands. - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 # 2. Install the declared pnpm version so pnpm is available on PATH. - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9.15.0 # 3. Install the repository's Node.js version and enable pnpm dependency caching. - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.x cache: pnpm diff --git a/.github/workflows/deploy-ai-credits-web.yml b/.github/workflows/deploy-ai-credits-web.yml index 433ffd90..7a92f6c1 100644 --- a/.github/workflows/deploy-ai-credits-web.yml +++ b/.github/workflows/deploy-ai-credits-web.yml @@ -14,6 +14,9 @@ on: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' +permissions: + contents: read + concurrency: group: deploy-ai-credits-web-${{ github.ref }} cancel-in-progress: true @@ -30,15 +33,15 @@ jobs: url: ${{ steps.deploy.outputs.url }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9.15.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml index 9179233e..e1a54e8b 100644 --- a/.github/workflows/deploy-storybook.yml +++ b/.github/workflows/deploy-storybook.yml @@ -11,6 +11,9 @@ on: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' +permissions: + contents: read + concurrency: group: deploy-storybook-${{ github.ref }} cancel-in-progress: true @@ -30,15 +33,15 @@ jobs: working-directory: examples/storybook steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 9.15.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/promote-next.yml b/.github/workflows/promote-next.yml new file mode 100644 index 00000000..fcb3b060 --- /dev/null +++ b/.github/workflows/promote-next.yml @@ -0,0 +1,59 @@ +name: Promote main to next + +# main remains the development/deployment branch. A reviewed manual promotion is the only +# supported path to next, and only next is wired to npm publication. +on: + workflow_dispatch: + inputs: + source: + description: Source branch (intentionally restricted) + required: true + type: choice + options: [main] + +permissions: + contents: read + +concurrency: + group: goodwidget-next-promotion + cancel-in-progress: false + +jobs: + promote: + permissions: + contents: write + # The release environment supplies the maintainer approval gate before next can move. + environment: release + runs-on: ubuntu-latest + steps: + - name: Require a maintainer and main as source + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + SOURCE: ${{ inputs.source }} + REPOSITORY: ${{ github.repository }} + run: | + test "$SOURCE" = main + permission="$(gh api "repos/$REPOSITORY/collaborators/$ACTOR/permission" --jq .permission)" + test "$permission" = admin -o "$permission" = maintain + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + ref: main + - name: Create or update next + env: + GIT_AUTHOR_NAME: goodwidget-release + GIT_AUTHOR_EMAIL: goodwidget-release@users.noreply.github.com + GIT_COMMITTER_NAME: goodwidget-release + GIT_COMMITTER_EMAIL: goodwidget-release@users.noreply.github.com + run: | + git fetch origin main next || git fetch origin main + if git show-ref --verify --quiet refs/remotes/origin/next; then + # A normal merge preserves next's release history and exits before push on conflicts. + git switch --create next --track origin/next + git merge --no-edit origin/main + else + # The first promotion creates the long-lived publication branch from main. + git switch --create next origin/main + fi + git push origin next diff --git a/.github/workflows/recover-release.yml b/.github/workflows/recover-release.yml new file mode 100644 index 00000000..a12583ac --- /dev/null +++ b/.github/workflows/recover-release.yml @@ -0,0 +1,57 @@ +name: Recover committed npm release + +# Recovery never computes a new version. It republishes (or only verifies) the version +# already committed on next after an interrupted registry publication. +on: + workflow_dispatch: + inputs: + package: + description: Already-versioned production package to publish or verify + required: true + type: choice + options: + - '@goodwidget/ui' + - '@goodwidget/core' + - '@goodwidget/embed' + - '@goodwidget/ai-credits-widget' + - '@goodwidget/citizen-claim-widget' + - '@goodwidget/goodreserve-widget' + - '@goodwidget/staking-migration-widget' + - '@goodwidget/streaming-widget' + +permissions: + contents: read + +concurrency: + group: goodwidget-npm-release + cancel-in-progress: false + +jobs: + recover: + permissions: + contents: read + id-token: write + environment: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + ref: next + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22.14.0 + registry-url: https://registry.npmjs.org + - name: Configure pinned toolchain + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + - run: pnpm install --frozen-lockfile + - name: Publish the authoritative committed version + env: + RECOVERY_PACKAGE: ${{ inputs.package }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' + GOODWALLET_DISPATCH_PAT: ${{ secrets.GOODWALLET_DISPATCH_PAT }} + ENABLE_GOODWALLET_DISPATCH: ${{ vars.ENABLE_GOODWALLET_DISPATCH }} + run: pnpm release:recover diff --git a/.github/workflows/release-next.yml b/.github/workflows/release-next.yml new file mode 100644 index 00000000..78626737 --- /dev/null +++ b/.github/workflows/release-next.yml @@ -0,0 +1,56 @@ +name: Release production packages + +# Publication is intentionally branch-gated: pushes to main may deploy demos, but cannot +# enter this workflow. A maintainer must first promote main to next. +on: + push: + branches: [next] + +permissions: + contents: read + +concurrency: + group: goodwidget-npm-release + cancel-in-progress: false + +jobs: + release: + # The release script pushes its authoritative version commit back to next. The marker + # prevents that bot-authored push from recursively starting another release. + if: ${{ !contains(github.event.head_commit.message, '[goodwidget release]') }} + permissions: + contents: write + id-token: write + environment: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + ref: next + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22.14.0 + registry-url: https://registry.npmjs.org + - name: Configure pinned toolchain + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + node --version + pnpm --version + - name: Install immutable workspace + run: pnpm install --frozen-lockfile + - name: Configure release identity + run: | + git config user.name goodwidget-release + git config user.email goodwidget-release@users.noreply.github.com + - name: Build, pack, publish, verify, and dispatch + env: + RELEASE_BASE_SHA: ${{ github.event.before }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' + GOODWALLET_DISPATCH_PAT: ${{ secrets.GOODWALLET_DISPATCH_PAT }} + # Wallet automation remains inert until both this explicit switch and its scoped + # repository-dispatch credential are configured in the protected environment. + ENABLE_GOODWALLET_DISPATCH: ${{ vars.ENABLE_GOODWALLET_DISPATCH }} + run: pnpm release:widgets diff --git a/.github/workflows/validate-release.yml b/.github/workflows/validate-release.yml new file mode 100644 index 00000000..ba788789 --- /dev/null +++ b/.github/workflows/validate-release.yml @@ -0,0 +1,35 @@ +name: Validate release contracts + +# Keep graph detection, packaging, and the wallet-facing manifest contract reviewable +# before either promotion or release credentials can be reached. +on: + pull_request: + paths: + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'packages/**' + - 'schemas/**' + - 'scripts/release/**' + - '.github/workflows/**' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22.14.0 + - name: Configure pinned toolchain + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + - run: pnpm install --frozen-lockfile + - run: pnpm test:release + - run: pnpm lint + - run: pnpm build + - run: pnpm validate:register-entries diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..444079bd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GoodDollar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 63685c81..52814317 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -129,21 +129,30 @@ export const MyWidgetElement = createMiniAppElement( ```ts // src/register.ts -import { MyWidgetElement } from './element' - const TAG = 'my-widget' -export function register(tagName: string = TAG): string { +export async function register(tagName: string = TAG): Promise { + // SSR-aware bundlers may evaluate this entry without browser globals. Keep + // the browser-focused element graph behind the guard as well as DOM access. + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { MyWidgetElement } = await import('./element') customElements.define(tagName, MyWidgetElement) } return tagName } // Auto-register on import -register() +void register() ``` +`createMiniAppElement()` and `register.ts` must both be safe to evaluate in +Node. The dynamic element import is deliberately reached only in a browser; +registration becomes a no-op on the server and runs normally when the browser +bundle evaluates. This lets Next.js inspect the package without loading +browser-focused dependencies or attempting to mount a Custom Element during +server rendering. + ```ts // src/index.ts export { MyWidget } from './MyWidget' diff --git a/package.json b/package.json index 6876aea3..9cc34d40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "goodwidget", "private": true, + "license": "MIT", "scripts": { "build": "turbo run build", "dev": "turbo run dev", @@ -16,7 +17,12 @@ "vercel:deploy:production": "pnpm vercel:build:production && vercel deploy --prebuilt --prod", "test:storybook": "pnpm --filter @goodwidget/storybook test:storybook", "test:demo": "playwright test", - "test:ai-credits-web": "playwright test --config apps/ai-credits-web/playwright.config.ts" + "test:ai-credits-web": "playwright test --config apps/ai-credits-web/playwright.config.ts", + "test:release": "node --test scripts/release/*.test.mjs && node scripts/release/validate-packages.mjs && node scripts/release/validate-manifests.mjs", + "release:widgets": "node scripts/release/release.mjs", + "release:recover": "node scripts/release/release.mjs recover", + "validate:manifests": "node scripts/release/validate-manifests.mjs", + "validate:register-entries": "node scripts/release/validate-register-entries.mjs" }, "devDependencies": { "@playwright/test": "^1.44.0", @@ -26,6 +32,7 @@ "prettier": "^3.5.0", "@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/parser": "^8.25.0", + "ajv": "8.17.1", "vercel": "^54.14.2" }, "packageManager": "pnpm@9.15.0" diff --git a/packages/ai-credits-widget/integration-manifest.json b/packages/ai-credits-widget/integration-manifest.json new file mode 100644 index 00000000..ee05b53e --- /dev/null +++ b/packages/ai-credits-widget/integration-manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "2.0.0", + "hostContractVersion": "1.0.0", + "widgetId": "goodwidget.ai-credits", + "packageName": "@goodwidget/ai-credits-widget", + "entries": { + "react": { + "export": "AiCreditsWidget" + }, + "webComponent": { + "registerPath": "./register", + "tagName": "ai-credits-widget" + } + } +} diff --git a/packages/ai-credits-widget/package.json b/packages/ai-credits-widget/package.json index 1ba6b91b..5418f26e 100644 --- a/packages/ai-credits-widget/package.json +++ b/packages/ai-credits-widget/package.json @@ -2,6 +2,23 @@ "name": "@goodwidget/ai-credits-widget", "version": "0.1.0", "description": "GoodWidget for buying AI coding credits with G$ on Celo", + "license": "MIT", + "files": [ + "dist", + "integration-manifest.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/ai-credits-widget" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "./dist/register.js", + "./dist/register.cjs" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +38,8 @@ "types": "./dist/register.d.ts", "import": "./dist/register.js", "require": "./dist/register.cjs" - } + }, + "./integration-manifest": "./integration-manifest.json" }, "scripts": { "build": "tsup", @@ -35,9 +53,9 @@ }, "dependencies": { "@goodsdks/citizen-sdk": "1.2.7", - "@goodwidget/core": "workspace:*", - "@goodwidget/embed": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/embed": "workspace:^", + "@goodwidget/ui": "workspace:^", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/ai-credits-widget/src/register.ts b/packages/ai-credits-widget/src/register.ts index e4743b5c..ce65799c 100644 --- a/packages/ai-credits-widget/src/register.ts +++ b/packages/ai-credits-widget/src/register.ts @@ -1,5 +1,3 @@ -import { AiCreditsWidgetElement } from './element' - const DEFAULT_TAG_NAME = 'ai-credits-widget' /** @@ -11,18 +9,22 @@ const DEFAULT_TAG_NAME = 'ai-credits-widget' * Then use in HTML: * * - * Returns the tag name so you can use it programmatically: - * const tag = register() // 'ai-credits-widget' + * Resolves to the tag name so you can use it programmatically: + * const tag = await register() // 'ai-credits-widget' * * Or register under a custom tag: - * const tag = register('my-ai-credits-widget') + * const tag = await register('my-ai-credits-widget') */ -export function register(tagName: string = DEFAULT_TAG_NAME): string { +export async function register(tagName: string = DEFAULT_TAG_NAME): Promise { + // Keep the element and its browser-focused React/Tamagui dependency graph + // out of server evaluation. A static import here would execute that graph + // before this DOM guard could protect an SSR or package-inspection process. if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { AiCreditsWidgetElement } = await import('./element') customElements.define(tagName, AiCreditsWidgetElement) } return tagName } -register() +void register() diff --git a/packages/citizen-claim-widget/integration-manifest.json b/packages/citizen-claim-widget/integration-manifest.json new file mode 100644 index 00000000..ad5b91a4 --- /dev/null +++ b/packages/citizen-claim-widget/integration-manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "2.0.0", + "hostContractVersion": "1.0.0", + "widgetId": "goodwidget.citizen-claim", + "packageName": "@goodwidget/citizen-claim-widget", + "entries": { + "react": { + "export": "CitizenClaimWidget" + }, + "webComponent": { + "registerPath": "./register", + "tagName": "gw-citizen-claim" + } + } +} diff --git a/packages/citizen-claim-widget/package.json b/packages/citizen-claim-widget/package.json index 3aafce86..eec3bbbc 100644 --- a/packages/citizen-claim-widget/package.json +++ b/packages/citizen-claim-widget/package.json @@ -1,7 +1,25 @@ { "name": "@goodwidget/citizen-claim-widget", - "version": "0.2.0-beta", + "version": "0.2.0", "description": "GoodWidget migration target for the GoodSDKs citizen claim flow", + "license": "MIT", + "files": [ + "dist", + "integration-manifest.json", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/citizen-claim-widget" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "./dist/register.js", + "./dist/register.cjs" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +39,8 @@ "types": "./dist/register.d.ts", "import": "./dist/register.js", "require": "./dist/register.cjs" - } + }, + "./integration-manifest": "./integration-manifest.json" }, "scripts": { "build": "tsup", @@ -35,9 +54,9 @@ }, "dependencies": { "@goodsdks/citizen-sdk": "1.2.7", - "@goodwidget/core": "workspace:*", - "@goodwidget/embed": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/embed": "workspace:^", + "@goodwidget/ui": "workspace:^", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/citizen-claim-widget/src/register.ts b/packages/citizen-claim-widget/src/register.ts index 38a74723..5e50bcdc 100644 --- a/packages/citizen-claim-widget/src/register.ts +++ b/packages/citizen-claim-widget/src/register.ts @@ -1,5 +1,3 @@ -import { CitizenClaimWidgetElement } from './element' - const DEFAULT_TAG_NAME = 'gw-citizen-claim' /** @@ -11,17 +9,21 @@ const DEFAULT_TAG_NAME = 'gw-citizen-claim' * Then use in HTML: * * - * Returns the tag name so you can use it programmatically: - * const tag = register() // 'gw-citizen-claim' + * Resolves to the tag name so you can use it programmatically: + * const tag = await register() // 'gw-citizen-claim' * * Or register under a custom tag: - * const tag = register('my-claim-widget') + * const tag = await register('my-claim-widget') */ -export function register(tagName: string = DEFAULT_TAG_NAME): string { +export async function register(tagName: string = DEFAULT_TAG_NAME): Promise { + // A server must not evaluate the browser-only element dependency graph. + // Load it only after confirming that this is a Custom Elements runtime. + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { CitizenClaimWidgetElement } = await import('./element') customElements.define(tagName, CitizenClaimWidgetElement) } return tagName } -register() +void register() diff --git a/packages/claim-widget-theme-demo/package.json b/packages/claim-widget-theme-demo/package.json index ed951bb5..131948f8 100644 --- a/packages/claim-widget-theme-demo/package.json +++ b/packages/claim-widget-theme-demo/package.json @@ -2,6 +2,7 @@ "name": "@goodwidget/claim-widget-theme-demo", "version": "0.1.0", "description": "Sample GoodDollar claim widget — works in React, React Native, and as a Web Component", + "license": "MIT", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/claim-widget-theme-demo/src/register.ts b/packages/claim-widget-theme-demo/src/register.ts index 622403e4..c27630d5 100644 --- a/packages/claim-widget-theme-demo/src/register.ts +++ b/packages/claim-widget-theme-demo/src/register.ts @@ -1,5 +1,3 @@ -import { ClaimWidgetElement } from './element' - const TAG_NAME = 'gw-claim-widget' /** @@ -11,14 +9,16 @@ const TAG_NAME = 'gw-claim-widget' * Then use in HTML: * * - * Returns the tag name so you can use it programmatically: - * const tag = register() // 'gw-claim-widget' + * Resolves to the tag name so you can use it programmatically: + * const tag = await register() // 'gw-claim-widget' */ -export function register(tagName: string = TAG_NAME): string { +export async function register(tagName: string = TAG_NAME): Promise { + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { ClaimWidgetElement } = await import('./element') customElements.define(tagName, ClaimWidgetElement) } return tagName } -register() +void register() diff --git a/packages/core/package.json b/packages/core/package.json index 37b03714..9f141159 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,20 @@ { "name": "@goodwidget/core", "version": "0.1.0", + "description": "Provider, wallet, and host runtime for GoodWidget applications.", + "license": "MIT", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/core" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -33,7 +47,7 @@ } }, "dependencies": { - "@goodwidget/ui": "workspace:*", + "@goodwidget/ui": "workspace:^", "tamagui": "1.121.0" }, "devDependencies": { diff --git a/packages/core/src/detect.ts b/packages/core/src/detect.ts index 07c1a088..070aee0c 100644 --- a/packages/core/src/detect.ts +++ b/packages/core/src/detect.ts @@ -1,5 +1,5 @@ import type { EIP1193Provider } from './eip1193' -import type { HostEnvironment, HostCapabilities, HostDetectionResult } from './types' +import type { HostCapabilities, HostDetectionResult } from './types' interface WindowWithProviders { ethereum?: EIP1193Provider & { diff --git a/packages/embed/package.json b/packages/embed/package.json index 61ca65aa..d1e96c01 100644 --- a/packages/embed/package.json +++ b/packages/embed/package.json @@ -1,6 +1,20 @@ { "name": "@goodwidget/embed", "version": "0.1.0", + "description": "Web Component and host bridge for GoodWidget applications.", + "license": "MIT", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/embed" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -28,8 +42,8 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "@goodwidget/core": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/ui": "workspace:^", "@r2wc/react-to-web-component": "^2.0.3", "@reown/appkit": "^1.8.22", "@reown/appkit-adapter-wagmi": "^1.8.22", diff --git a/packages/embed/src/createMiniAppElement.tsx b/packages/embed/src/createMiniAppElement.tsx index 7357a07c..f46e1711 100644 --- a/packages/embed/src/createMiniAppElement.tsx +++ b/packages/embed/src/createMiniAppElement.tsx @@ -56,13 +56,15 @@ export function createMiniAppElement( App: React.ComponentType>, options: MiniAppElementOptions = {}, ) { - const HTMLElementBase = + // Package entry points are also inspected by SSR-aware bundlers. Defining + // the class must therefore be safe when Node has no DOM globals. The + // fallback is never instantiated on the server; browser and server bundles + // evaluate this module in separate runtimes, so the browser still extends + // the platform's real HTMLElement. + const HTMLElementBase: typeof HTMLElement = typeof globalThis !== 'undefined' && 'HTMLElement' in globalThis ? (globalThis as { HTMLElement: typeof HTMLElement }).HTMLElement - : undefined - if (!HTMLElementBase) { - throw new Error('createMiniAppElement is only supported in DOM environments') - } + : (class {} as typeof HTMLElement) const { shadow = true, @@ -91,7 +93,7 @@ export function createMiniAppElement( static get observedAttributes(): string[] { return Object.entries(normalizedProps) - .filter(([_, def]) => def.type === 'attribute') + .filter(([, def]) => def.type === 'attribute') .map(([name]) => toKebabCase(name)) } diff --git a/packages/goodreserve-widget/integration-manifest.json b/packages/goodreserve-widget/integration-manifest.json new file mode 100644 index 00000000..31da331d --- /dev/null +++ b/packages/goodreserve-widget/integration-manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "2.0.0", + "hostContractVersion": "1.0.0", + "widgetId": "goodwidget.goodreserve", + "packageName": "@goodwidget/goodreserve-widget", + "entries": { + "react": { + "export": "GoodReserveWidget" + }, + "webComponent": { + "registerPath": "./register", + "tagName": "gw-goodreserve-widget" + } + } +} diff --git a/packages/goodreserve-widget/package.json b/packages/goodreserve-widget/package.json index edd95e75..5f7c03c9 100644 --- a/packages/goodreserve-widget/package.json +++ b/packages/goodreserve-widget/package.json @@ -2,6 +2,23 @@ "name": "@goodwidget/goodreserve-widget", "version": "0.1.0", "description": "GoodReserve swap widget for GoodWidget", + "license": "MIT", + "files": [ + "dist", + "integration-manifest.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/goodreserve-widget" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "./dist/register.js", + "./dist/register.cjs" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +38,8 @@ "types": "./dist/register.d.ts", "import": "./dist/register.js", "require": "./dist/register.cjs" - } + }, + "./integration-manifest": "./integration-manifest.json" }, "scripts": { "build": "tsup", @@ -35,9 +53,9 @@ }, "dependencies": { "@goodsdks/good-reserve": "^0.1.0", - "@goodwidget/core": "workspace:*", - "@goodwidget/embed": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/embed": "workspace:^", + "@goodwidget/ui": "workspace:^", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/goodreserve-widget/src/register.ts b/packages/goodreserve-widget/src/register.ts index 8cc54fce..51914ead 100644 --- a/packages/goodreserve-widget/src/register.ts +++ b/packages/goodreserve-widget/src/register.ts @@ -1,13 +1,15 @@ -import { GoodReserveWidgetElement } from './element' - const DEFAULT_TAG_NAME = 'gw-goodreserve-widget' // Registers the reserve widget custom element for non-React hosts. -export function register(tagName: string = DEFAULT_TAG_NAME): string { +export async function register(tagName: string = DEFAULT_TAG_NAME): Promise { + // Next.js may evaluate this entry in Node. Defer the element import because + // its React/Tamagui graph is needed only when a browser can register it. + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { GoodReserveWidgetElement } = await import('./element') customElements.define(tagName, GoodReserveWidgetElement) } return tagName } -register() +void register() diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json index dc91ce9f..3a0f5caa 100644 --- a/packages/governance-widget/package.json +++ b/packages/governance-widget/package.json @@ -2,6 +2,7 @@ "name": "@goodwidget/governance-widget", "version": "0.1.0", "description": "Presentational governance homepage components for GoodWidget", + "license": "MIT", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/staking-migration-widget/integration-manifest.json b/packages/staking-migration-widget/integration-manifest.json new file mode 100644 index 00000000..d6fba37d --- /dev/null +++ b/packages/staking-migration-widget/integration-manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "2.0.0", + "hostContractVersion": "1.0.0", + "widgetId": "goodwidget.staking-migration", + "packageName": "@goodwidget/staking-migration-widget", + "entries": { + "react": { + "export": "StakingMigrationWidget" + }, + "webComponent": { + "registerPath": "./register", + "tagName": "gw-staking-migration-widget" + } + } +} diff --git a/packages/staking-migration-widget/package.json b/packages/staking-migration-widget/package.json index a1899eba..91710b90 100644 --- a/packages/staking-migration-widget/package.json +++ b/packages/staking-migration-widget/package.json @@ -2,6 +2,23 @@ "name": "@goodwidget/staking-migration-widget", "version": "0.1.0", "description": "Fuse staking migration widget for moving sG$ positions to Celo savings", + "license": "MIT", + "files": [ + "dist", + "integration-manifest.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/staking-migration-widget" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "./dist/register.js", + "./dist/register.cjs" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +38,8 @@ "types": "./dist/register.d.ts", "import": "./dist/register.js", "require": "./dist/register.cjs" - } + }, + "./integration-manifest": "./integration-manifest.json" }, "scripts": { "build": "tsup", @@ -34,9 +52,9 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "@goodwidget/core": "workspace:*", - "@goodwidget/embed": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/embed": "workspace:^", + "@goodwidget/ui": "workspace:^", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/staking-migration-widget/src/register.ts b/packages/staking-migration-widget/src/register.ts index e788b1e8..87ff6592 100644 --- a/packages/staking-migration-widget/src/register.ts +++ b/packages/staking-migration-widget/src/register.ts @@ -1,13 +1,15 @@ -import { StakingMigrationWidgetElement } from './element' - const TAG_NAME = 'gw-staking-migration-widget' // This helper registers the default staking migration custom element tag. -export function register(tagName: string = TAG_NAME): string { +export async function register(tagName: string = TAG_NAME): Promise { + // Keep package discovery and SSR evaluation out of the element's + // browser-only dependency graph, not merely free of DOM side effects. + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { StakingMigrationWidgetElement } = await import('./element') customElements.define(tagName, StakingMigrationWidgetElement) } return tagName } -register() +void register() diff --git a/packages/streaming-widget/integration-manifest.json b/packages/streaming-widget/integration-manifest.json new file mode 100644 index 00000000..eda5e918 --- /dev/null +++ b/packages/streaming-widget/integration-manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "2.0.0", + "hostContractVersion": "1.0.0", + "widgetId": "goodwidget.streaming", + "packageName": "@goodwidget/streaming-widget", + "entries": { + "react": { + "export": "StreamingWidget" + }, + "webComponent": { + "registerPath": "./register", + "tagName": "gw-streaming" + } + } +} diff --git a/packages/streaming-widget/package.json b/packages/streaming-widget/package.json index 57eaf0c2..11ce3e9e 100644 --- a/packages/streaming-widget/package.json +++ b/packages/streaming-widget/package.json @@ -1,7 +1,24 @@ { "name": "@goodwidget/streaming-widget", - "version": "0.1.0-beta", + "version": "0.1.0", "description": "GoodWidget for Superfluid streaming flows — streams, GDA pool memberships, and Super Token balances", + "license": "MIT", + "files": [ + "dist", + "integration-manifest.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/streaming-widget" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": [ + "./dist/register.js", + "./dist/register.cjs" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -21,7 +38,8 @@ "types": "./dist/register.d.ts", "import": "./dist/register.js", "require": "./dist/register.cjs" - } + }, + "./integration-manifest": "./integration-manifest.json" }, "scripts": { "build": "tsup", @@ -35,9 +53,9 @@ }, "dependencies": { "@goodsdks/streaming-sdk": "1.0.0", - "@goodwidget/core": "workspace:*", - "@goodwidget/embed": "workspace:*", - "@goodwidget/ui": "workspace:*", + "@goodwidget/core": "workspace:^", + "@goodwidget/embed": "workspace:^", + "@goodwidget/ui": "workspace:^", "viem": "^2.0.0" }, "devDependencies": { diff --git a/packages/streaming-widget/src/register.ts b/packages/streaming-widget/src/register.ts index 46536fc7..403f94f8 100644 --- a/packages/streaming-widget/src/register.ts +++ b/packages/streaming-widget/src/register.ts @@ -1,5 +1,3 @@ -import { StreamingWidgetElement } from './element' - const DEFAULT_TAG_NAME = 'gw-streaming' /** @@ -11,17 +9,20 @@ const DEFAULT_TAG_NAME = 'gw-streaming' * Then use in HTML: * * - * Returns the tag name so you can use it programmatically: - * const tag = register() // 'gw-streaming' + * Resolves to the tag name so you can use it programmatically: + * const tag = await register() // 'gw-streaming' * * Or register under a custom tag: - * const tag = register('my-streaming-widget') + * const tag = await register('my-streaming-widget') */ -export function register(tagName: string = DEFAULT_TAG_NAME): string { +export async function register(tagName: string = DEFAULT_TAG_NAME): Promise { + // Keep server-side package evaluation out of the browser-only element graph. + if (typeof customElements === 'undefined') return tagName if (!customElements.get(tagName)) { + const { StreamingWidgetElement } = await import('./element') customElements.define(tagName, StreamingWidgetElement) } return tagName } -register() +void register() diff --git a/packages/ui/package.json b/packages/ui/package.json index ba9268e8..198276c7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,20 @@ { "name": "@goodwidget/ui", "version": "0.1.0", + "description": "Shared UI primitives and themes for GoodWidget applications.", + "license": "MIT", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/GoodDollar/GoodWidget.git", + "directory": "packages/ui" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/ui/src/clipboard.ts b/packages/ui/src/clipboard.ts index c85c958e..e07add16 100644 --- a/packages/ui/src/clipboard.ts +++ b/packages/ui/src/clipboard.ts @@ -1,7 +1,3 @@ -type NativeClipboardModule = { - setString: (text: string) => void -} - function canUseWebClipboard(): boolean { return ( typeof navigator !== 'undefined' && diff --git a/packages/ui/src/components-test/Input.tsx b/packages/ui/src/components-test/Input.tsx index 00d59767..192c56ff 100644 --- a/packages/ui/src/components-test/Input.tsx +++ b/packages/ui/src/components-test/Input.tsx @@ -93,6 +93,7 @@ const InputWithLabel = React.forwardRef(function InputWithL ref={ref} error={error || !!errorMessage} onChange={handleChange} + onChangeText={handleChangeText} {...props} /> {errorMessage && {errorMessage}} diff --git a/packages/ui/src/components-test/Select.tsx b/packages/ui/src/components-test/Select.tsx index b93aa0d3..b71b63c0 100644 --- a/packages/ui/src/components-test/Select.tsx +++ b/packages/ui/src/components-test/Select.tsx @@ -1,6 +1,5 @@ import React, { useState, useCallback } from 'react' import { styled, Stack, Text as TamaguiText } from 'tamagui' -import type { GetProps } from 'tamagui' const SelectFrame = styled(Stack, { name: 'Select', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e20f4a9a..6081c2de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.25.0 version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + ajv: + specifier: 8.17.1 + version: 8.17.1 eslint: specifier: ^9.20.0 version: 9.39.4 @@ -302,13 +305,13 @@ importers: specifier: 1.2.7 version: 1.2.7(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))(wagmi@3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(yaml@2.8.3) '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/embed': - specifier: workspace:* + specifier: workspace:^ version: link:../embed '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui viem: specifier: ^2.0.0 @@ -339,13 +342,13 @@ importers: specifier: 1.2.7 version: 1.2.7(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))(wagmi@3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(yaml@2.8.3) '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/embed': - specifier: workspace:* + specifier: workspace:^ version: link:../embed '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui viem: specifier: ^2.0.0 @@ -404,7 +407,7 @@ importers: packages/core: dependencies: '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui tamagui: specifier: 1.121.0 @@ -429,10 +432,10 @@ importers: packages/embed: dependencies: '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui '@r2wc/react-to-web-component': specifier: ^2.0.3 @@ -478,13 +481,13 @@ importers: specifier: ^0.1.0 version: 0.1.0(@swc/core@1.15.30)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))(yaml@2.8.3) '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/embed': - specifier: workspace:* + specifier: workspace:^ version: link:../embed '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui viem: specifier: ^2.0.0 @@ -552,13 +555,13 @@ importers: packages/staking-migration-widget: dependencies: '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/embed': - specifier: workspace:* + specifier: workspace:^ version: link:../embed '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui viem: specifier: ^2.0.0 @@ -589,13 +592,13 @@ importers: specifier: 1.0.0 version: 1.0.0(react@18.3.1)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) '@goodwidget/core': - specifier: workspace:* + specifier: workspace:^ version: link:../core '@goodwidget/embed': - specifier: workspace:* + specifier: workspace:^ version: link:../embed '@goodwidget/ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ui viem: specifier: ^2.0.0 @@ -1956,7 +1959,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -4926,8 +4929,8 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} ajv@8.6.3: resolution: {integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==} @@ -16501,13 +16504,13 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.18.0): + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: - ajv: 8.18.0 + ajv: 8.17.1 - ajv-keywords@5.1.0(ajv@8.18.0): + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: - ajv: 8.18.0 + ajv: 8.17.1 fast-deep-equal: 3.1.3 ajv@6.14.0: @@ -16517,7 +16520,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -20924,9 +20927,9 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.18.0 - ajv-formats: 2.1.1(ajv@8.18.0) - ajv-keywords: 5.1.0(ajv@8.18.0) + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) selfsigned@2.4.1: dependencies: @@ -21289,7 +21292,7 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.18.0 + ajv: 8.17.1 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 diff --git a/schemas/widget-integration-manifest.schema.json b/schemas/widget-integration-manifest.schema.json new file mode 100644 index 00000000..3124f272 --- /dev/null +++ b/schemas/widget-integration-manifest.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gooddollar.org/schemas/goodwidget/integration-manifest/v2.json", + "title": "GoodWidget package integration descriptor", + "description": "Declares immutable widget identity and package entry points. Wallet presentation, provider policy, tests, and release provenance are intentionally outside this descriptor.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "hostContractVersion", "widgetId", "packageName", "entries"], + "properties": { + "schemaVersion": { "const": "2.0.0" }, + "hostContractVersion": { + "type": "string", + "pattern": "^1\\.[0-9]+\\.[0-9]+$" + }, + "widgetId": { + "type": "string", + "pattern": "^goodwidget\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$" + }, + "packageName": { + "type": "string", + "pattern": "^@goodwidget/[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "entries": { + "type": "object", + "additionalProperties": false, + "required": ["react", "webComponent"], + "properties": { + "react": { + "type": "object", + "additionalProperties": false, + "required": ["export"], + "properties": { + "export": { + "type": "string", + "pattern": "^[A-Z][A-Za-z0-9]*$" + } + } + }, + "webComponent": { + "type": "object", + "additionalProperties": false, + "required": ["registerPath", "tagName"], + "properties": { + "registerPath": { "const": "./register" }, + "tagName": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$" + } + } + } + } + } + } +} diff --git a/scripts/release/packages.mjs b/scripts/release/packages.mjs new file mode 100644 index 00000000..a68999ec --- /dev/null +++ b/scripts/release/packages.mjs @@ -0,0 +1,156 @@ +// This allowlist is deliberately topological: shared foundations precede their consumers. +// affectedPackages() preserves this order for versioning, packing, and npm publication. +export const packages = [ + { dir: 'packages/ui', name: '@goodwidget/ui', kind: 'shared', dependencies: [] }, + { + dir: 'packages/core', + name: '@goodwidget/core', + kind: 'shared', + dependencies: ['@goodwidget/ui'], + }, + { + dir: 'packages/embed', + name: '@goodwidget/embed', + kind: 'shared', + dependencies: ['@goodwidget/ui', '@goodwidget/core'], + }, + { + dir: 'packages/ai-credits-widget', + name: '@goodwidget/ai-credits-widget', + kind: 'widget', + dependencies: ['@goodwidget/ui', '@goodwidget/core', '@goodwidget/embed'], + }, + { + dir: 'packages/citizen-claim-widget', + name: '@goodwidget/citizen-claim-widget', + kind: 'widget', + dependencies: ['@goodwidget/ui', '@goodwidget/core', '@goodwidget/embed'], + }, + { + dir: 'packages/goodreserve-widget', + name: '@goodwidget/goodreserve-widget', + kind: 'widget', + dependencies: ['@goodwidget/ui', '@goodwidget/core', '@goodwidget/embed'], + }, + { + dir: 'packages/staking-migration-widget', + name: '@goodwidget/staking-migration-widget', + kind: 'widget', + dependencies: ['@goodwidget/ui', '@goodwidget/core', '@goodwidget/embed'], + }, + { + dir: 'packages/streaming-widget', + name: '@goodwidget/streaming-widget', + kind: 'widget', + dependencies: ['@goodwidget/ui', '@goodwidget/core', '@goodwidget/embed'], + }, +] + +export const productionPackageNames = new Set(packages.map((pkg) => pkg.name)) +export const releaseMarker = '[goodwidget release]' + +const rootReleaseFiles = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'turbo.json', + 'tsconfig.base.json', +]) + +export function isReleaseRelevant(path) { + if (rootReleaseFiles.has(path)) return true + if (/(^|\/)(?:docs?|tests?|__tests__|screenshots?|fixtures?)(\/|$)/i.test(path)) return false + if ( + /(?:\.stories\.[cm]?[jt]sx?|\.test\.[cm]?[jt]sx?|\.spec\.[cm]?[jt]sx?|\.mdx?|\.snap)$/i.test( + path, + ) + ) + return false + return packages.some( + (pkg) => + path === `${pkg.dir}/package.json` || + path.startsWith(`${pkg.dir}/src/`) || + path.startsWith(`${pkg.dir}/tsup.`) || + path.startsWith(`${pkg.dir}/tsconfig.`) || + path === `${pkg.dir}/integration-manifest.json`, + ) +} + +export function affectedPackages(changedPaths) { + const relevant = changedPaths.filter(isReleaseRelevant) + if (relevant.length === 0) return [] + + const affected = new Set() + if (relevant.some((path) => rootReleaseFiles.has(path))) { + for (const pkg of packages) affected.add(pkg.name) + } else { + for (const pkg of packages) { + if (relevant.some((path) => path === pkg.dir || path.startsWith(`${pkg.dir}/`))) { + affected.add(pkg.name) + } + } + } + + // Release every transitive published consumer so its resolved workspace ranges and + // tarball are rebuilt against the newly released dependency. + let changed = true + while (changed) { + changed = false + for (const pkg of packages) { + if ( + !affected.has(pkg.name) && + pkg.dependencies.some((dependency) => affected.has(dependency)) + ) { + affected.add(pkg.name) + changed = true + } + } + } + return packages.filter((pkg) => affected.has(pkg.name)) +} + +export function stableVersion(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/) + if (!match) throw new Error(`Unsupported package version: ${version}`) + return `${match[1]}.${match[2]}.${match[3]}` +} + +export function nextVersion(version, currentVersionIsPublished) { + const normalized = stableVersion(version) + // First publication keeps the checked-in stable version (and strips a legacy prerelease + // suffix); later publications are patch releases. + if (!currentVersionIsPublished) return normalized + const [major, minor, patch] = normalized.split('.').map(Number) + return `${major}.${minor}.${patch + 1}` +} + +export function compareStableVersions(left, right) { + const leftParts = stableVersion(left).split('.').map(Number) + const rightParts = stableVersion(right).split('.').map(Number) + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index] + } + return 0 +} + +export function registryReleaseMatches(record, latest, expectedVersion) { + return ( + record?.version === expectedVersion && + Boolean(record.integrity) && + latest === expectedVersion + ) +} + +export function walletDispatchEnabled(environment) { + // A credential alone must never activate cross-repository automation. + return ( + environment.ENABLE_GOODWALLET_DISPATCH === 'true' && + Boolean(environment.GOODWALLET_DISPATCH_PAT) + ) +} + +export function pathsForPush(baseSha, diffPaths) { + // GitHub reports an all-zero base for the first push of next. Treat that bootstrap as a + // release of the complete production allowlist rather than an empty diff. + return /^0{40}$/.test(baseSha) ? packages.map((pkg) => `${pkg.dir}/package.json`) : diffPaths +} diff --git a/scripts/release/packages.test.mjs b/scripts/release/packages.test.mjs new file mode 100644 index 00000000..1baf3e68 --- /dev/null +++ b/scripts/release/packages.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + affectedPackages, + compareStableVersions, + isReleaseRelevant, + nextVersion, + packages, + pathsForPush, + registryReleaseMatches, + releaseMarker, + stableVersion, + walletDispatchEnabled, +} from './packages.mjs' + +const names = (paths) => affectedPackages(paths).map((pkg) => pkg.name) +const widgets = packages.filter((pkg) => pkg.kind === 'widget').map((pkg) => pkg.name) + +test('ui changes cascade through core, embed, and every production widget', () => { + assert.deepEqual( + names(['packages/ui/src/Button.tsx']), + packages.map((pkg) => pkg.name), + ) +}) +test('core changes cascade to embed and every production widget', () => { + assert.deepEqual(names(['packages/core/src/provider.tsx']), [ + '@goodwidget/core', + '@goodwidget/embed', + ...widgets, + ]) +}) +test('embed changes cascade only to production widgets', () => { + assert.deepEqual(names(['packages/embed/src/bridge.ts']), ['@goodwidget/embed', ...widgets]) +}) +test('widget-only fixes release only that widget', () => { + assert.deepEqual(names(['packages/goodreserve-widget/src/amount.ts']), [ + '@goodwidget/goodreserve-widget', + ]) +}) +test('docs, tests, stories, and screenshots are no-op changes', () => { + assert.equal(isReleaseRelevant('packages/ui/src/Button.test.tsx'), false) + assert.deepEqual( + names([ + 'docs/PACKAGING.md', + 'packages/ui/README.md', + 'packages/ui/src/Button.test.tsx', + 'packages/goodreserve-widget/screenshots/a.png', + ]), + [], + ) +}) +test('root resolution changes release the complete allowlist', () => { + assert.deepEqual( + names(['pnpm-lock.yaml']), + packages.map((pkg) => pkg.name), + ) +}) +test('excluded packages never enter the production allowlist', () => { + assert.deepEqual( + names([ + 'packages/governance-widget/src/index.ts', + 'packages/claim-widget-theme-demo/src/index.ts', + ]), + [], + ) +}) +test('bootstrap strips prerelease without increment and subsequent releases patch bump', () => { + assert.equal(stableVersion('0.2.0-beta'), '0.2.0') + assert.equal(nextVersion('0.2.0-beta', false), '0.2.0') + assert.equal(nextVersion('0.2.0', true), '0.2.1') + assert.ok(compareStableVersions('1.0.10', '1.0.9') > 0) +}) +test('release marker is stable for loop prevention', () => { + assert.equal(releaseMarker, '[goodwidget release]') +}) +test('registry verification requires the immutable version, integrity, and latest tag', () => { + const record = { version: '1.2.3', integrity: 'sha512-release' } + assert.equal(registryReleaseMatches(record, '1.2.3', '1.2.3'), true) + assert.equal(registryReleaseMatches(record, '1.2.2', '1.2.3'), false) + assert.equal( + registryReleaseMatches({ version: '1.2.3' }, '1.2.3', '1.2.3'), + false, + ) + assert.equal(registryReleaseMatches(null, '1.2.3', '1.2.3'), false) +}) +test('wallet dispatch requires both explicit activation and its scoped PAT', () => { + assert.equal(walletDispatchEnabled({ GOODWALLET_DISPATCH_PAT: 'secret' }), false) + assert.equal(walletDispatchEnabled({ ENABLE_GOODWALLET_DISPATCH: 'true' }), false) + assert.equal( + walletDispatchEnabled({ + ENABLE_GOODWALLET_DISPATCH: 'false', + GOODWALLET_DISPATCH_PAT: 'secret', + }), + false, + ) + assert.equal( + walletDispatchEnabled({ + ENABLE_GOODWALLET_DISPATCH: 'true', + GOODWALLET_DISPATCH_PAT: 'secret', + }), + true, + ) +}) +test('first creation of next bootstraps the full production allowlist', () => { + const paths = pathsForPush('0000000000000000000000000000000000000000', []) + assert.deepEqual(affectedPackages(paths), packages) + assert.deepEqual(pathsForPush('a'.repeat(40), ['packages/goodreserve-widget/src/amount.ts']), [ + 'packages/goodreserve-widget/src/amount.ts', + ]) +}) diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs new file mode 100644 index 00000000..f5d30b22 --- /dev/null +++ b/scripts/release/release.mjs @@ -0,0 +1,257 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + affectedPackages, + compareStableVersions, + nextVersion, + packages, + pathsForPush, + registryReleaseMatches, + releaseMarker, + walletDispatchEnabled, +} from './packages.mjs' +import { + createReleaseEnvelope, + validateAllManifests, + validateManifest, +} from './validate-manifests.mjs' + +function run(command, args, options = {}) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: options.capture ? 'pipe' : 'inherit', + ...options, + }).trim() +} + +function packageJson(pkg) { + return JSON.parse(readFileSync(`${pkg.dir}/package.json`, 'utf8')) +} + +function registryRecord(name, version) { + try { + const record = JSON.parse( + run('npm', ['view', `${name}@${version}`, '--json'], { capture: true }), + ) + return { version: record.version, integrity: record.dist?.integrity } + } catch { + return null + } +} + +function registryLatest(name) { + try { + return JSON.parse(run('npm', ['view', name, 'dist-tags.latest', '--json'], { capture: true })) + } catch { + return null + } +} + +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +async function verifyRegistryRelease(name, version) { + // npm metadata is eventually consistent. Do not tag or notify GoodWallet + // until both the immutable release and the mutable `latest` channel agree. + for (let attempt = 1; attempt <= 6; attempt += 1) { + const record = registryRecord(name, version) + const latest = registryLatest(name) + if (registryReleaseMatches(record, latest, version)) return record + if (attempt < 6) await wait(2_000) + } + throw new Error(`Registry verification failed for ${name}@${version} or its latest tag`) +} + +function changedPaths(base, head) { + const diff = /^0{40}$/.test(base) + ? [] + : run('git', ['diff', '--name-only', `${base}..${head}`], { capture: true }) + .split('\n') + .filter(Boolean) + return pathsForPush(base, diff) +} + +function assertNextBranch() { + const branch = + process.env.GITHUB_REF_NAME || run('git', ['branch', '--show-current'], { capture: true }) + if (branch !== 'next') + throw new Error( + `Refusing npm release from ${branch || 'detached HEAD'}; releases only run from next`, + ) +} + +async function dispatchWidget(pkg, version, integrity, sourceCommit, releaseUrl) { + if (!walletDispatchEnabled(process.env)) { + console.log( + `GoodWallet dispatch is disabled for ${pkg.name}@${version}; activation requires ENABLE_GOODWALLET_DISPATCH=true and the scoped PAT.`, + ) + return + } + // The checked-in descriptor is immutable package capability metadata. Release + // provenance is added beside it only after npm has returned the exact integrity. + const descriptor = JSON.parse(readFileSync(`${pkg.dir}/integration-manifest.json`, 'utf8')) + validateManifest(descriptor, packageJson(pkg)) + const envelope = createReleaseEnvelope(descriptor, { + version, + integrity, + sourceSha: sourceCommit, + releaseUrl, + }) + const response = await fetch( + 'https://api.github.com/repos/GoodDollar/GoodWallet/repository/dispatches', + { + method: 'POST', + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${process.env.GOODWALLET_DISPATCH_PAT}`, + 'x-github-api-version': '2022-11-28', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + event_type: 'goodwidget-released', + client_payload: envelope, + }), + }, + ) + if (!response.ok) + throw new Error(`GoodWallet dispatch failed: ${response.status} ${await response.text()}`) +} + +async function release() { + // This assertion is repeated in code so local/manual invocation cannot bypass the + // workflow-level main-versus-next publication boundary. + assertNextBranch() + const head = process.env.GITHUB_SHA || 'HEAD' + const subject = run('git', ['log', '-1', '--pretty=%s', head], { capture: true }) + if (subject.includes(releaseMarker)) + return console.log('Release marker commit detected; nothing to release.') + const base = process.env.RELEASE_BASE_SHA || process.argv[2] || `${head}^` + const selected = affectedPackages(changedPaths(base, head)) + if (selected.length === 0) return console.log('No release-relevant package changes.') + + // selected follows the topological allowlist, so consumers are processed after every + // affected shared dependency. + for (const pkg of selected) { + const json = packageJson(pkg) + let candidate = nextVersion(json.version, Boolean(registryRecord(pkg.name, json.version))) + const latest = registryLatest(pkg.name) + if (latest && compareStableVersions(candidate, latest) <= 0) { + candidate = nextVersion(latest, true) + } + while (Boolean(registryRecord(pkg.name, candidate))) { + candidate = nextVersion(candidate, true) + } + json.version = candidate + writeFileSync(`${pkg.dir}/package.json`, `${JSON.stringify(json, null, 2)}\n`) + } + run('pnpm', ['install', '--lockfile-only', '--frozen-lockfile=false']) + await validateAllManifests() + run('pnpm', ['lint']) + run('pnpm', ['build']) + // Exercise the built `./register` exports in plain Node before packing. + // This catches accidental DOM access that would break Next.js consumers. + run('pnpm', ['validate:register-entries']) + run('pnpm', ['test:release']) + + const packDir = mkdtempSync(join(tmpdir(), 'goodwidget-release-')) + const tarballs = new Map() + for (const pkg of selected) { + run('pnpm', ['--dir', pkg.dir, 'pack', '--pack-destination', packDir]) + const tarball = readdirSync(packDir).find( + (file) => + file.startsWith(pkg.name.replace('@', '').replace('/', '-')) && file.endsWith('.tgz'), + ) + if (!tarball) throw new Error(`Unable to locate packed tarball for ${pkg.name}`) + const listing = run('tar', ['-tzf', join(packDir, tarball)], { capture: true }) + if ( + !listing.includes('package/dist/index.js') || + (pkg.kind === 'widget' && + (!listing.includes('package/dist/register.js') || + !listing.includes('package/integration-manifest.json'))) + ) + throw new Error(`Tarball inspection failed for ${pkg.name}`) + tarballs.set(pkg.name, join(packDir, tarball)) + } + + const versions = selected.map((pkg) => `${pkg.name}@${packageJson(pkg).version}`) + // Versions and the lockfile become authoritative before publication. The marker makes + // the resulting push a no-op for release-next.yml and enables exact-version recovery. + run('git', ['add', ...selected.map((pkg) => `${pkg.dir}/package.json`), 'pnpm-lock.yaml']) + run('git', ['commit', '-m', `${releaseMarker} ${versions.join(', ')}`]) + run('git', ['push', 'origin', 'next']) + + // Publish in dependency order and require both immutable registry integrity + // and the `latest` tag before creating local tags or notifying GoodWallet. + const verifiedReleases = new Map() + for (const pkg of selected) { + const version = packageJson(pkg).version + if (!registryRecord(pkg.name, version)) + run('npm', ['publish', tarballs.get(pkg.name), '--access', 'public', '--provenance']) + const verified = await verifyRegistryRelease(pkg.name, version) + verifiedReleases.set(pkg.name, verified) + run('git', [ + 'tag', + '-a', + `${pkg.name.replace('@goodwidget/', '')}-v${version}`, + '-m', + `${pkg.name}@${version}`, + ]) + } + run('git', ['push', 'origin', '--tags']) + + // Cross-repository dispatch happens only after the whole npm release is verified and + // its tags are pushed; a partial or unverified release cannot reach GoodWallet. + const sourceCommit = run('git', ['rev-parse', 'HEAD'], { capture: true }) + for (const pkg of selected.filter((entry) => entry.kind === 'widget')) { + const version = packageJson(pkg).version + const verified = verifiedReleases.get(pkg.name) + await dispatchWidget( + pkg, + version, + verified.integrity, + sourceCommit, + `https://github.com/GoodDollar/GoodWidget/tree/${pkg.name.replace('@goodwidget/', '')}-v${version}`, + ) + } +} + +async function recover() { + assertNextBranch() + const requested = process.env.RECOVERY_PACKAGE + const pkg = packages.find((entry) => entry.name === requested) + if (!pkg) throw new Error(`RECOVERY_PACKAGE must be a production package, got ${requested}`) + const version = packageJson(pkg).version + // Recovery is idempotent: publish only when absent, then verify the exact committed + // version before optionally replaying the wallet dispatch. + if (!registryRecord(pkg.name, version)) + run('pnpm', [ + '--dir', + pkg.dir, + 'publish', + '--access', + 'public', + '--provenance', + '--no-git-checks', + ]) + // Recovery owns the already committed version. If publication succeeded but + // the mutable channel was not updated, repair it idempotently. + if (registryLatest(pkg.name) !== version) { + run('npm', ['dist-tag', 'add', `${pkg.name}@${version}`, 'latest']) + } + const verified = await verifyRegistryRelease(pkg.name, version) + if (pkg.kind === 'widget') { + const sourceCommit = run('git', ['rev-parse', 'HEAD'], { capture: true }) + await dispatchWidget( + pkg, + version, + verified.integrity, + sourceCommit, + `https://github.com/GoodDollar/GoodWidget/tree/${pkg.name.replace('@goodwidget/', '')}-v${version}`, + ) + } +} + +const mode = process.argv[2] +if (mode === 'recover') await recover() +else await release() diff --git a/scripts/release/validate-manifests.mjs b/scripts/release/validate-manifests.mjs new file mode 100644 index 00000000..b5f7d184 --- /dev/null +++ b/scripts/release/validate-manifests.mjs @@ -0,0 +1,98 @@ +import { readFile } from 'node:fs/promises' +import Ajv2020 from 'ajv/dist/2020.js' +import { packages } from './packages.mjs' + +const allowedKeys = { + root: ['schemaVersion', 'hostContractVersion', 'widgetId', 'packageName', 'entries'], + entries: ['react', 'webComponent'], + react: ['export'], + webComponent: ['registerPath', 'tagName'], +} + +function exactKeys(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object containing exactly: ${keys.join(', ')}`) + } + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + if (actual.join('\0') !== expected.join('\0')) + throw new Error(`${label} must contain exactly: ${keys.join(', ')}`) +} + +export function validateManifest(descriptor, packageJson) { + // This descriptor intentionally contains only package identity and entry points. + // Wallet presentation, provider policy, tests, and release provenance have separate owners. + exactKeys(descriptor, allowedKeys.root, 'descriptor') + exactKeys(descriptor.entries, allowedKeys.entries, 'entries') + exactKeys(descriptor.entries.react, allowedKeys.react, 'entries.react') + exactKeys(descriptor.entries.webComponent, allowedKeys.webComponent, 'entries.webComponent') + + if (descriptor.schemaVersion !== '2.0.0') throw new Error('Unsupported schemaVersion') + if (!/^1\.\d+\.\d+$/.test(descriptor.hostContractVersion)) + throw new Error('Unsupported hostContractVersion') + if (!/^goodwidget\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(descriptor.widgetId)) + throw new Error('Invalid widgetId') + if (!/^@goodwidget\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(descriptor.packageName)) + throw new Error('Invalid packageName') + if (!/^[A-Z][A-Za-z0-9]*$/.test(descriptor.entries.react.export)) + throw new Error('Invalid React export') + if (descriptor.entries.webComponent.registerPath !== './register') + throw new Error('Web Component registerPath must be ./register') + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(descriptor.entries.webComponent.tagName)) + throw new Error('Invalid Web Component tagName') + if (descriptor.packageName !== packageJson.name) + throw new Error(`packageName mismatch for ${packageJson.name}`) + if (!packageJson.exports?.['./integration-manifest']) + throw new Error(`${packageJson.name} does not export its integration descriptor`) + if (!packageJson.files?.includes('integration-manifest.json')) + throw new Error(`${packageJson.name} does not pack its integration descriptor`) + if (!packageJson.exports?.['.']) + throw new Error(`${packageJson.name} does not export its React entry`) + if (!packageJson.exports?.[descriptor.entries.webComponent.registerPath]) + throw new Error( + `${packageJson.name} does not export ${descriptor.entries.webComponent.registerPath}`, + ) +} + +export function createReleaseEnvelope(descriptor, { version, integrity, sourceSha, releaseUrl }) { + if (!/^\d+\.\d+\.\d+$/.test(version)) + throw new Error(`Release version must be stable semver, got ${version}`) + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity)) + throw new Error('Release integrity must be an npm sha512 integrity value') + if (!/^[0-9a-f]{40}$/.test(sourceSha)) + throw new Error('Release sourceSha must be a 40-character lowercase commit SHA') + const parsedReleaseUrl = new URL(releaseUrl) + if (parsedReleaseUrl.protocol !== 'https:') throw new Error('Release URL must use HTTPS') + + return { + descriptor, + version, + integrity, + sourceSha, + releaseUrl, + idempotencyKey: `${descriptor.packageName}@${version}`, + } +} + +export async function validateAllManifests() { + const schema = JSON.parse( + await readFile('schemas/widget-integration-manifest.schema.json', 'utf8'), + ) + const ajv = new Ajv2020({ allErrors: true }) + const validateSchema = ajv.compile(schema) + for (const pkg of packages.filter((entry) => entry.kind === 'widget')) { + const packageJson = JSON.parse(await readFile(`${pkg.dir}/package.json`, 'utf8')) + const descriptor = JSON.parse(await readFile(`${pkg.dir}/integration-manifest.json`, 'utf8')) + if (!validateSchema(descriptor)) { + throw new Error( + `${pkg.name} schema validation failed: ${ajv.errorsText(validateSchema.errors)}`, + ) + } + validateManifest(descriptor, packageJson) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await validateAllManifests() + console.log('Validated all production widget integration descriptors.') +} diff --git a/scripts/release/validate-manifests.test.mjs b/scripts/release/validate-manifests.test.mjs new file mode 100644 index 00000000..d9727998 --- /dev/null +++ b/scripts/release/validate-manifests.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createReleaseEnvelope, validateManifest } from './validate-manifests.mjs' + +const descriptor = { + schemaVersion: '2.0.0', + hostContractVersion: '1.0.0', + widgetId: 'goodwidget.goodreserve', + packageName: '@goodwidget/goodreserve-widget', + entries: { + react: { export: 'GoodReserveWidget' }, + webComponent: { + registerPath: './register', + tagName: 'gw-goodreserve-widget', + }, + }, +} + +const packageJson = { + name: '@goodwidget/goodreserve-widget', + files: ['dist', 'integration-manifest.json'], + exports: { + '.': './dist/index.js', + './register': './dist/register.js', + './integration-manifest': './integration-manifest.json', + }, +} + +test('accepts the minimal v2 descriptor', () => { + assert.doesNotThrow(() => validateManifest(descriptor, packageJson)) +}) + +test('rejects undeclared fields and entry points missing from the package', () => { + assert.throws( + () => validateManifest({ ...descriptor, selectors: {} }, packageJson), + /descriptor must contain exactly/, + ) + assert.throws( + () => + validateManifest(descriptor, { + ...packageJson, + exports: { ...packageJson.exports, './register': undefined }, + }), + /does not export \.\/register/, + ) +}) + +test('rejects malformed nested descriptor objects with a clear validation error', () => { + assert.throws( + () => + validateManifest( + { + ...descriptor, + entries: null, + }, + packageJson, + ), + /entries must be an object containing exactly/, + ) +}) + +test('builds a release envelope without duplicating descriptor fields', () => { + const envelope = createReleaseEnvelope(descriptor, { + version: '1.2.3', + integrity: `sha512-${'a'.repeat(86)}==`, + sourceSha: 'a'.repeat(40), + releaseUrl: 'https://github.com/GoodDollar/GoodWidget/tree/goodreserve-widget-v1.2.3', + }) + + assert.deepEqual(Object.keys(envelope), [ + 'descriptor', + 'version', + 'integrity', + 'sourceSha', + 'releaseUrl', + 'idempotencyKey', + ]) + assert.equal(envelope.idempotencyKey, '@goodwidget/goodreserve-widget@1.2.3') +}) + +test('rejects prerelease versions and malformed provenance', () => { + const release = { + version: '1.2.3', + integrity: `sha512-${'a'.repeat(86)}==`, + sourceSha: 'a'.repeat(40), + releaseUrl: 'https://github.com/GoodDollar/GoodWidget/tree/goodreserve-widget-v1.2.3', + } + + assert.throws( + () => createReleaseEnvelope(descriptor, { ...release, version: '1.2.3-beta.1' }), + /stable semver/, + ) + assert.throws( + () => createReleaseEnvelope(descriptor, { ...release, integrity: 'sha256-nope' }), + /sha512/, + ) + assert.throws( + () => createReleaseEnvelope(descriptor, { ...release, sourceSha: 'abc' }), + /sourceSha/, + ) +}) diff --git a/scripts/release/validate-packages.mjs b/scripts/release/validate-packages.mjs new file mode 100644 index 00000000..54f1ec72 --- /dev/null +++ b/scripts/release/validate-packages.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { packages } from './packages.mjs' + +const repositoryUrl = 'https://github.com/GoodDollar/GoodWidget.git' + +// Keep npm's legal/source metadata aligned with the repository rather than +// relying on each package author to remember the same publication fields. +for (const expected of packages) { + const manifest = JSON.parse(readFileSync(`${expected.dir}/package.json`, 'utf8')) + + assert.equal(manifest.name, expected.name) + assert.notEqual(manifest.private, true, `${manifest.name} must remain public`) + assert.equal(manifest.license, 'MIT', `${manifest.name} needs the MIT license`) + assert.equal( + manifest.repository?.url, + repositoryUrl, + `${manifest.name} needs its source repository`, + ) + assert.equal( + manifest.repository?.directory, + expected.dir, + `${manifest.name} needs its monorepo directory`, + ) + assert.equal( + manifest.publishConfig?.access, + 'public', + `${manifest.name} needs explicit public npm access`, + ) +} + +const license = readFileSync('LICENSE', 'utf8') +assert.match(license, /^MIT License\n/, 'The repository needs an MIT LICENSE file') + +console.log(`Validated metadata for ${packages.length} production packages.`) diff --git a/scripts/release/validate-register-entries.mjs b/scripts/release/validate-register-entries.mjs new file mode 100644 index 00000000..95330a48 --- /dev/null +++ b/scripts/release/validate-register-entries.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' +import { packages } from './packages.mjs' + +const require = createRequire(import.meta.url) + +// Next.js and other SSR-aware consumers may evaluate package exports in Node +// while building their module graph. Every production registration entry must +// be importable without HTMLElement, customElements, document, or window. +for (const pkg of packages.filter((entry) => entry.kind === 'widget')) { + const entryUrl = pathToFileURL(resolve(pkg.dir, 'dist/register.js')).href + const module = await import(entryUrl) + + assert.equal( + typeof module.register, + 'function', + `${pkg.name} must export a register function`, + ) + + const serverProbeTag = `ssr-probe-${pkg.name.split('/').at(-1)}` + assert.equal( + await module.register(serverProbeTag), + serverProbeTag, + `${pkg.name} register() must be a no-op without DOM globals`, + ) + + // The package publishes both import and require conditions. Exercise the + // CommonJS artifact too so a safe ESM entry cannot hide an eager CJS bundle. + const commonJsModule = require(resolve(pkg.dir, 'dist/register.cjs')) + const commonJsProbeTag = `cjs-${serverProbeTag}` + assert.equal( + await commonJsModule.register(commonJsProbeTag), + commonJsProbeTag, + `${pkg.name} CommonJS register() must be a no-op without DOM globals`, + ) +} + +console.log('Validated SSR-safe ESM and CommonJS imports for all widget registration entries.')