uppt#
A composite GitHub Action that turns conventional commits into a draft release PR, tags the PR on merge, and stages publishing to npm via OIDC trusted publishing.
The aim of uppt is to make a very simple, secure release workflow for maintainers which adheres to best security practices and doesn't require tokens or trusting a third-party GitHub App. It was extracted from scripts used in nuxt/nuxt.
Usage#
name: release
on:
push:
branches: [main]
pull_request_target:
types: [closed]
branches: [main]
# this is required to trigger releases when the release PR is merged, or to rerun a release if needed
workflow_dispatch:
permissions: {}
jobs:
# `pr` mode: parse commits since the last tag, push a `release/vX.Y.Z`
# branch, open or update a draft release PR, and close any superseded
# release PRs (e.g. `release/v1.0.1` when the bump is now `release/v1.1.0`).
pr:
if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
permissions:
contents: write # push the `release/vX.Y.Z` branch and delete superseded ones
pull-requests: write # create a release PR, update its body, close superseded PRs
steps:
- uses: danielroe/uppt@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
# `release` mode: the release PR was merged. Tag the squash commit, cut a
# GitHub release from the PR body, dispatch the publish workflow. The
# `release/v` head-ref guard is what keeps regular feature-PR merges from
# triggering a tag attempt.
release:
if: |
github.event_name == 'pull_request_target'
&& github.event.pull_request.merged == true
&& startsWith(github.event.pull_request.head.ref, 'release/v')
runs-on: ubuntu-latest
permissions:
contents: write # push the `vX.Y.Z` tag and create the GitHub release
actions: write # `gh workflow run release.yml --ref vX.Y.Z` chained dispatch
steps:
- uses: danielroe/uppt@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
# `publish` mode: the chained dispatch from `release` lands here as a
# `workflow_dispatch` event on a `vX.Y.Z` tag ref. Manual recovery uses
# the same path (Run workflow -> pick a `v*` tag).
publish:
if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read # checkout the tag
id-token: write # OIDC claim for npm trusted publisher
environment: npm # must match the trusted-publisher entry on npmjs.com
steps:
- uses: danielroe/uppt@main
IMPORTANT
Once you add this workflow, it is strongly recommended to run npx pin-github-action .github/workflows/release.yml to pin the action's version to a SHA.
Is pull_request_target safe here?#
pull_request_target is a well-known footgun, but is used safely in this action:
- It checks out the squash commit on the default branch, not the PR.
- It does not install dependencies or run anything from the codebase being released.
- It reads a single value from the codebase -
package.json#version- which is validated against a strict semver regex. - All subprocess calls use
execFileSyncwith argv arrays, and the generated PR body is passed as an env var and forwarded togh release create --notesas a single arg.
What it does#
Creates a PR (pr)#
Whenever you push to the default branch, this action parses conventional commits since the latest semver tag, decides the next bump (major, minor or patch) and creates a release/vX.Y.Z branch with the version bump, and opens or updates a draft PR against the base branch.
TIP
You can edit this PR to add your own release notes. Anything above ## 👉 Changelog is preserved when the changelog is updated.
Creates a release (release)#
When you merge a release PR, the action tags that commit, creates a GitHub Release using the PR body as notes, then dispatches the publish workflow on the new tag.
Stages a publish (publish)#
This runs pnpm pack (if you have a pnpm-lock.yaml) and then runs npm stage publish with OIDC authentication. The staged version then needs to be approved by a maintainer with 2FA on npmjs.com before it goes live.
Inputs#
| Input | Default | Description |
|---|---|---|
mode |
auto |
auto, pr, release, or publish. |
token |
${{ github.token }} |
Required for release; recommended for pr. Not used by publish. |
base-branch |
default branch | Base branch for the release PR. |
node-version |
24 |
Node version used for the scripts and for publish. Needs to support --experimental-strip-types (Node 22.6+, 24+ recommended). |
npm-access |
public |
npm access level (public or restricted). |
publish-workflow |
release.yml |
Workflow filename to dispatch after tagging. Must declare workflow_dispatch. |
checkout |
true |
Set to false if the caller has already checked out the right ref. |
Prerequisites#
For publish to work end to end you need:
- An npmjs.com trusted-publisher entry per package, pointing at the caller's
release.ymland thenpmenvironment, with thenpm stage publishpermission chip. - A GitHub environment named
npm(or whichever name you put on the publish job). - The package must already exist on npmjs.com;
npm stage publishcannot stage a brand-new package.
Credits#
Inspired by unjs/changelogen and antfu/changelogithub.
There are also a number of other actions and workflows you might want to check out, including:
License#
Made with ❤️
Published under MIT License.