sfpm
Guides

Build, validate, and publish

A CI workflow that builds packages, resolves async validation for unlocked packages, and publishes only the packages that passed. Has a known DevHub-wiring limitation at the default settings, covered below.

This guide builds a sfpm workspace in CI, waits for the async validation of unlocked packages, and publishes the packages that are safe to publish. It uses three actions in sequence: build, build-validation, and a publish step.

How the pipeline works

  1. build builds the packages. At org/full validation, it's meant to create unlocked package versions that Salesforce validates in the background, and to org-deploy and test source packages, but see the limitation below before relying on either at the default settings. The action returns immediately with a result JSON that records each package's pending validation. A later step in the same job uploads the dist/ directories as a workflow artifact, so the publish job has the built output.
  2. build-validation reads the result JSON, polls the DevHub until each pending validation finishes, and sets the publishable-packages output.
  3. Publish runs sfpm publish for the names in publishable-packages, and nothing else.

The build action never passes a build org to the orchestrator, and it logs devhub-username without passing that through either. At validation: org or full, this affects both package types: unlocked packages don't get a working DevHub connection (so Salesforce never creates or validates a package version for them), and source packages have no build org to deploy and test against, so they log a skip and are not org-validated either. Neither failure stops the build; both package types simply don't get the validation the level name implies. For packages in CI today, use validation: local. Unlocked packages then build as plain source packages: no DevHub is needed and no package version is created, so there is nothing for build-validation to resolve for them, and source packages still get the same local checks they'd get at org/full, just no org deploy. The workflow below does this.

Publishable packages

There is no package setting that marks a package as publishable. The list is computed by build-validation. A package is in publishable-packages when all of the following are true:

  • Its build succeeded.
  • The build did not skip it. A skipped package had no source changes, so there is nothing new to publish.
  • It did not require async validation, or its validation passed.

Packages that fail any of these checks are left out of the list. The publish step gets the exact names it should publish, and it does not filter on its own.

The publish step

sfpm publish publishes each named package from its dist/ directory. It runs pnpm publish --no-git-checks in that directory, so pnpm must be on the runner's PATH. The default npm dist-tag is latest. Pass --tag to change it.

Publish only after build-validation succeeds for the packages you publish. The publishable-packages output already applies this rule, so do not replace it with a hard-coded package list.

Workflow

The workflow below uses these repository secrets: SFDX_CONSUMER_KEY, SFDX_JWT_KEY, DEVHUB_USERNAME, and NPM_TOKEN. The JWT key is written to a file for sf org login jwt and removed afterward.

name: SFPM Build and Publish

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.build.outputs.result }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - name: Install Salesforce CLI
        run: pnpm add -g @salesforce/cli

      - name: Authenticate DevHub
        env:
          SFDX_CONSUMER_KEY: ${{ secrets.SFDX_CONSUMER_KEY }}
          SFDX_JWT_KEY: ${{ secrets.SFDX_JWT_KEY }}
          DEVHUB_USERNAME: ${{ secrets.DEVHUB_USERNAME }}
        run: |
          printf '%s' "$SFDX_JWT_KEY" > server.key
          sf org login jwt \
            --client-id "$SFDX_CONSUMER_KEY" \
            --jwt-key-file server.key \
            --username "$DEVHUB_USERNAME" \
            --set-default-dev-hub \
            --alias devhub
          rm server.key

      - name: Build packages
        id: build
        uses: b64hub/sfpm/packages/actions/build@vX.Y.Z
        with:
          devhub-username: devhub
          validation: local

      - name: Upload build output
        uses: actions/upload-artifact@v4
        with:
          name: sfpm-dist
          path: |
            **/dist/**
            !node_modules/**
          retention-days: 1

  build-validation:
    needs: build
    runs-on: ubuntu-latest
    outputs:
      publishable-packages: ${{ steps.resume.outputs.publishable-packages }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - name: Install Salesforce CLI
        run: pnpm add -g @salesforce/cli

      - name: Authenticate DevHub
        env:
          SFDX_CONSUMER_KEY: ${{ secrets.SFDX_CONSUMER_KEY }}
          SFDX_JWT_KEY: ${{ secrets.SFDX_JWT_KEY }}
          DEVHUB_USERNAME: ${{ secrets.DEVHUB_USERNAME }}
        run: |
          printf '%s' "$SFDX_JWT_KEY" > server.key
          sf org login jwt \
            --client-id "$SFDX_CONSUMER_KEY" \
            --jwt-key-file server.key \
            --username "$DEVHUB_USERNAME" \
            --set-default-dev-hub \
            --alias devhub
          rm server.key

      - name: Resolve validation
        id: resume
        uses: b64hub/sfpm/packages/actions/build-validation@vX.Y.Z
        with:
          build-result: ${{ needs.build.outputs.result }}
          max-wait-minutes: '60'

  publish:
    needs: [build, build-validation]
    if: needs.build-validation.outputs.publishable-packages != ''
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - name: Install sfpm CLI
        run: pnpm add -g @b64hub/sfpm-cli

      - name: Download build output
        uses: actions/download-artifact@v4
        with:
          name: sfpm-dist

      - name: Publish packages
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          PUBLISHABLE_PACKAGES: ${{ needs.build-validation.outputs.publishable-packages }}
        run: |
          printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > ~/.npmrc
          sfpm publish $(echo "$PUBLISHABLE_PACKAGES" | tr ',' ' ')

Replace vX.Y.Z with the release tag you pin. See Versioning and pinning.

Notes on the workflow

  • DevHub in both jobs. build-validation polls the DevHub named in each pending validation, by username. With validation: local, the build creates no pending validations for unlocked packages, so the DevHub login in this job only matters once org validation for unlocked packages works. Keep it in place so the job is ready when it does.

  • Validation level. The build action defaults validation to org. The CLI's sfpm build defaults to local. The workflow sets validation: local explicitly because of the limitation described above, for both unlocked and source packages.

  • Validation wait. build-validation waits up to max-wait-minutes (default 120). Set it below your job timeout.

  • Install check. This workflow does not install the built packages into an org. Add an install job with origin: local if you want to smoke-test this run's output before publishing. See GitHub Actions.

On this page