Appearance
Publish to GitHub Packages
Publish a private, scoped npm package to GitHub Packages and consume it from other repositories, keeping the whole release pipeline inside GitHub. The recommended setup versions and publishes automatically on every merge to main; a tag-triggered workflow remains for the first publish and manual releases.
When to use
Use GitHub Packages when a package should stay private to your account or org and its consumers already authenticate to GitHub. You get a private registry, CI that publishes with no external secrets, and GITHUB_TOKEN-based reads from consumer CI.
Automatic versioning and publishing (recommended)
On every merge to main, .github/workflows/version-on-merge.yaml derives the SemVer bump from the package's public surface, publishes the package, pushes the v<version> tag, and lands the bump on main through an auto-merging PR. You never choose a version or cut a release by hand. How the bump is decided is in Versioning a skills package; how the release commit clears branch protection is in Version on merge.
Create the GitHub App (once per account)
The release PR must be opened by something other than the built-in GITHUB_TOKEN. GitHub's recursion guard means a PR opened with GITHUB_TOKEN never triggers the required CI check, so its auto-merge would wait forever. A GitHub App token does trigger the check, and an App private key does not expire.
Create the GitHub App once for your account. It is reusable across every repo that publishes this way, so these four steps happen a single time:
- Create the App under account Settings → Developer settings → GitHub Apps → New GitHub App. Name it generically (for example
release bot, not after one repo), set any homepage URL, and uncheck Webhook → Active (it needs no webhook). Leave Where can this GitHub App be installed? on Only on this account. - Grant two repository permissions: Contents: Read and write (to push the release branch and tag) and Pull requests: Read and write (to open the PR and enable auto-merge). Leave the rest unset.
- Create the App, then copy its App ID from the General page.
- Generate a private key on that page; GitHub downloads a
.pem. It cannot be re-downloaded, so back it up somewhere durable such as a password manager.
Enable the GitHub App for a repo (repeat per repo)
The GitHub App is created once, but every repo that runs the release workflow needs its own copy of the two secrets and its own repo settings: a personal account has no shared secret store, so secrets live per repo.
- Install the App on the repo (Install App, then pick the repo). One App can be installed on many repos.
- Add two repo secrets under Settings → Secrets and variables → Actions:
RELEASE_APP_ID: the numeric App ID.RELEASE_APP_PRIVATE_KEY: the full contents of the.pem, including theBEGIN/ENDlines.
- Turn on two repo settings under Settings → General → Pull Requests: Allow auto-merge (so the release PR merges itself once
Testspasses) and Automatically delete head branches (so eachreleases/v*branch is removed after merge instead of accumulating).
The private key is multi-line, so read the .pem from a file with --body-file rather than pasting it by hand:
sh
gh secret set RELEASE_APP_PRIVATE_KEY --repo OWNER/REPO --body-file key.pemThe workflow exchanges those secrets for a short-lived installation token with actions/create-github-app-token, scoped to just this repo and those two permissions:
yaml
- name: Mint release app token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}Publishing itself still uses the built-in GITHUB_TOKEN (packages: write); the App token only opens the auto-merging release PR.
Manual or initial publish
The first publish, before any tag exists, and any manual release run from a tag-triggered workflow, .github/workflows/publish-to-github-packages.yaml:
yaml
name: Publish to GitHub Packages
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
publish-to-github-packages:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://npm.pkg.github.com"
scope: "@your-scope"
- name: Install dependencies
run: npm ci
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Tag a release locally and push the tag to trigger it:
sh
npm version patch # or minor | major
git push --follow-tagsBot-created tag pushes from GITHUB_TOKEN do not trigger this workflow, so the automatic pipeline above publishes inline rather than relying on tag chaining.
Package configuration
Point publishing at GitHub Packages with a scoped name and publishConfig. A complete package.json for a plain-ESM package (no build step; tests run on node --test):
json
{
"name": "@your-scope/my-library",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"test": "node --test"
},
"files": [
"index.js",
"lib/"
],
"publishConfig": {
"registry": "https://npm.pkg.github.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/your-scope/my-library.git"
}
}The scope must match the repository owner (@your-scope), and files is the allowlist of what ships in the published tarball.
Consume the package
A consumer authenticates to the private registry, then installs it like any other dependency. Commit a token-free .npmrc that scopes the namespace and reads the token from an env var:
ini
@your-scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}Supply NODE_AUTH_TOKEN per environment: a read:packages token in your personal ~/.npmrc for local dev, and the built-in GITHUB_TOKEN with packages: read in consumer CI. Then install on a caret range, so patches and minors flow in while majors arrive as their own PR:
sh
npm install --save-dev @your-scope/my-libraryFor the dev-skills package specifically (activating skills on disk, the Dependabot wiring, and adopting majors), see Use dev-skills in a repo.
Trade-offs
- Tag triggers vs branch triggers: tag triggers publish only explicit releases; branch triggers risk publishing on every commit. The automatic pipeline derives the tag from a merge, so it gets release-only publishing with no manual tag step.
- Scoped packages: GitHub Packages requires scoped names matching the repository owner (
@your-scope/package-name). GITHUB_TOKENvs App token vs PAT:GITHUB_TOKENpublishes and reads within the same account, but a PR it opens cannot trigger required checks. An App token clears that without the expiry and broad scope of a PAT.
See also
- Version on merge: how the release commit clears branch protection on
main. - Versioning a skills package: how the version number is decided, for maintainers.
- Use dev-skills in a repo: the full consumer setup for this package.
- GitHub: GitHub CLI, Actions, PRs, and CI workflows.