How Do I Publish a Package From a Workflow?

Gate the publish job behind a release event, authenticate with a credential scoped to the registry, and publish the artifact the test job already built.

Publish from a job that runs only on a release event, authenticate with a credential scoped to the target registry, and ship the artifact the test job already built. The publish job downloads that artifact instead of rebuilding it, so the bytes that passed the test suite are the bytes that reach the registry.

Answer

A publish workflow is three decisions: when the job runs, what credential it holds, and where the file it uploads comes from. Getting any one of them wrong shows up later as a bad release rather than as a red build.

When. Publishing belongs on an event that a human or a tag creates. GitHub triggers a workflow on release with type published, and on push filtered by tags, both documented in the events that trigger workflows reference. A branch push runs the tests, and only the release event reaches the registry.

What credential. The three options differ in blast radius rather than in effort.

CredentialHow the job gets itTradeoff
Workflow GITHUB_TOKENAutomatic, with permissions: packages: write in the jobWorks for GitHub Packages registries tied to the repository. Expires with the job. Cannot publish to an external registry.
Long-lived registry tokenStored as an encrypted repository or environment secretWorks with every registry. Lives until someone rotates it, and any job granted the secret can use it.
OIDC exchange with a trusted publisherThe job requests id-token: write and swaps the token for a short-lived credentialNo stored secret to leak or rotate. Requires registry support and a trust rule naming the repository and workflow file.

GitHub documents the OIDC token flow, and the registries carry the other half: npm trusted publishers and PyPI trusted publishers. Where a trusted publisher exists, use it, because a credential that lives for the length of one job removes the rotation schedule and the leak window that a long-lived token carries.

Which file. Build once. The test job produces the tarball, wheel, or jar, uploads it with actions/upload-artifact, and the publish job pulls it back with actions/download-artifact. Rebuilding inside the publish job re-runs a compiler against a fresh dependency resolution and produces a file that nothing tested.

Detail

A release-triggered publish job

One workflow, two jobs. Every push runs build, and only a published release runs publish.

name: release

on:
  push:
    branches: [main]
  release:
    types: [published]

permissions:
  contents: read

concurrency:
  group: publish-${{ github.ref }}
  cancel-in-progress: false

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org

      - uses: WarpBuilds/cache@v1
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('package-lock.json') }}

      - run: npm ci
      - run: npm test
      - run: npm pack --pack-destination dist

      - uses: actions/upload-artifact@v4
        with:
          name: package-tarball
          path: dist/*.tgz
          retention-days: 7

  publish:
    needs: build
    if: github.event_name == 'release'
    runs-on: warp-ubuntu-latest-x64-2x
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: package-tarball
          path: dist

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org

      - run: npm publish dist/*.tgz --provenance --access public

Four lines carry the design. if: github.event_name == 'release' keeps pushes off the registry. environment: release routes the job through an environment, which is where a required reviewer or a branch restriction attaches. id-token: write is what makes the OIDC exchange and the --provenance flag work, and with a trusted publisher configured on the package there is no NODE_AUTH_TOKEN line at all. concurrency stops two releases from racing to the same version.

If the registry has no trusted publisher support, the last step keeps a secret instead:

      - run: npm publish dist/*.tgz --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Scope that secret to the release environment rather than the repository, so only jobs that declare environment: release can read it. On WarpBuild runners the secret handling is unchanged: each runner runs in its own virtual machine, created on demand and destroyed after the build, and WarpBuild does not access or store build secrets, which stay in your source code repository and are only accessible to your runner environment (security documentation).

The build-once rule, and what breaks it

The artifact handoff has two failure modes worth knowing before they bite.

The first is expiry. Workflow artifacts have a retention window, 90 days by default and configurable per repository or per upload, per the GitHub Actions limits reference. A publish job that runs in the same workflow run always finds its artifact. A publish workflow triggered days later by a separate release event has to fetch the artifact from the earlier run by run id, and it fails once retention has passed.

The second is the rebuild that sneaks back in. Any npm ci, mvn package, or cargo build inside the publish job resolves dependencies again at publish time, and a dependency that moved between the test run and the release turns the published file into something untested. Keep the publish job free of build steps, and it stays a download plus an upload.

Provenance makes the rule checkable rather than aspirational. npm records a provenance statement linking the published version to the workflow run that produced it, and actions/attest-build-provenance does the same for arbitrary artifacts. Both attest the run, so a rebuild in a different job is visible to anyone who checks.

Which matters for releases that ship more than one platform binary. Build the macOS binary on a macOS runner and the Linux binary on a Linux runner, upload both as artifacts, and let one small Linux publish job collect them and push a single release.

What the split costs

The publish job downloads a file and calls a registry API, so it belongs on the smallest runner available. Here is a release pipeline for a package with a nine minute test and build job, priced from the WarpBuild pricing page, checked on 2026-08-13.

JobRunnerMinutesRate per minuteCost per release
Test and buildwarp-ubuntu-latest-x64-4x9$0.008$0.072
Publish, artifact download onlywarp-ubuntu-latest-x64-2x2$0.004$0.008
Publish with a rebuild insteadwarp-ubuntu-latest-x64-4x8$0.008$0.064

The build-once pipeline is $0.080 per release. Rebuilding in the publish job takes the same release to $0.136, and at 40 releases a month that is $3.20 against $5.44 for a file nothing tested.

The rate on the publish job is the cheapest line in the table for a reason. warp-ubuntu-latest-x64-2x gives 2 vCPU and 8 GB at $0.004 per minute, while GitHub-hosted ubuntu-latest on a private repository is the same 2 vCPU, 8 GB shape at $0.006 per minute: $0.002 divided by $0.006 is 33 percent lower list price (GitHub Actions billing reference, checked on 2026-08-13).

Two structural facts sit under those numbers. Package storage bills separately on GitHub's side under GitHub Packages billing, and the dependency restore that dominates the build job is the line that caching moves.

Should the publish job rebuild the package?

No. Build and test once, upload the result with actions/upload-artifact, and have the publish job download that same file. A rebuild inside the publish job produces bytes that no test ever ran against, and it pays for the build minutes twice: $0.064 against $0.008 in the table above. The full artifact handoff pattern, including matrix builds that produce one artifact per platform, is in the build artifacts and publishing guide.

Do I need a stored registry token to publish?

It depends on the registry. GitHub Packages accepts the workflow GITHUB_TOKEN with packages: write permission, which expires when the job ends. npm and PyPI both support OIDC trusted publishing, which exchanges a short-lived workflow identity for a publish credential and removes the stored secret entirely. Registries without OIDC support still need a token in an encrypted secret, scoped to a deployment environment rather than to the whole repository. The deployment environment definition covers what that scoping buys.

How do I stop a publish from running on every push?

Put the publish work in its own job with a condition on the event, so pushes run tests and only a published release reaches the registry. Add an environment with a required reviewer when a human approval belongs in the path, and a concurrency group so two releases cannot publish the same version at the same time. Promoting one tested artifact through several environments is the same idea one step further, covered in build once, deploy many.

How do I make the release pipeline finish faster?

Cut the test and build job, since it holds most of the minutes. Cache the dependency restore, split the test suite across a matrix, and size the runner to the job rather than to the repository. The guide to speeding up GitHub Actions works through each lever with configuration examples, and every per-minute rate quoted above is on the pricing page.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.