Skip to content
Draft
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
331 changes: 331 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
name: Release

# Stages a release of @figma/code-connect to npm, then tags it and drafts a
# GitHub release.
#
# Deliberately manual: the commit being released is already on main by the time
# this runs, and cutting a release is a decision rather than a consequence of a
# push.
#
# This workflow only ever *stages* the npm publish. Its trusted publisher is
# configured stage-only, so `npm publish` from CI is rejected by the registry; a
# maintainer completes the release with `npm stage approve`, which requires 2FA
# and cannot be done with a CI token.
#
# Do not move the staging step into a reusable workflow: npm validates the
# *calling* workflow's filename against the trusted publisher, so `workflow_call`
# indirection breaks authentication. Renaming this file also breaks it.

on:
workflow_dispatch:
inputs:
version:
description: 'Version to release, e.g. 2.0.1. Asserted against cli/package.json; leave blank to use whatever is committed.'
required: false
type: string
dist_tag:
description: 'npm dist-tag to stage under.'
required: false
default: 'latest'
type: string
dry_run:
description: 'Run every check and pack the tarball, but do not stage, tag or draft a release.'
required: false
default: false
type: boolean

# Least privilege by default; each job opts in to what it needs.
permissions: {}

# Never let two releases interleave.
concurrency:
group: release
cancel-in-progress: false

jobs:
authorize:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
# The npm-release environment restricts deployments to main as well, but
# failing here gives a clearer error than a rejected deployment.
- name: Check ref
if: github.ref != 'refs/heads/main'
env:
REF: ${{ github.ref }}
run: |
echo "::error::Releases must be dispatched from main, got ${REF}."
exit 1

# Defence in depth. Triggering workflow_dispatch already requires write
# access, and the npm-release environment's required reviewers are the real
# gate — this just fails fast for someone who is not a release maintainer,
# rather than leaving a run queued for approval. Set the
# RELEASE_DISPATCHERS repo variable to a comma-separated list of GitHub
# usernames to enable it.
- name: Check actor
env:
DISPATCHERS: ${{ vars.RELEASE_DISPATCHERS }}
ACTOR: ${{ github.actor }}
run: |
if [ -z "${DISPATCHERS}" ]; then
echo "RELEASE_DISPATCHERS is not set; relying on the npm-release environment reviewers."
exit 0
fi
if ! printf '%s' "${DISPATCHERS}" | tr ',' '\n' | sed 's/[[:space:]]//g' | grep -qxF "${ACTOR}"; then
echo "::error::${ACTOR} is not in RELEASE_DISPATCHERS."
exit 1
fi
echo "${ACTOR} is an authorized release dispatcher."

- name: Checkout
uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0

- name: Resolve version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
run: |
VERSION=$(node -p "require('./cli/package.json').version")

if [ -n "${REQUESTED_VERSION}" ] && [ "${REQUESTED_VERSION}" != "${VERSION}" ]; then
echo "::error::Requested ${REQUESTED_VERSION} but cli/package.json is ${VERSION}."
exit 1
fi

echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Releasing @figma/code-connect@${VERSION}" >> "$GITHUB_STEP_SUMMARY"

verify:
needs: authorize
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0

- name: Setup node
uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22.22.0'

- name: Check the version is not already published
env:
VERSION: ${{ needs.authorize.outputs.version }}
run: |
if npm view "@figma/code-connect@${VERSION}" version >/dev/null 2>&1; then
echo "::error::@figma/code-connect@${VERSION} is already on npm. Bump the version and re-run the release."
exit 1
fi
echo "@figma/code-connect@${VERSION} is not yet published."

- name: Check the CHANGELOG
env:
VERSION: ${{ needs.authorize.outputs.version }}
run: |
if [ ! -f scripts/changelog-section.sh ]; then
echo "::error::scripts/changelog-section.sh is missing; cannot build the release notes."
exit 1
fi

NOTES=$(bash scripts/changelog-section.sh CHANGELOG.md "${VERSION}")
if [ -z "${NOTES}" ]; then
echo "::error::The CHANGELOG section for v${VERSION} is empty."
exit 1
fi

{
echo "### Release notes for v${VERSION}"
echo ""
printf '%s\n' "${NOTES}"
} >> "$GITHUB_STEP_SUMMARY"

test:
needs: authorize
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
# 20.18.1 is the real floor: undici@^7 requires it, even though
# package.json still advertises engines.node ">=18".
node-version: ['20.18.1', '22.22.0']
steps:
- name: Checkout
uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0

- name: Setup node ${{ matrix.node-version }}
uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: ${{ matrix.node-version }}

# There is no committed lockfile, so `npm install` rather than `npm ci`.
- name: Install
run: npm install
working-directory: cli

- name: Typecheck
run: npm run typecheck
working-directory: cli

- name: Test
run: npm run test:ci
working-directory: cli

- name: Check the package builds and packs
run: npm run bundle:npm
working-directory: cli

stage:
needs: [authorize, verify, test]
runs-on: ubuntu-latest
# Required reviewers on this environment are what authorize a release. npm's
# trusted publisher is pinned to this environment name too, so its OIDC
# token claim is checked registry-side — a run that bypassed this gate
# cannot stage.
environment: npm-release
permissions:
contents: read
id-token: write # REQUIRED for trusted publishing
outputs:
staged: ${{ steps.stage.outputs.staged }}
steps:
- name: Checkout
uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0

- name: Setup node
uses: figma/actions-setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22.22.0'
registry-url: 'https://registry.npmjs.org'

# Staged publishing needs npm >= 11.15.0, which is newer than the npm
# bundled with Node 22.
- name: Setup npm
run: |
REQUIRED=11.15.0
npm install -g "npm@^${REQUIRED}"
NPM_VERSION="$(npm --version)"
echo "npm ${NPM_VERSION}"
if [ "$(printf '%s\n%s\n' "${REQUIRED}" "${NPM_VERSION}" | sort -V | head -1)" != "${REQUIRED}" ]; then
echo "::error::npm ${NPM_VERSION} is too old for staged publishing; need >= ${REQUIRED}."
exit 1
fi

- name: Install
run: npm install
working-directory: cli

- name: Build
run: npm run build
working-directory: cli

# The published README is the repo root one, not cli/README.md. Swapping it
# in has to happen before packing, and has to be undone afterwards even on
# failure, hence the separate always() step below.
- name: Swap in the npm README
run: npm run bundle:npm-readme:prepare
working-directory: cli

- name: Pack (dry run)
if: inputs.dry_run
run: |
npm pack --dry-run
echo "Dry run: nothing was staged." >> "$GITHUB_STEP_SUMMARY"
working-directory: cli

# Not routed through an npm script: `publish:npm` chains the README
# restore with `;`, which masks a failing publish behind the restore's exit
# code. Here a failure has to fail the job.
- name: Stage the publish
id: stage
if: ${{ !inputs.dry_run }}
env:
NPM_CONFIG_TAG: ${{ inputs.dist_tag }}
run: |
npm stage publish --access public --provenance
echo "staged=true" >> "$GITHUB_OUTPUT"
working-directory: cli

- name: Restore the repo README
if: always()
run: npm run bundle:npm-readme:restore
working-directory: cli

- name: Summarise
if: ${{ !inputs.dry_run }}
env:
VERSION: ${{ needs.authorize.outputs.version }}
run: |
{
echo "### Staged @figma/code-connect@${VERSION}"
echo ""
echo "Not published yet. A maintainer must approve it with 2FA:"
echo ""
echo '```sh'
echo "npm stage list @figma/code-connect"
echo "npm stage download <stage-id> # optional, inspect the tarball"
echo "npm stage approve <stage-id> # prompts for 2FA"
echo '```'
echo ""
echo "Or use the **Staged Packages** tab on"
echo "<https://www.npmjs.com/package/@figma/code-connect>."
echo ""
echo "Then publish the draft GitHub release."
} >> "$GITHUB_STEP_SUMMARY"

tag-and-release:
needs: [authorize, stage]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
contents: write # push the tag, create the release
steps:
- name: Checkout
uses: figma/actions-checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0

- name: Tag the release
env:
VERSION: ${{ needs.authorize.outputs.version }}
GIT_AUTHOR_NAME: figma-bot
GIT_AUTHOR_EMAIL: security@figma.com
GIT_COMMITTER_NAME: figma-bot
GIT_COMMITTER_EMAIL: security@figma.com
run: |
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "Tag v${VERSION} already exists, leaving it alone."
exit 0
fi
git tag -a "v${VERSION}" -m "Code Connect v${VERSION}"
git push origin "v${VERSION}"

# Draft, not published: the release is only real once the staged npm
# publish has been approved, and that is a separate human action.
- name: Draft the GitHub release
env:
VERSION: ${{ needs.authorize.outputs.version }}
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "v${VERSION}" >/dev/null 2>&1; then
echo "Release v${VERSION} already exists, leaving it alone."
exit 0
fi

bash scripts/changelog-section.sh CHANGELOG.md "${VERSION}" > /tmp/release-notes.md

gh release create "v${VERSION}" \
--draft \
--title "Code Connect ${VERSION}" \
--notes-file /tmp/release-notes.md

{
echo "### Drafted release v${VERSION}"
echo ""
echo "Publish it once the npm publish is approved:"
echo "<${{ github.server_url }}/${{ github.repository }}/releases>"
} >> "$GITHUB_STEP_SUMMARY"