From 54b1f0d417fd90af1646f2ef9282abdbbf92077b Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 16 Oct 2026 12:44:37 -0400 Subject: [PATCH 1/2] feat: update widget versions to 0.1.12 for ai-credits and 0.1.7 for superfluid, add widget version tests --- .../fixtures/aiCreditsWidgetRegisterMock.ts | 2 +- src/widgets/registry.test.ts | 3 - src/widgets/registry.ts | 4 +- src/widgets/widgetVersions.test.ts | 66 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/widgets/widgetVersions.test.ts diff --git a/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts b/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts index 0f038e9..9c78a55 100644 --- a/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts +++ b/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts @@ -7,7 +7,7 @@ */ export const goodWidgetMetadata = { packageName: "@goodwidget/ai-credits-widget", - packageVersion: "0.1.0", + packageVersion: "0.1.12", } export const register = (tagName?: string): string => diff --git a/src/widgets/registry.test.ts b/src/widgets/registry.test.ts index 2033b7c..01651fa 100644 --- a/src/widgets/registry.test.ts +++ b/src/widgets/registry.test.ts @@ -157,7 +157,6 @@ describe("widget registry", () => { expect(superfluid).toMatchObject({ widgetId: "goodwidget.superfluid-campaign", packageName: "@goodwidget/superfluid-campaign-widget", - packageVersion: "0.1.5", routeSlug: "superfluid-campaign", entry: { tagName: "gw-superfluid-campaign" }, providerPolicy: { @@ -230,7 +229,6 @@ describe("widget registry", () => { expect(goodReserve).toMatchObject({ widgetId: "goodwidget.goodreserve", packageName: "@goodwidget/goodreserve-widget", - packageVersion: "0.1.7", routeSlug: "goodreserve", entry: { tagName: "gw-goodreserve-widget" }, providerPolicy: { @@ -283,7 +281,6 @@ describe("widget registry", () => { expect(streaming).toMatchObject({ widgetId: "goodwidget.streaming", packageName: "@goodwidget/streaming-widget", - packageVersion: "0.1.1", routeSlug: "streaming", entry: { tagName: "gw-streaming" }, providerPolicy: { diff --git a/src/widgets/registry.ts b/src/widgets/registry.ts index c7448ae..4bfe79b 100644 --- a/src/widgets/registry.ts +++ b/src/widgets/registry.ts @@ -161,7 +161,7 @@ export const createWidgetRegistry = ( const aiCreditsWidget = defineWidget({ widgetId: "goodwidget.ai-credits", packageName: "@goodwidget/ai-credits-widget", - packageVersion: "0.1.6", + packageVersion: "0.1.12", routeSlug: "ai-credits", displayName: "AI Credits", description: "Purchase AI compute credits with your G$ balance", @@ -214,7 +214,7 @@ const testFixtureWidget = defineWidget({ const superfluidCampaignWidget = defineWidget({ widgetId: "goodwidget.superfluid-campaign", packageName: "@goodwidget/superfluid-campaign-widget", - packageVersion: "0.1.5", + packageVersion: "0.1.7", routeSlug: "superfluid-campaign", displayName: "Superfluid Rewards", description: "Earn SUP rewards through GoodDollar and ecosystem actions", diff --git a/src/widgets/widgetVersions.test.ts b/src/widgets/widgetVersions.test.ts new file mode 100644 index 0000000..26f54bf --- /dev/null +++ b/src/widgets/widgetVersions.test.ts @@ -0,0 +1,66 @@ +import { readdirSync, readFileSync } from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +import { WIDGETS } from "./registry" + +/** + * A widget records its version in three places: the `package.json` dependency, + * `packageVersion` in the registry, and the Vitest register mocks. Nothing + * else keeps them in step, and the registry copy is a Custom Element + * registration cache key, so a stale one is a real defect rather than a + * cosmetic one. + * + * This makes the disagreement fail in CI instead of at runtime. + */ + +const repoRoot = process.cwd() + +const dependencies: Record = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), +).dependencies + +/** Not published; it only exists to host Playwright runs. */ +const UNPUBLISHED_PACKAGES = new Set(["@goodwidget/test-fixture"]) + +const installedWidgets = WIDGETS.filter( + (widget) => !UNPUBLISHED_PACKAGES.has(widget.packageName), +) + +describe("widget package versions", () => { + it.each( + installedWidgets.map((widget) => [widget.packageName, widget]), + )("%s is registered at the installed version", (packageName, widget) => { + expect(dependencies[packageName as string]).toBe(widget.packageVersion) + }) + + it("pins every widget dependency to an exact version", () => { + for (const widget of installedWidgets) { + expect(dependencies[widget.packageName]).toMatch(/^\d+\.\d+\.\d+$/) + } + }) + + it("keeps register mocks on the installed version", () => { + const fixturesDir = path.join(repoRoot, "src/widgets/fixtures") + const mocks = readdirSync(fixturesDir).filter((name) => + name.endsWith("RegisterMock.ts"), + ) + + for (const mock of mocks) { + const source = readFileSync(path.join(fixturesDir, mock), "utf8") + const declared = source.match( + /packageName:\s*"(@goodwidget\/[^"]+)",\s*\n\s*packageVersion:\s*"([^"]+)"/, + ) + expect(declared, `${mock} declares goodWidgetMetadata`).not.toBeNull() + if (!declared) continue + + const [, packageName, packageVersion] = declared + // A mock may outlive the registry entry it stood in for; only assert + // against packages still installed. + if (!dependencies[packageName]) continue + expect(packageVersion, `${mock} matches package.json`).toBe( + dependencies[packageName], + ) + } + }) +}) From 9cff1e83bee7ba82b47172aac752d05ecc0f0f1a Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 16 Oct 2026 13:53:39 -0400 Subject: [PATCH 2/2] feat: implement widget upgrade automation workflow and related documentation --- .github/workflows/widget-upgrade.yml | 120 +++++++++++++++ docs/widget-release-automation.md | 80 ++++++++++ package.json | 1 + scripts/widget-release/sync-version.mjs | 175 ++++++++++++++++++++++ src/widgets/widgetUpgradeWorkflow.test.ts | 54 +++++++ 5 files changed, 430 insertions(+) create mode 100644 .github/workflows/widget-upgrade.yml create mode 100644 docs/widget-release-automation.md create mode 100644 scripts/widget-release/sync-version.mjs create mode 100644 src/widgets/widgetUpgradeWorkflow.test.ts diff --git a/.github/workflows/widget-upgrade.yml b/.github/workflows/widget-upgrade.yml new file mode 100644 index 0000000..1b3b312 --- /dev/null +++ b/.github/workflows/widget-upgrade.yml @@ -0,0 +1,120 @@ +name: Upgrade widget + +# Manual widget upgrade. Pick a widget and the version to move it to; this +# opens a pull request with the bump, which then runs the normal checks and +# preview deployment. Nothing is merged automatically. +# +# The widget list mirrors src/widgets/registry.ts. A dropdown cannot be built +# at runtime, so the options are checked against the registry by +# src/widgets/widgetUpgradeWorkflow.test.ts — add a widget there and the test +# tells you to add it here. + +on: + workflow_dispatch: + inputs: + widget: + description: Widget to upgrade + required: true + type: choice + options: + - "@goodwidget/ai-credits-widget" + - "@goodwidget/goodreserve-widget" + - "@goodwidget/streaming-widget" + - "@goodwidget/superfluid-campaign-widget" + version: + description: Version to upgrade to, or "latest" + required: false + default: latest + type: string + +permissions: + contents: read + +concurrency: + group: widget-upgrade-${{ inputs.widget }} + cancel-in-progress: false + +jobs: + upgrade: + name: Open upgrade pull request + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: corepack enable + + - name: Run install + run: yarn install --immutable + + - name: Upgrade widget + id: upgrade + run: | + yarn node scripts/widget-release/sync-version.mjs \ + --package "${{ inputs.widget }}" \ + --version "${{ inputs.version }}" + + - name: Update lockfile + run: yarn install --no-immutable + + - name: Run Biome Fix + run: yarn biome:fix:all + + - name: Run Biome Check + run: yarn biome:verify:all + + - name: Run Tests + run: yarn test + + - name: Compose pull request body + id: body + env: + WIDGET: ${{ inputs.widget }} + VERSION: ${{ steps.upgrade.outputs.version }} + PREVIOUS: ${{ steps.upgrade.outputs.previous-version }} + CHANGED: ${{ steps.upgrade.outputs.changed-files }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + { + echo "Widget upgrade requested by @$ACTOR." + echo + echo "| | |" + echo "|---|---|" + echo "| Widget | \`$WIDGET\` |" + echo "| Version | \`$PREVIOUS\` -> \`$VERSION\` |" + echo + echo "Updated \`$CHANGED\` and \`yarn.lock\`." + echo + echo "Biome and the unit tests passed before this pull request was" + echo "opened. Preview deployment and Playwright run in the checks." + echo + echo "Route, icon, integration mode and providerPolicy are" + echo "unchanged - this is a version bump only." + } > "$RUNNER_TEMP/pr-body.md" + echo "path=$RUNNER_TEMP/pr-body.md" >> "$GITHUB_OUTPUT" + + - name: Open pull request + uses: peter-evans/create-pull-request@v7 + with: + # Pull requests opened with the default GITHUB_TOKEN do not trigger + # the pull_request workflow, so the bump would land with no preview + # deployment or Playwright run. + token: ${{ secrets.WIDGET_AUTOMATION_TOKEN || github.token }} + base: main + branch: bump/${{ steps.upgrade.outputs.branch-slug }}-${{ steps.upgrade.outputs.version }} + delete-branch: true + labels: widget-upgrade + commit-message: "chore: bump ${{ inputs.widget }} to version ${{ steps.upgrade.outputs.version }} in package.json and update references" + title: "chore: bump ${{ inputs.widget }} to version ${{ steps.upgrade.outputs.version }}" + body-path: ${{ steps.body.outputs.path }} diff --git a/docs/widget-release-automation.md b/docs/widget-release-automation.md new file mode 100644 index 0000000..44f6828 --- /dev/null +++ b/docs/widget-release-automation.md @@ -0,0 +1,80 @@ +# Upgrading a widget + +Widget upgrades are manual and deliberate: a developer picks the widget and the +version, and the workflow opens a pull request with the bump. Nothing is merged +automatically, and nothing upgrades on its own. + +## Running an upgrade + +Actions → **Upgrade widget** → *Run workflow*, then choose: + +- **Widget** — a dropdown of every registered widget +- **Version** — an exact version like `0.1.2`, or `latest` (the default) + +The run pins the version, refreshes `yarn.lock`, runs Biome and the unit tests, +and opens `bump/-`. The pull request then goes through the +normal checks and preview deployment like any other change. + +`latest` resolves to whatever npm currently tags latest. An explicit version +that is not published fails at the lockfile step, and no pull request opens. + +The widget list is a dropdown but versions are free text: GitHub renders the +run form from the workflow file before any code executes, so choices have to +be literals and cannot be fetched from npm. + +## What a bump touches + +A widget records its version in three places, and +`scripts/widget-release/sync-version.mjs` keeps them in step: + +- `package.json` — the exact dependency pin +- `src/widgets/registry.ts` — `RegisteredWidget.packageVersion` +- `src/widgets/fixtures/*RegisterMock.ts` — the mocked `goodWidgetMetadata` + +Route, icon, integration mode and `providerPolicy` are never touched. Those are +reviewed decisions that belong to whoever integrates the widget. + +Locally: + +```bash +yarn widgets:sync --package @goodwidget/streaming-widget --version 0.1.2 +yarn install && yarn test +``` + +The script only accepts an exact `major.minor.patch` version of a +`@goodwidget/*` package, and will not invent a registry entry for a package +that has none. + +## Guards + +Two tests keep the moving parts honest, both inside `yarn test`: + +- `src/widgets/widgetVersions.test.ts` — fails if `package.json`, the registry + and the register mocks ever disagree about a version. +- `src/widgets/widgetUpgradeWorkflow.test.ts` — fails if a registered widget is + missing from the workflow dropdown. A `workflow_dispatch` dropdown is static + YAML and cannot be generated at runtime, so this is what stops it going + stale. + +## Adding a new widget + +Registering a widget is a separate, reviewed change: add the dependency, a +`defineWidget` entry with its route, icon, Custom Element tag and provider +policy, a register mock plus its `vitest.config.ts` alias, and the entry in the +`WIDGETS` array. Add it to the workflow dropdown in the same pull request — the +guard test will remind you. + +## Before the first run + +Set **`WIDGET_AUTOMATION_TOKEN`** (`contents: write`, `pull-requests: write`). +Pull requests opened with the default `GITHUB_TOKEN` do not trigger +`pull_request` workflows, so the bump would land with no preview deployment or +Playwright run. The workflow falls back to `github.token`, but degraded. + +## Note on GoodWidget + +GoodWidget's release pipeline can dispatch a `goodwidget-released` event to +this repository. GoodWallet has no receiver for it — upgrades here are manual +by choice — so that dispatch currently goes nowhere. Leave +`ENABLE_GOODWALLET_DISPATCH` off on the GoodWidget side to avoid implying +otherwise. diff --git a/package.json b/package.json index c6f85b4..9ed9b7a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:e2e": "playwright test", "test:e2e:screenshots": "playwright test --update-snapshots", + "widgets:sync": "node scripts/widget-release/sync-version.mjs", "biome:verify:all": "biome check", "biome:fix:staged": "biome check --write --no-errors-on-unmatched --staged", "biome:fix:all": "biome check --write --no-errors-on-unmatched", diff --git a/scripts/widget-release/sync-version.mjs b/scripts/widget-release/sync-version.mjs new file mode 100644 index 0000000..b3150af --- /dev/null +++ b/scripts/widget-release/sync-version.mjs @@ -0,0 +1,175 @@ +/** + * Pins a `@goodwidget/*` package to an exact version across every file in this + * repo that records it, so the manifest and the widget registry cannot drift. + * + * Usage: + * node scripts/widget-release/sync-version.mjs --package @goodwidget/streaming-widget --version 0.1.2 + * node scripts/widget-release/sync-version.mjs --package @goodwidget/streaming-widget --version latest + * + * Touched files: + * - package.json exact dependency pin + * - src/widgets/registry.ts RegisteredWidget.packageVersion + * - src/widgets/fixtures/*RegisterMock.ts mocked goodWidgetMetadata + * + * `yarn.lock` is refreshed separately by the caller (`yarn install`). + * + * Exits 0 and reports `mode=new` when the package is not yet registered: that + * path needs a human-authored registry entry (route, icon, provider policy), + * so this script only adds the dependency and leaves the rest alone. + */ + +import { appendFileSync, readFileSync, writeFileSync } from "node:fs" +import { readdir } from "node:fs/promises" +import path from "node:path" + +const PACKAGE_NAME = /^@goodwidget\/[a-z0-9]+(?:-[a-z0-9]+)*$/ +// Mirrors EXACT_PACKAGE_VERSION in src/widgets/registry.ts. Prerelease tags are +// rejected there, so they are rejected here too rather than failing later. +const EXACT_VERSION = /^\d+\.\d+\.\d+$/ + +const repoRoot = process.cwd() +const fixturesDir = path.join(repoRoot, "src/widgets/fixtures") +const manifestPath = path.join(repoRoot, "package.json") +const registryPath = path.join(repoRoot, "src/widgets/registry.ts") + +const parseArgs = (argv) => { + const args = new Map() + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token.startsWith("--")) continue + const [flag, inlineValue] = token.slice(2).split("=") + args.set(flag, inlineValue ?? argv[++index]) + } + return args +} + +const fail = (message) => { + console.error(`sync-widget-version: ${message}`) + process.exit(1) +} + +const report = (entries) => { + for (const [key, value] of Object.entries(entries)) { + console.log(`${key}=${value}`) + } + if (!process.env.GITHUB_OUTPUT) return + const lines = Object.entries(entries).map(([key, value]) => `${key}=${value}`) + appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`) +} + +/** + * Rewrites `packageVersion` only where it sits directly beneath a matching + * `packageName`, which is the shape used by both the registry entries and the + * fixture mocks. Synthetic versions in unit tests are left untouched. + */ +const rewriteAdjacentVersion = (source, packageName, version) => { + const pattern = new RegExp( + `(packageName:\\s*"${packageName.replace("/", "\\/")}",\\s*\\n\\s*packageVersion:\\s*")([^"]+)(")`, + "g", + ) + let previous + const next = source.replace(pattern, (_match, head, found, tail) => { + previous = found + return `${head}${version}${tail}` + }) + // `found` means the package is declared here; `changed` means its recorded + // version actually moved. A package already at the target version is found + // but unchanged, and must not be mistaken for an unregistered one. + return { next, previous, found: previous !== undefined, changed: next !== source } +} + +/** `latest` is the default, so the common upgrade needs no version typed. */ +const resolveLatest = async (packageName) => { + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, + ) + if (!response.ok) fail(`npm lookup failed with ${response.status}`) + const metadata = await response.json() + const latest = metadata["dist-tags"]?.latest + if (!latest) fail(`${packageName} has no npm latest tag`) + console.log(`Resolved latest to ${packageName}@${latest}`) + return latest +} + +const main = async () => { + const args = parseArgs(process.argv.slice(2)) + const packageName = args.get("package") + const requested = (args.get("version") ?? "latest").trim() + + if (!packageName) fail("--package is required") + if (!PACKAGE_NAME.test(packageName)) { + fail(`refusing to sync ${packageName}: not a @goodwidget/* package`) + } + + const version = + requested === "latest" ? await resolveLatest(packageName) : requested + + if (!EXACT_VERSION.test(version)) { + fail( + `refusing to sync ${version}: the widget registry requires an exact major.minor.patch version`, + ) + } + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) + const previousDependency = manifest.dependencies?.[packageName] + const isNewPackage = previousDependency === undefined + + if (previousDependency === version) { + console.log(`${packageName} is already pinned to ${version}`) + } + + manifest.dependencies = Object.fromEntries( + Object.entries({ ...manifest.dependencies, [packageName]: version }).sort( + ([left], [right]) => left.localeCompare(right), + ), + ) + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + + const changedFiles = ["package.json"] + + const registry = rewriteAdjacentVersion( + readFileSync(registryPath, "utf8"), + packageName, + version, + ) + if (registry.changed) { + writeFileSync(registryPath, registry.next) + changedFiles.push("src/widgets/registry.ts") + } + + const fixtures = await readdir(fixturesDir) + for (const fixture of fixtures.filter((name) => + name.endsWith("RegisterMock.ts"), + )) { + const fixturePath = path.join(fixturesDir, fixture) + const mock = rewriteAdjacentVersion( + readFileSync(fixturePath, "utf8"), + packageName, + version, + ) + if (!mock.changed) continue + writeFileSync(fixturePath, mock.next) + changedFiles.push(path.relative(repoRoot, fixturePath)) + } + + report({ + mode: registry.found ? "update" : "new", + package: packageName, + // Matches the established bump/- branch convention. + "branch-slug": packageName.replace("@goodwidget/", ""), + version, + "previous-version": registry.previous ?? previousDependency ?? "none", + "is-new-dependency": String(isNewPackage), + "changed-files": changedFiles.join(","), + }) + + if (!registry.found) { + console.log( + `\n${packageName} has no entry in src/widgets/registry.ts.\n` + + "Added the dependency only — registering a new widget needs a route slug,\n" + + "icon, Custom Element tag and provider policy chosen by a human.", + ) + } +} + +await main() diff --git a/src/widgets/widgetUpgradeWorkflow.test.ts b/src/widgets/widgetUpgradeWorkflow.test.ts new file mode 100644 index 0000000..5c4f846 --- /dev/null +++ b/src/widgets/widgetUpgradeWorkflow.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +import { WIDGETS } from "./registry" + +/** + * GitHub builds a `workflow_dispatch` dropdown from static YAML, so the widget + * list in .github/workflows/widget-upgrade.yml cannot be generated from the + * registry at runtime. This asserts the two agree, so adding a widget without + * adding it to the dropdown fails here rather than leaving it unupgradeable. + */ + +/** Not published; it only exists to host Playwright runs. */ +const UNPUBLISHED_PACKAGES = new Set(["@goodwidget/test-fixture"]) + +const workflow = readFileSync( + path.join(process.cwd(), ".github/workflows/widget-upgrade.yml"), + "utf8", +) + +// Deliberately a narrow parse rather than a YAML dependency: the options block +// is a fixed shape, and the test should not pull in a transitive package. +const dropdownOptions = (() => { + const block = workflow.match(/options:\n((?:\s+- "@goodwidget\/[^"]+"\n)+)/) + if (!block) return [] + return [...block[1].matchAll(/- "(@goodwidget\/[^"]+)"/g)].map( + ([, name]) => name, + ) +})() + +const expectedPackages = [ + ...new Set( + WIDGETS.filter( + (widget) => !UNPUBLISHED_PACKAGES.has(widget.packageName), + ).map((widget) => widget.packageName), + ), +].sort() + +describe("widget upgrade workflow", () => { + it("offers every registered widget", () => { + expect([...dropdownOptions].sort()).toEqual(expectedPackages) + }) + + it("offers no widget that is not registered", () => { + for (const option of dropdownOptions) { + expect(expectedPackages).toContain(option) + } + }) + + it("lists the options in a stable order", () => { + expect(dropdownOptions).toEqual([...dropdownOptions].sort()) + }) +})