From ffd98bcab36f82503f4eb6b982ba1637a2a86c62 Mon Sep 17 00:00:00 2001 From: Alex Garbutt Date: Wed, 16 Sep 2026 22:26:16 +0000 Subject: [PATCH] plugin-typings: Add staged npm release workflows --- .github/RELEASING.md | 26 ++++ .github/workflows/finalize.yml | 48 +++++++ .github/workflows/release.yml | 77 ++++++++++ .github/workflows/test.yml | 29 ++-- .gitignore | 3 +- package-lock.json | 47 +++++++ package.json | 13 +- scripts/release.mjs | 247 +++++++++++++++++++++++++++++++++ scripts/release.test.mjs | 67 +++++++++ test-usage.sh | 18 ++- 10 files changed, 550 insertions(+), 25 deletions(-) create mode 100644 .github/RELEASING.md create mode 100644 .github/workflows/finalize.yml create mode 100644 .github/workflows/release.yml create mode 100644 package-lock.json create mode 100644 scripts/release.mjs create mode 100644 scripts/release.test.mjs diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000..4912a40 --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,26 @@ +# Releasing @figma/plugin-typings + +Prepare the package version and `releases/X.Y.Z.md` notes in a reviewed pull request. +Confirm the API changes are available to users before releasing. + +1. Run `npm ci --ignore-scripts` and `npm run release:check`. This packs and tests + the package without publishing. CI runs the same checks on pull requests. +2. After review and merge, run Release (`release.yml`) on `main` with the exact + version and `dry_run=true`. Inspect the package artifact and test results. +3. When ready, run Release again with `dry_run=false`. A release maintainer + approves the `npm-release` environment. The workflow stages the tested tarball. +4. A package maintainer reviews the staged package on npm and completes approval + with 2FA. Record the successful Release run ID. If staging succeeded but a later + step failed, inspect the existing npm stage before retrying. +5. Once the version is live, run Finalize Release (`finalize.yml`) on `main` with + the version and Release run ID. It checks the npm package, provenance metadata, + tarball integrity, and source commit before creating `vX.Y.Z` and the release. + +Do not rename `release.yml` or its `npm-release` environment: npm trusted publishing +uses both identifiers. Staging runs directly in this workflow. + +Finalization can be retried after a partial failure. It accepts an existing tag +only at the same commit and leaves an existing published release unchanged. +Artifacts expire after 90 days; finish the release within that window. If an +artifact is unavailable or verification fails, stop and ask a maintainer to +investigate. Do not move an existing version tag. diff --git a/.github/workflows/finalize.yml b/.github/workflows/finalize.yml new file mode 100644 index 0000000..5ae2204 --- /dev/null +++ b/.github/workflows/finalize.yml @@ -0,0 +1,48 @@ +name: Finalize Release +on: + workflow_dispatch: + inputs: + version: + description: 'Published version (X.Y.Z)' + required: true + type: string + release_run_id: + description: 'Successful Release workflow run that staged this version' + required: true + type: string +permissions: + contents: read +concurrency: + group: npm-release + cancel-in-progress: false +jobs: + finalize: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + actions: read + steps: + - uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22.22.0' + - name: Download release artifact + env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + run: | + [[ "$RELEASE_RUN_ID" =~ ^[0-9]+$ ]] + ATTEMPT=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID" --jq .run_attempt) + [[ "$ATTEMPT" =~ ^[0-9]+$ ]] + gh run download "$RELEASE_RUN_ID" --repo "$GITHUB_REPOSITORY" --name "npm-package-$RELEASE_RUN_ID-$ATTEMPT" --dir .release + - name: Verify publication and finalize + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + run: node scripts/release.mjs finalize diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cca78e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release +on: + workflow_dispatch: + inputs: + version: + description: 'Exact package version (X.Y.Z)' + required: true + type: string + dry_run: + description: 'Test and pack only' + required: true + default: true + type: boolean +permissions: + contents: read +concurrency: + group: npm-release + cancel-in-progress: false +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check release ref + if: ${{ !inputs.dry_run && github.ref != 'refs/heads/main' }} + run: exit 1 + - uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + - uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22.22.0' + - run: npm ci --ignore-scripts + - run: npm run release:check + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_DRY_RUN: ${{ inputs.dry_run }} + - name: Check version availability + if: ${{ !inputs.dry_run }} + run: node scripts/release.mjs available + - uses: figma/actions-upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-package-${{ github.run_id }}-${{ github.run_attempt }} + path: | + .release/package.tgz + .release/release.json + .release/notes.md + include-hidden-files: true + if-no-files-found: error + retention-days: 90 + stage: + needs: prepare + if: ${{ !inputs.dry_run && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm-release + permissions: + contents: read + id-token: write + steps: + - uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + - uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22.22.0' + - uses: figma/actions-download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + with: + name: npm-package-${{ github.run_id }}-${{ github.run_attempt }} + path: .release/ + - run: npm install --global npm@11.15.0 --ignore-scripts --registry=https://registry.npmjs.org + - run: node scripts/release.mjs verify + - run: node scripts/release.mjs available + - name: Stage package + run: | + npm stage publish .release/package.tgz --ignore-scripts --access public --provenance --tag latest --registry=https://registry.npmjs.org + echo 'Package staged. Awaiting maintainer approval on npm.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f80b0d3..5e3853d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,18 +1,19 @@ name: CI -on: [push] +on: [push, pull_request] +permissions: + contents: read jobs: - test-typings-compile: + test-package: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - name: Check out repository code - uses: actions/checkout@v4 - - run: npm install typescript - - run: npm test - - run: npm run prettier:check - - test-usage-script: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v4 - - run: ./test-usage.sh + - uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + with: + persist-credentials: false + - uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22.22.0' + - run: npm ci --ignore-scripts + - run: npm run release:check + env: + RELEASE_DRY_RUN: 'true' diff --git a/.gitignore b/.gitignore index 17574ab..dc3c278 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules -package-lock.json test-artifacts + +.release/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9cf794e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@figma/plugin-typings", + "version": "1.138.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@figma/plugin-typings", + "version": "1.138.0", + "license": "MIT License", + "devDependencies": { + "prettier": "3.9.3", + "typescript": "^5.9.3" + } + }, + "node_modules/prettier": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz", + "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json index 8fc495f..c56b12e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "prettier:check": "npm run prettier-path:check -- '**/*.{ts,js}'", "prettier-path": "prettier --write", "prettier-path:check": "prettier --check", - "test": "tsc" + "test": "tsc", + "release:check": "node --test scripts/release.test.mjs && node scripts/release.mjs pack && bash test-usage.sh .release/package.tgz && npm run prettier:check" }, "repository": { "type": "git", @@ -26,6 +27,12 @@ }, "homepage": "https://github.com/figma/plugin-typings#readme", "devDependencies": { - "prettier": "3.9.3" - } + "prettier": "3.9.3", + "typescript": "^5.9.3" + }, + "files": [ + "index.d.ts", + "plugin-api.d.ts", + "plugin-api-standalone.d.ts" + ] } diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..a28c5e6 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const registry = 'https://registry.npmjs.org' +const run = (command, args, options = {}) => + execFileSync(command, args, { encoding: 'utf8', ...options }).trim() +const json = (file) => JSON.parse(fs.readFileSync(file, 'utf8')) +const integrity = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}` +const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function validateVersion(version) { + assert.equal(version, version.trim()) + assert.match(version, versionPattern, 'Use an exact X.Y.Z version') +} + +export function validatePublication(record, published) { + validateVersion(record.version) + assert.equal(record.dryRun, false, 'A rehearsal cannot finalize') + assert.match(record.commit, /^[a-f0-9]{40}$/) + assert.equal(published.name, record.package, 'Published package differs') + assert.equal(published.version, record.version, 'Published version differs') + assert.equal(published.gitHead, record.commit, 'Published commit differs') + assert.equal(published.dist?.integrity, record.integrity, 'Published tarball differs') + assert.equal( + published.dist?.attestations?.provenance?.predicateType, + 'https://slsa.dev/provenance/v1', + 'Published provenance is missing', + ) +} + +export function validateRun(record, workflowRun, repository) { + assert.equal(workflowRun.repository.full_name, repository) + assert.equal(workflowRun.path, '.github/workflows/release.yml') + assert.equal(workflowRun.event, 'workflow_dispatch') + assert.equal(workflowRun.head_branch, 'main') + assert.equal(workflowRun.status, 'completed') + assert.equal(workflowRun.conclusion, 'success') + assert.equal(workflowRun.head_sha, record.commit) +} + +function readArtifact() { + const record = json('.release/release.json') + const pkg = json('package.json') + assert.equal(record.package, pkg.name) + validateVersion(record.version) + assert.match(record.commit, /^[a-f0-9]{40}$/) + assert.equal( + integrity(fs.readFileSync('.release/package.tgz')), + record.integrity, + 'Artifact checksum differs', + ) + const packed = JSON.parse(run('tar', ['-xOf', '.release/package.tgz', 'package/package.json'])) + assert.equal(packed.name, record.package) + assert.equal(packed.version, record.version) + assert.equal(packed.gitHead, record.commit) + assert.equal( + packed.repository.url, + `git+https://github.com/${process.env.GITHUB_REPOSITORY || pkg.repository.url.match(/github.com\/(.+)\.git$/)[1]}.git`, + ) + return record +} + +function pack() { + const pkg = json('package.json') + validateVersion(pkg.version) + assert(!pkg.private) + if (process.env.RELEASE_VERSION) + assert.equal(pkg.version, process.env.RELEASE_VERSION, 'Requested version differs') + const commit = run('git', ['rev-parse', 'HEAD']) + assert.match(commit, /^[a-f0-9]{40}$/) + if (process.env.RELEASE_DRY_RUN === 'false') + assert.equal( + run('git', ['diff', 'HEAD', '--', ...pkg.files, 'package.json', 'package-lock.json']), + '', + 'Commit package changes before packing', + ) + const notes = `releases/${pkg.version}.md` + if (process.env.RELEASE_DRY_RUN === 'false') { + assert( + fs.existsSync(notes) && fs.readFileSync(notes, 'utf8').trim(), + 'Commit release notes before staging', + ) + assert.equal(run('git', ['diff', 'HEAD', '--', notes]), '') + run('git', ['ls-files', '--error-unmatch', notes]) + } + fs.mkdirSync('.release', { recursive: true }) + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-release-')) + try { + for (const file of [...pkg.files, 'README.md', 'LICENSE']) { + assert(!path.isAbsolute(file) && !file.split('/').includes('..')) + fs.mkdirSync(path.dirname(path.join(temporary, file)), { recursive: true }) + fs.copyFileSync(file, path.join(temporary, file)) + } + fs.writeFileSync( + path.join(temporary, 'package.json'), + JSON.stringify({ ...pkg, gitHead: commit }, null, 2) + '\n', + ) + const packed = JSON.parse( + run( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', path.resolve('.release')], + { cwd: temporary }, + ), + )[0] + const expected = [...pkg.files, 'README.md', 'LICENSE', 'package.json'].sort() + assert.deepEqual( + packed.files.map((file) => file.path).sort(), + expected, + 'Unexpected package contents', + ) + fs.renameSync(path.join('.release', packed.filename), '.release/package.tgz') + const record = { + dryRun: process.env.RELEASE_DRY_RUN !== 'false', + package: pkg.name, + version: pkg.version, + commit, + integrity: integrity(fs.readFileSync('.release/package.tgz')), + } + fs.writeFileSync('.release/release.json', JSON.stringify(record, null, 2) + '\n') + fs.writeFileSync( + '.release/notes.md', + fs.existsSync(notes) ? fs.readFileSync(notes) : `${pkg.description}\n`, + ) + console.log(JSON.stringify(record, null, 2)) + } finally { + fs.rmSync(temporary, { recursive: true, force: true }) + } +} + +async function publication(record) { + const response = await fetch( + `${registry}/${encodeURIComponent(record.package)}/${record.version}`, + { signal: AbortSignal.timeout(30000) }, + ) + assert(response.ok, 'Published package could not be verified') + validatePublication(record, await response.json()) +} + +async function available() { + const record = readArtifact() + assert.equal(record.dryRun, false) + assert.equal(record.commit, process.env.GITHUB_SHA) + repository() + const response = await fetch( + `${registry}/${encodeURIComponent(record.package)}/${record.version}`, + { signal: AbortSignal.timeout(30000) }, + ) + assert.equal(response.status, 404, 'Version is already published or registry verification failed') +} + +function github(endpoint, args = []) { + try { + return JSON.parse(run('gh', ['api', endpoint, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })) + } catch (error) { + if (error.status === 1 && /\(HTTP 404\)/.test(String(error.stderr))) return null + throw error + } +} + +function repository() { + const repo = process.env.GITHUB_REPOSITORY + assert.match(repo, /^figma\/(plugin-typings|widget-typings|rest-api-spec)$/) + assert.equal(process.env.GITHUB_REF, 'refs/heads/main') + return repo +} + +async function finalize() { + const repo = repository() + const record = readArtifact() + assert.equal(record.version, process.env.RELEASE_VERSION) + const id = process.env.RELEASE_RUN_ID + assert.match(id, /^\d+$/) + const workflowRun = github(`repos/${repo}/actions/runs/${id}`) + validateRun(record, workflowRun, repo) + run('git', ['merge-base', '--is-ancestor', record.commit, 'origin/main']) + const sourcePackage = JSON.parse(run('git', ['show', `${record.commit}:package.json`])) + assert.equal(sourcePackage.name, record.package) + assert.equal(sourcePackage.version, record.version) + await publication(record) + const tag = `v${record.version}` + const ref = github(`repos/${repo}/git/ref/tags/${tag}`) + if (ref) { + run('git', ['fetch', 'origin', `refs/tags/${tag}`]) + assert.equal( + run('git', ['rev-parse', 'FETCH_HEAD^{commit}']), + record.commit, + 'Existing tag points elsewhere', + ) + } else { + github(`repos/${repo}/git/refs`, [ + '--method', + 'POST', + '-f', + `ref=refs/tags/${tag}`, + '-f', + `sha=${record.commit}`, + ]) + } + const release = github(`repos/${repo}/releases/tags/${tag}`) + if (release) { + assert.equal(release.draft, false, 'Existing release is a draft; review it manually') + assert.equal(release.prerelease, false, 'Existing release is a prerelease; review it manually') + console.log(release.html_url) + } else { + console.log( + run('gh', [ + 'release', + 'create', + tag, + '--repo', + repo, + '--verify-tag', + '--title', + `${record.package} ${record.version}`, + '--notes-file', + '.release/notes.md', + ]), + ) + } +} + +async function main() { + switch (process.argv[2]) { + case 'pack': + return pack() + case 'verify': + return readArtifact() + case 'available': + return available() + case 'finalize': + return finalize() + default: + throw new Error('Expected pack, verify, available, or finalize') + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 0000000..50d9850 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { validatePublication, validateRun, validateVersion } from './release.mjs' + +const record = { + dryRun: false, + package: '@figma/example', + version: '1.2.3', + commit: 'a'.repeat(40), + integrity: 'sha512-example', +} +const publication = { + name: record.package, + version: record.version, + gitHead: record.commit, + dist: { + integrity: record.integrity, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + }, +} +const workflowRun = { + repository: { full_name: 'figma/example' }, + path: '.github/workflows/release.yml', + event: 'workflow_dispatch', + head_branch: 'main', + status: 'completed', + conclusion: 'success', + head_sha: record.commit, +} + +test('published artifact is accepted after approval', () => { + validatePublication(record, publication) + validateRun(record, workflowRun, 'figma/example') +}) +test('unpublished and mismatched packages cannot finalize', () => { + for (const change of [ + { name: '@other/example' }, + { version: '1.2.4' }, + { gitHead: 'b'.repeat(40) }, + { dist: { ...publication.dist, integrity: 'sha512-changed' } }, + { dist: { integrity: record.integrity } }, + ]) { + assert.throws(() => validatePublication(record, { ...publication, ...change })) + } + assert.throws(() => validatePublication(record, {})) +}) +test('failed, unrelated and branch runs cannot finalize', () => { + for (const change of [ + { path: '.github/workflows/test.yml' }, + { event: 'pull_request' }, + { head_branch: 'other' }, + { conclusion: 'failure' }, + { status: 'in_progress' }, + { head_sha: 'b'.repeat(40) }, + { repository: { full_name: 'other/example' } }, + ]) { + assert.throws(() => validateRun(record, { ...workflowRun, ...change }, 'figma/example')) + } +}) +test('ranges and dist-tags are not release versions', () => { + for (const version of ['latest', '^1.2.3', '1.2', '01.2.3', '1.2.3\n', '1.2.3;echo bad']) + assert.throws(() => validateVersion(version)) +}) + +test('a rehearsal cannot authorize finalization', () => { + assert.throws(() => validatePublication({ ...record, dryRun: true }, publication)) +}) diff --git a/test-usage.sh b/test-usage.sh index faf69a0..3991995 100755 --- a/test-usage.sh +++ b/test-usage.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash -set -xueo pipefail +set -euo pipefail + +ROOT=$(pwd) +PACKAGE_TARBALL=$(realpath "${1:?Pass the packed package to test}") +TSC="$ROOT/node_modules/.bin/tsc" rm -rf test-artifacts mkdir -p test-artifacts @@ -147,9 +151,9 @@ function testParameters() { EOF -npm install typescript@5 -npm install ../../ -npx tsc --noEmit +mkdir -p node_modules/@figma/plugin-typings +tar -xzf "$PACKAGE_TARBALL" --strip-components=1 -C node_modules/@figma/plugin-typings +"$TSC" --noEmit popd @@ -198,8 +202,8 @@ const gradient: GradientPaint = { type VectorAlias = Vector EOF -npm install typescript@5 -npm install ../../ -npx tsc --noEmit +mkdir -p node_modules/@figma/plugin-typings +tar -xzf "$PACKAGE_TARBALL" --strip-components=1 -C node_modules/@figma/plugin-typings +"$TSC" --noEmit popd