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 a simple, secure release workflow that doesn't require tokens or trusting a third-party GitHub App. It was extracted from scripts used in nuxt/nuxt.
Getting started #
-
Set up trusted publishing on npmjs.com. Visit
https://npmjs.com/package/<package-name>/accessand add a trusted publisher pointing at your repo and therelease.ymlworkflow, with thenpm stage publishpermission chip and 'Environment name' set tonpm. In a monorepo, repeat once per published package.The package must already exist on npm; for a brand-new package,
npx setup-trusted-publishing(docs) publishes a0.0.0stub you can attach the entry to.
Note
Staged publishing means you approve each publish (with 2FA) before it goes live. It's also recommended to set "Require two-factor authentication and disallow tokens."
Screenshot
-
Create a GitHub environment named
npm, with whatever restrictions you like (such as required approvals). If you limit which refs can deploy to it, allow bothv*andrelease-*tags.Screenshot
-
Allow GitHub Actions to create pull requests: check Allow GitHub Actions to create and approve pull requests under Settings → Actions → General → Workflow permissions. Without it,
uppt/prwon't be able to open the release PR. -
Add the workflow below as
.github/workflows/release.yml, and you're done!
Tip
@e18e/setup-publish can scaffold this file: run npx @e18e/setup-publish and pick the uppt template (pass --env npm to match step 1).
name: release
on:
push:
# release/v* picks up hand edits to an open release PR
branches: [main, 'release/v*']
pull_request:
types: [closed]
branches: [main]
# merged release PRs, publish reruns, and prereleases all arrive here
workflow_dispatch:
inputs:
prerelease:
description: 'Cut a prerelease instead of a normal release, e.g. `beta`, `rc`, or `0`'
required: false
default: ''
npm-tag:
description: 'npm dist-tag to publish to. Set by uppt/release for maintenance lines; leave blank otherwise'
required: false
default: ''
permissions: {}
jobs:
# open or update a draft release PR from conventional commits
pr:
if: |
!github.event.repository.fork
&& (
(
github.event_name == 'push'
&& (
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|| startsWith(github.ref, 'refs/heads/release/v')
)
) || (
github.event_name == 'workflow_dispatch'
&& !startsWith(github.ref, 'refs/tags/')
)
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: danielroe/uppt/pr@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ inputs.prerelease }}
# release PR merged: tag it, cut a GitHub release, dispatch the publish run
release:
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
&& startsWith(github.event.pull_request.head.ref, 'release/v')
&& github.event.pull_request.head.repo.full_name == github.repository
&& !github.event.repository.fork
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: write
actions: write
steps:
- uses: danielroe/uppt/release@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
token: ${{ secrets.GITHUB_TOKEN }}
# the chained dispatch lands here on the vX.Y.Z tag; build the tarball(s)
pack:
if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
concurrency:
group: pack-${{ github.ref }}
cancel-in-progress: false
permissions: {}
outputs:
files: ${{ steps.pack.outputs.files }}
steps:
- id: pack
uses: danielroe/uppt/pack@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
# stage the prebuilt tarball(s) to npm via OIDC
publish:
if: |
github.event_name == 'workflow_dispatch'
&& startsWith(github.ref, 'refs/tags/v')
&& needs.pack.outputs.files != '[]'
needs: pack
runs-on: ubuntu-latest
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write
environment: npm
steps:
- uses: danielroe/uppt/publish@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
files: ${{ needs.pack.outputs.files }}
npm-tag: ${{ inputs.npm-tag }}
How it works #
uppt/pr- on every push to the default branch, parses conventional commits since the latest tag, decides the bump (major/minor/patch), pushes arelease/vX.Y.Zbranch with the version bump, and opens or updates a draft release PR (closing any superseded ones).uppt/release- when the release PR is merged, tags the squash commit, creates a GitHub Release from the PR body, and dispatches the publish workflow on the new tag.uppt/pack- installs dependencies, runspnpm pack(ifpnpm-lock.yamlexists) ornpm pack, and uploads the tarball(s) as a workflow artifact, exposing afilesoutput.uppt/publish- downloads the artifact and runsnpm stage publishwith OIDC. You then approve the staged version on npmjs.com.
Tip
You can edit the release PR to add your own release notes. Anything above ## 👉 Changelog is preserved when the changelog is updated.
To release a different version than the one uppt picked, just change version in a package.json on the release branch. On the next run (straight away if your workflow has the release/v* push trigger above, otherwise on the next push to the base branch), uppt will update the PR title and changelog to match (and, in a lockstep monorepo, the other manifests), and keep that version on later pushes. The branch keeps its original name.
If base changes a manifest the release PR also bumps, the next run rebases the PR onto it, keeping its version. A release branch carrying other changes is left for you to update.
Inputs #
All subactions take a node-version input (default 24; uppt needs --experimental-strip-types, so Node 22.6+ also works) and, where applicable, a checkout input (true by default; set to false if the caller has already checked out the right ref - fetch-depth: 0 for pr, on the release PR's base for a release/v* push, the merge commit for release, the tag for pack).
uppt/pr
| Input | Default | Description |
|---|---|---|
token |
${{ github.token }} |
Needs contents: write and pull-requests: write. |
base-branch |
default branch | Base branch for the release PR. |
packages |
(unset) | Newline-separated list of publishable workspace directories (paths or globs, e.g. packages/*). See Monorepo support. |
allow-forks |
false |
By default the action skips on forks so they don't open release PRs of their own. |
prerelease |
(unset) | One-shot prerelease identifier (beta, rc, or a bare number). See Prereleases. |
uppt/release
| Input | Default | Description |
|---|---|---|
token |
${{ github.token }} |
Needs contents: write and actions: write. |
publish-workflow |
release.yml |
Workflow filename to dispatch after tagging. Must declare workflow_dispatch. |
mode |
lockstep |
lockstep or independent. Must match uppt/pr. See Independent versioning. |
npm-tag |
(derived) | npm dist-tag override, passed on to the publish workflow. Derived as <major>x for a release merged into a non-default branch. See Maintenance releases. |
allow-forks |
false |
By default the action skips on forks so they don't tag or publish releases of their own. |
uppt/pack
| Input | Default | Description |
|---|---|---|
install |
true |
Set to false to handle actions/setup-node and dependency installation yourself (pinned package manager, cached node_modules, hardened install policy). The caller must then put node, npm, and any package manager on PATH first. |
packages |
(unset) | Must match the value passed to uppt/pr. |
releases |
(unset) | Independent-mode publish payload, from the workflow's releases dispatch input. Never set by hand. |
nightly |
false |
Pack nightly builds of the current branch. See Nightly releases. |
nightly-suffix |
-nightly |
Suffix appended to package names for nightly builds. |
nightly-aliases |
(unset) | External dependencies to point at their own nightlies. |
| Output | Description |
|---|---|
files |
JSON array of tarball filenames (e.g. ["my-pkg-1.2.3.tgz"]). Pass to uppt/publish via its files input. |
uppt/publish
| Input | Default | Description |
|---|---|---|
npm-access |
public |
npm access level (public or restricted). |
npm-tag |
(derived) | npm dist-tag override for every tarball. By default stable versions publish to latest, prereleases to their identifier (5.0.0-beta.0 → beta), bare-numeric prereleases (5.0.0-0) to next, and maintenance releases to <major>x (see Maintenance releases). |
files |
(scan artifact) | JSON array of tarball filenames, as emitted by uppt/pack. When omitted, every *.tgz in the artifact is published. |
releases |
(unset) | Independent-mode publish payload. Never set by hand. |
nightly |
false |
Publish nightly builds with npm publish instead of staging them. See Nightly releases. |
Lifecycle scripts #
uppt runs your package's lifecycle scripts at one specific point and skips them everywhere else, so the runner that produces the tarball executes as little third-party code as possible:
- Install (in
uppt/pack): runs with--ignore-scripts. Dependencies'postinstallhooks and your ownpreparedo not fire; this is why a compromised transitive dependency can't run code on the publish runner. If your build genuinely needs a dependency'spostinstall, setinstall: falseand install yourself. - Pack (in
uppt/pack):prepack,prepare, andpostpackrun. This is where your build belongs. - Publish (in
uppt/publish): nothing runs, includingprepublishOnly. Move anyprepublishOnlylogic intoprepack.
Prereleases #
To cut a prerelease, run the release workflow from your default branch with the prerelease input set:
gh workflow run release.yml -f prerelease=beta
From 4.5.2, if there's been a breaking change this will open a PR for 5.0.0-beta.0. Running it again will produce 5.0.0-beta.1. A different identifier will reset the counter (5.0.0-rc.0), or a bare number produces the 5.0.0-0 style. It's one-shot: the next ordinary push opens a separate PR for a stable release (e.g. 5.0.0). Prerelease and stable release PRs are tracked independently, so an ordinary push won't close an open prerelease PR.
Prereleases don't take the latest dist-tag: uppt/publish derives the tag from the version being published, so 5.0.0-beta.0 is staged as beta, 5.0.0-rc.1 as rc, and 5.0.0-0 as next (a bare number has no name to use). Set npm-tag on uppt/publish to override; it's also required if a tarball's filename carries no parseable version, as uppt fails rather than defaulting to latest.
Maintenance releases #
To release from an older line, run uppt/pr on that branch with base-branch set to it (3.x), and add the branch to the workflow's push and pull_request triggers.
Nothing else to configure: a release PR merged into a branch other than the default one can't be the newest line, so uppt publishes it to <major>x (3.9.1 → 3x) instead of latest, and doesn't mark the GitHub release as the repo's "Latest". The derived tag is passed to the publish workflow as its npm-tag input, which the starter workflow already forwards to uppt/publish.
If your line uses a different dist-tag (legacy, v3-latest), or the release spans majors so there's no single <major>x to derive, set npm-tag on uppt/release:
- uses: danielroe/uppt/release@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
npm-tag: legacy
It overrides the derivation, applies to every tarball in the run, and any value other than latest also keeps the GitHub release out of the "Latest" slot.
Nightly releases #
uppt can also publish a nightly build of every push to a branch, under a separate package name (@nuxt/test-utils → @nuxt/test-utils-nightly). Nightlies skip the release PR and staging: they publish straight to npm via OIDC, so add a trusted publisher for each nightly package pointing at the workflow below, with 'Environment name' set to nightly (no npm stage publish permission needed). As with stable releases, each nightly package must already exist on npm before you can attach a trusted publisher to it, so publish a stub first (for example with npx setup-trusted-publishing).
Create a matching nightly GitHub environment and limit its deployment branches to the branch(es) you publish nightlies from (e.g. main). Without approvals or staging, this is what stops a workflow run on any other ref from publishing.
Nightly workflow
name: nightly
on:
push:
branches: [main]
permissions: {}
jobs:
pack:
if: '!github.event.repository.fork'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
files: ${{ steps.pack.outputs.files }}
steps:
- id: pack
uses: danielroe/uppt/pack@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
nightly: true
publish:
needs: pack
runs-on: ubuntu-latest
concurrency:
group: nightly-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write
environment: nightly
steps:
- uses: danielroe/uppt/publish@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
nightly: true
files: ${{ needs.pack.outputs.files }}
uppt/pack rewrites each package before packing it:
- Name:
<name>-nightly. Setnightly-suffixto use something else (-edge). - Version:
<version>-<YYMMDDHHmm>-<sha>, e.g.3.20.1-2605140905-a1b2c3d.<version>is theX.Y.Zpart of the version inpackage.json, the timestamp is the HEAD commit's date in UTC, and<sha>is its 7-character short hash. Newer commits always sort higher. Publishing a commit whose nightly is already on npm (for example, a re-run) logs a warning and skips the package rather than failing. - Workspace dependencies: in a monorepo (pass the same
packagesinput), dependencies between listed packages becomenpm:<name>-nightly@<version>, so each nightly installs the other nightlies from the same commit. - External nightlies:
nightly-aliasespoints dependencies on packages from other repos at their own nightlies. - Bins: every command gains a
-nightlycopy, plus one named after the package, sonpx <name>-nightlyworks.
- uses: danielroe/uppt/pack@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
nightly: true
packages: packages/*
nightly-aliases: |
nuxi
@nuxt/cli: @nuxt/cli-nightly@5x
A bare name aliases to npm:<name>-nightly@latest. Nightlies publish to the latest dist-tag of the nightly package; set npm-tag on uppt/publish to publish a branch to another tag (5x).
Monorepo support #
For a lockstep monorepo (where every publishable package shares a single version and one vX.Y.Z tag), pass the same packages: input to uppt/pr, uppt/release, and uppt/pack:
with:
# ...
packages: |
packages/*
!packages/playground
Each line is a directory path or glob; !-prefixed entries are excluded; workspaces with "private": true are silently skipped.
Every listed package must agree on a single semver version. The root package.json#version is only bumped when it already matches, so a 0.0.0 or absent root version is left untouched.
Important
The packages: value must match across all three subactions, or the release PR, the tag, and the published tarballs will cover different sets of packages. And if you use pnpm, every listed workspace must also be in pnpm-workspace.yaml, or workspace:/catalog: specifiers won't resolve at pack time.
Independent versioning (experimental) #
If lockstep is the wrong shape (one package on 0.8.0, another on 0.2.1), set mode: independent alongside packages: on uppt/pr and uppt/release, and each package will advance on its own cadence.
Commits are routed to packages by conventional-commit scope. By default a package claims the last segment of its name (feat(kit): bumps @nuxt/kit); override or alias this with scopes: on uppt/pr:
scopes: |
@nuxt/kit: kit nuxt-kit
@nuxt/schema: schema
A comma-separated scope (fix(fontaine,fontless):) bumps every package it names. Commits with no scope, or an unmatched scope, do not bump anything.
Note
Bumping a package also releases anything that depends on it via dependencies, peerDependencies, or optionalDependencies.
You get a single release/<base>-pending PR with a section per package. On merge, uppt tags each released package as <name>@X.Y.Z, creates one GitHub release on a release-YYYY-MM-DD coordination tag, and dispatches the publish workflow with a releases payload naming the packages to pack and stage. They go live together when you approve them on npmjs.com.
The workflow needs a few changes on top of the lockstep setup: a releases dispatch input, mode: independent, a looser release/ head-ref guard, and job conditions that accept the coordination tag.
Full independent-mode workflow
name: release
on:
push:
branches: [main]
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
prerelease:
description: 'Cut a prerelease instead of a normal release, e.g. `beta`, `rc`, or `0`'
required: false
default: ''
npm-tag:
description: 'npm dist-tag to publish to. Set by uppt/release for maintenance lines; leave blank otherwise'
required: false
default: ''
releases:
description: 'Publish payload emitted by uppt/release; leave empty when rerunning a publish by hand'
required: false
default: ''
permissions: {}
jobs:
pr:
if: |
!github.event.repository.fork
&& (
(
github.event_name == 'push'
&& github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
) || (
github.event_name == 'workflow_dispatch'
&& !startsWith(github.ref, 'refs/tags/')
)
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: danielroe/uppt/pr@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ inputs.prerelease }}
mode: independent
packages: |
packages/*
release:
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
&& startsWith(github.event.pull_request.head.ref, 'release/')
&& github.event.pull_request.head.repo.full_name == github.repository
&& !github.event.repository.fork
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: write
actions: write
steps:
- uses: danielroe/uppt/release@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
token: ${{ secrets.GITHUB_TOKEN }}
mode: independent
packages: |
packages/*
pack:
if: |
github.event_name == 'workflow_dispatch'
&& (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/release-'))
runs-on: ubuntu-latest
concurrency:
group: pack-${{ github.ref }}
cancel-in-progress: false
permissions: {}
outputs:
files: ${{ steps.pack.outputs.files }}
steps:
- id: pack
uses: danielroe/uppt/pack@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
releases: ${{ inputs.releases }}
packages: |
packages/*
publish:
if: |
github.event_name == 'workflow_dispatch'
&& (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/release-'))
&& needs.pack.outputs.files != '[]'
needs: pack
runs-on: ubuntu-latest
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write
environment: npm
steps:
- uses: danielroe/uppt/publish@6ec27140623aa835e362f866d8ab0018ab057f4d # v0.6.11
with:
files: ${{ needs.pack.outputs.files }}
npm-tag: ${{ inputs.npm-tag }}
releases: ${{ inputs.releases }}
When releases is empty every action behaves exactly as in lockstep mode, so rerunning a publish by hand on a v* tag still works.
Note
Independent mode is new and hasn't been through many real releases yet. If something looks wrong, please open an issue.
Important
When switching an existing repo over: if your npm environment only allows v* tags to deploy, add release-* (independent-mode publishes run on the coordination tag). Existing tags are fine as they are: uppt reads <name>@X.Y.Z tags, and a package with no tag of its own falls back to the newest vX.Y.Z, so the first independent run only releases what has actually changed.
Credits #
Inspired by unjs/changelogen and antfu/changelogithub. You might also want to check out changesets and release-please.
License #
Made with ❤️
Published under MIT License.