diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8320711 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 # optional, inspect the tarball" + echo "npm stage approve # prompts for 2FA" + echo '```' + echo "" + echo "Or use the **Staged Packages** tab on" + echo "." + 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"