Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/widget-upgrade.yml
Original file line number Diff line number Diff line change
@@ -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 }}
80 changes: 80 additions & 0 deletions docs/widget-release-automation.md
Original file line number Diff line number Diff line change
@@ -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/<package>-<version>`. 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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
175 changes: 175 additions & 0 deletions scripts/widget-release/sync-version.mjs
Original file line number Diff line number Diff line change
@@ -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/<widget>-<version> 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()
2 changes: 1 addition & 1 deletion src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
Loading
Loading