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
buildbuilds the packages. Atorg/fullvalidation, 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 aresultJSON that records each package's pending validation. A later step in the same job uploads thedist/directories as a workflow artifact, so the publish job has the built output.build-validationreads theresultJSON, polls the DevHub until each pending validation finishes, and sets thepublishable-packagesoutput.- Publish runs
sfpm publishfor the names inpublishable-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-validationpolls the DevHub named in each pending validation, by username. Withvalidation: 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
buildaction defaultsvalidationtoorg. The CLI'ssfpm builddefaults tolocal. The workflow setsvalidation: localexplicitly because of the limitation described above, for both unlocked and source packages. -
Validation wait.
build-validationwaits up tomax-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
installjob withorigin: localif you want to smoke-test this run's output before publishing. See GitHub Actions.