Skip to content

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.

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:

  1. 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.
  2. 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.
  3. Create the App, then copy its App ID from the General page.
  4. 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.

  1. Install the App on the repo (Install App, then pick the repo). One App can be installed on many repos.
  2. 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 the BEGIN/END lines.
  3. Turn on two repo settings under Settings → General → Pull Requests: Allow auto-merge (so the release PR merges itself once Tests passes) and Automatically delete head branches (so each releases/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.pem

The 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-tags

Bot-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-library

For 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_TOKEN vs App token vs PAT: GITHUB_TOKEN publishes 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