diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 117754a..8353184 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,35 @@ -name: Publish to npm +name: Publish on: - release: - types: [published] + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'npm dist-tag (alpha, beta, next, latest)' + default: 'alpha' + required: true jobs: publish: - name: Publish to npm + name: Publish all @tabmesh/* packages runs-on: ubuntu-latest + + # OIDC trusted publishing — npm exchanges the OIDC token for a + # short-lived publish credential. No long-lived NPM_TOKEN secret. + # See: https://docs.npmjs.com/trusted-publishers permissions: - contents: read id-token: write + contents: read + steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 with: version: 9 + - uses: actions/setup-node@v4 with: node-version: 20 @@ -25,13 +39,65 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run tests - run: pnpm test + - name: Verify tag matches package versions + if: github.event_name == 'push' + run: | + TAG="${GITHUB_REF_NAME#v}" + CORE=$(node -p "require('./packages/core/package.json').version") + REACT=$(node -p "require('./packages/react/package.json').version") + WS=$(node -p "require('./packages/transport-websocket/package.json').version") + echo "Tag: $TAG" + echo "@tabmesh/core: $CORE" + echo "@tabmesh/react: $REACT" + echo "@tabmesh/transport-websocket: $WS" + if [ "$TAG" != "$CORE" ] || [ "$TAG" != "$REACT" ] || [ "$TAG" != "$WS" ]; then + echo "::error::Tag does not match all three package.json versions" + exit 1 + fi + + - name: Build all packages + run: pnpm -r build + + - name: Run unit tests + run: pnpm exec vitest --run + + - name: Determine dist-tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + VER=$(node -p "require('./packages/core/package.json').version") + case "$VER" in + *"-alpha."*) echo "tag=alpha" >> "$GITHUB_OUTPUT" ;; + *"-beta."*) echo "tag=beta" >> "$GITHUB_OUTPUT" ;; + *"-rc."*|*"-next."*) echo "tag=next" >> "$GITHUB_OUTPUT" ;; + *) echo "tag=latest" >> "$GITHUB_OUTPUT" ;; + esac + fi + + # Publish in dependency order so the registry resolves @tabmesh/core + # before the dependents are fetched. + - name: Publish @tabmesh/core + run: > + pnpm --filter @tabmesh/core publish + --tag ${{ steps.tag.outputs.tag }} + --access public + --no-git-checks + --provenance - - name: Build packages - run: pnpm build + - name: Publish @tabmesh/transport-websocket + run: > + pnpm --filter @tabmesh/transport-websocket publish + --tag ${{ steps.tag.outputs.tag }} + --access public + --no-git-checks + --provenance - - name: Publish to npm - run: pnpm publish -r --access public --no-git-checks - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish @tabmesh/react + run: > + pnpm --filter @tabmesh/react publish + --tag ${{ steps.tag.outputs.tag }} + --access public + --no-git-checks + --provenance diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..5d7a29b --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Release helper. Bumps all three publishable @tabmesh/* package.json + * versions in lockstep, keeps their peer-dependency on @tabmesh/core + * pinned to the same version, and tags the result. + * + * Usage: + * node scripts/release.mjs 0.1.0-alpha.0 + * + * Then push the tag to trigger .github/workflows/publish.yml: + * git push origin main --tags + * + * The workflow re-validates that the tag matches the three package + * versions, then publishes via npm trusted publishing (OIDC, no + * NPM_TOKEN). The dist-tag is inferred from the version suffix: + * x.y.z-alpha.n -> alpha + * x.y.z-beta.n -> beta + * x.y.z-rc.n -> next + * x.y.z -> latest + * + * Idempotency: re-running with the same version is a no-op for files + * but will fail at `git commit` (nothing to commit) and `git tag` + * (tag exists). That's intentional. + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+(-[a-z]+\.\d+)?$/.test(version)) { + console.error('Usage: node scripts/release.mjs '); + console.error('Examples: 0.1.0-alpha.0, 0.1.0-beta.1, 0.1.0, 1.0.0-rc.0'); + process.exit(1); +} + +const packages = [ + { name: '@tabmesh/core', dir: 'packages/core', peerOfCore: false }, + { name: '@tabmesh/react', dir: 'packages/react', peerOfCore: true }, + { + name: '@tabmesh/transport-websocket', + dir: 'packages/transport-websocket', + peerOfCore: true, + }, +]; + +for (const pkg of packages) { + const path = resolve(pkg.dir, 'package.json'); + const json = JSON.parse(readFileSync(path, 'utf8')); + json.version = version; + // Pin the peer-dep on @tabmesh/core to the exact version so consumers + // can't end up with a mixed-version install. We're pre-1.0; protocol + // changes between alphas should force an all-three upgrade. + if (pkg.peerOfCore && json.peerDependencies?.['@tabmesh/core']) { + json.peerDependencies['@tabmesh/core'] = version; + } + writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`); + console.log(`bumped ${pkg.name} → ${version}`); +} + +// Commit and tag in one shot. Don't push — that's intentional, gives a +// chance to review `git show` before the publish workflow fires. +execSync(`git add ${packages.map((p) => `${p.dir}/package.json`).join(' ')}`); +execSync(`git commit -m "chore(release): v${version}"`); +execSync(`git tag v${version}`); + +console.log(''); +console.log(`Tagged v${version}. To publish, push the tag:`); +console.log(' git push origin main --tags'); +console.log(''); +console.log('The publish workflow will:'); +console.log(` 1. Verify tag v${version} matches all three package versions`); +console.log(' 2. Build all packages'); +console.log(' 3. Run unit tests'); +console.log( + ` 4. Publish all three with --tag ${version.includes('-alpha.') ? 'alpha' : version.includes('-beta.') ? 'beta' : version.includes('-rc.') || version.includes('-next.') ? 'next' : 'latest'} (inferred from suffix)` +); +console.log(' 5. Each publish carries npm provenance (OIDC trusted publishing)');