Deploying from a CI/CD Pipeline
Once an application is past the prototype stage, you want deployments to happen from your CI/CD pipeline, not from a laptop: a tag or release triggers a workflow, the workflow validates the code, and the pipeline tells your Harper cluster to deploy it. This guide uses GitHub Actions and GitHub's hosting (repositories and the GitHub Packages npm registry). On GitHub Actions, the Harper CLI authenticates with the runner's own identity token, so the workflow holds no Harper credential at all. Other CI systems call the deploy_component operation directly, which is a single HTTP call — see Other CI systems.
There are two ways to deliver an application to Harper, and choosing the right one is most of the battle:
- Deploy from a git tag — Harper installs your application directly from a tagged commit in your repository. Best for applications that run from source.
- Deploy from a package registry — CI builds and publishes an artifact to a private npm registry; Harper installs that artifact by version. Best for applications that need a build step.
Both work with private sources: deploy_component accepts a credentials array that names a credential in Harper's encrypted secrets store.
What You Will Learn
- How to choose between git-tag deploys and registry-artifact deploys
- How to let a GitHub Actions workflow deploy with no stored Harper credential, using an OIDC trust policy and a deploy-only role
- How to give Harper a durable credential for a private source — and why it must not be the workflow's ephemeral
GITHUB_TOKEN - A GitHub Actions workflow that deploys a tagged release straight from a repository
- A GitHub Actions workflow that builds, publishes to GitHub Packages, and deploys the published artifact
- How to wait until every node has taken the release, and how to go back to a previous one
- How to deploy isolated applications and keep one preview per PR, with shared databases and cleanup on closure
Prerequisites
- A Harper Fabric cluster, or a self-managed Harper cluster, running v5.3.0 or later, which added OIDC trust policies. Deploying from a private source also needs secret custody, which Fabric provides and the Pro secrets component provides on a self-managed cluster.
super_useraccess to the cluster from your own machine, to create the deploy user and the trust policy once. The pipeline never holds it.- The Harper CLI on your machine:
npm install -g harper. - A GitHub repository containing a working Harper application (Create your First Application if you don't have one).
- Familiarity with GitHub Actions basics (workflows, environments, variables).
Choosing a delivery model
Deploy from a git tag when your application runs from source. Most Harper applications do: a config.yaml, a schema.graphql, resource classes in JavaScript (or TypeScript that Harper runs directly via type stripping), and npm dependencies that install cleanly. For these, the repository is the artifact. A git-tag deploy has no publish step to maintain — you tag a release, CI runs harper deploy by_ref=true, and every node installs exactly that commit. The deployment record and the component config both name the commit, so the deployed version stays auditable.
Deploy from a registry when your application needs a build. If CI must produce output that isn't in the repository — a bundling step, compiled assets, code generation, native compilation — publish the built package to a registry and deploy that. This is better than making Harper build it for two reasons:
- The artifact you tested is the artifact you run. CI builds once, tests the result, publishes it. Every Harper node installs that identical, immutable artifact instead of re-running your build independently.
- No build scripts run on your database nodes. When Harper installs a credentialed git reference, npm's pack step runs with
--ignore-scriptsby default, precisely so repository code can't run during install with the deploy credential in reach. A repository that requires aprepare/build script to be runnable forces you to setinstall_allow_scripts: true, which allows that script — and your dependencies' install scripts — to execute on the node during deployment. A registry artifact needs none of that: it's already built.
A second constraint pushes the same direction: a git-reference deploy authenticates only the top-level repository. If your application has private git-hosted dependencies (a package.json dependency pointing at another private repo), their installation is not credentialed and will fail. Private registry dependencies work fine — registry credentials apply to the whole install. If your dependency graph reaches into private git repos, publish those dependencies (or the whole app) to a private registry instead.
| Your application... | Deploy from |
|---|---|
| Runs from source (schema, resources, npm dependencies) | Git tag |
| Needs a build step (bundling, codegen, compilation) | Private registry |
| Has private git-hosted dependencies | Private registry |
Let the workflow authenticate with OIDC
A GitHub Actions job that declares id-token: write can ask GitHub for an identity token that says which repository, workflow, and environment it is running for. The Harper CLI trades that token for a one-hour operation token, if it matches a trust policy you stored on the cluster. Nothing durable lives in your CI provider, and there is no 30-day refresh token to rotate. See Workload identity (OIDC) for how the CLI does this, and add_oidc_trust for every rule a policy can carry.
You set this up once, from your own machine, logged in as a super_user:
harper login https://my-cluster.example.com:9925
Create a deploy-only user
The user a trust policy names is the privilege boundary: a matching run gets that user's role. A pipeline does not need super_user. Give it a role that lists only the operations the workflows below call:
harper add_role role=ci_deploy permission='{"operations":["deploy_component","get_job","get_deployment"]}'
harper add_user username=ci-deploy role=ci_deploy active=true password="$(openssl rand -base64 32)"
deploy_componentis the deploy itself.get_jobis what the wait step polls. The jobs table listsget_jobas open to any role, but anoperationsallowlist refuses every operation it doesn't list, so without it the wait step fails with403.get_deploymentreads the deployment record.
restart_service is not needed: the rolling restart's job is started by Harper itself, not by the caller. The password is required to create the user, but a GitHub Actions pipeline never uses it, since it authenticates through the trust policy. Keep it only if you deploy from another CI system.
Deploying is still administrative authority, because the deployed component runs inside the Harper process. And a deploy that passes a literal registry or git token in credentials needs super_user, because Harper seals that token into the secrets store. So this role deploys private sources through a secret reference, which the next section sets up.
Add a trust policy
The workflows below run when you push a version tag, so the policy can't pin workflow_ref: the tag is part of it, and it isn't known when you write the policy. Pin the repository and the workflow file instead, and gate on a GitHub environment:
harper add_oidc_trust \
id=my-app-release \
issuer=https://token.actions.githubusercontent.com \
audience=https://my-cluster.example.com:9925/ \
user=ci-deploy \
claims='{"repository_id":"67890","workflow_path":"my-org/my-app/.github/workflows/deploy.yml","environment":"production"}'
repository_idpins the repository. It is immutable, so it survives a rename. Look it up withgh api repos/my-org/my-app --jq .id.workflow_pathpins the workflow file. Harper derives it from the token'sworkflow_refby removing the@<ref>suffix, so it is<owner>/<repo>/<path>.environmentis the ref gate. Without one, anyone who can push a branch could run the pinned workflow on it and mint a token. The gate is only as strong as the environment's rules, so in the repository's Settings → Environments → production, set Deployment branches and tags to thev*tag pattern, and add required reviewers if a release needs approval. Anyone who can push av*tag can still deploy, so restrict who can create those tags with a tag ruleset.audiencemust be the exact string the CLI asks for: the target URL with its port and a trailing slash.
A workflow that deploys on a push to main can pin workflow_ref (my-org/my-app/.github/workflows/deploy.yml@refs/heads/main) instead, which gates the ref by itself. Policy specificity for GitHub Actions explains why each of these pins is required, and why ref_type: tag is not accepted as a gate.
Finally, store the target as a GitHub Actions variable, not a secret, since it isn't sensitive:
gh variable set HARPER_CLI_TARGET --repo my-org/my-app --body https://my-cluster.example.com:9925/
Give Harper a durable credential for a private source
Skip this section if your repository is public, or your package is on a public registry.
Harper needs to authenticate to GitHub, and not only at the moment you deploy. Every node installs the release itself, and Harper keeps the credential (encrypted, in the secrets store) with the component's configuration, so that later installs — a new node joining the cluster, a reinstall after a restore — can fetch the package again without you re-supplying auth.
That has one practical consequence: use a durable token, not your workflow's ephemeral GITHUB_TOKEN. The automatic GITHUB_TOKEN in a GitHub Actions run expires when the job ends. It's fine for things that happen inside the job (like publishing a package), but an install that happens after the workflow finishes would fail authentication. Instead, create a token whose lifetime matches how long nodes may need to fetch this component:
- For a private repository (git-tag deploys): a GitHub fine-grained personal access token with Contents: read-only permission, scoped to just the application repository. An organization can attach these to a machine account so they aren't tied to an individual.
- For GitHub Packages (registry deploys): a token with the
read:packagesscope.
Store it in Harper once, from your machine, with harper deploy setup=true. It prompts for the token, encrypts it locally with the cluster's public key, grants it to the component, and prints the credentials reference to use — the secret's name, never the token. For a private repository:
harper deploy setup=true provider=github project=my-app
For GitHub Packages:
harper deploy setup=true provider=npm project=my-app registry=https://npm.pkg.github.com scope=@my-org
The name is derived from the component and the host, so for my-app it is deploy.my-app.git.github.com for the repository, and deploy.my-app.npm.pkg.github.com for GitHub Packages. The workflows below reference it by that name, so the token never appears in your pipeline. Harper Studio also provides a UI for creating, granting, and rotating secrets, and set_secret does the same from the Operations API.
Path A: deploy a tagged release from your repository
The flow: push a semver tag (v1.2.3) → the workflow runs harper deploy by_ref=true → every node clones that commit and installs it.
# .github/workflows/deploy.yml
name: Deploy to Harper
on:
push:
tags: ['v*']
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g harper
- name: Deploy the tagged commit
run: harper deploy project=my-app by_ref=true restart=rolling json=true > deploy.json
- name: Wait for every node to take the release
run: |
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
harper get_job id="$JOB_ID" json=true > job.json || continue
case $(jq -r '.[0].status // "MISSING"' job.json) in
COMPLETE) exit 0 ;;
ERROR) jq -r '.[0].message' job.json; exit 1 ;;
MISSING) LAST="was not on the node that answered" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
exit 1
For a private repository, add credential=true to the deploy command. It attaches the deploy.my-app.git.github.com reference you created with harper deploy setup=true.
A few details worth noting:
- No secrets.
HARPER_CLI_TARGETis the only thing the workflow is given.id-token: writelets the CLI request an identity token, andenvironment: productionputs theenvironmentclaim the trust policy requires into it. The CLI prints which policy authenticated it, and as whom, to stderr. by_ref=truedeploysgit+https://github.com/<owner>/<repo>.git#<sha>, where the SHA is the commit the tag points to (GITHUB_SHA). Every node clones that exact commit, even if the tag is moved later. Passref=<tag or sha>to deploy a different commit.replicatedisn't needed. On Harper Fabric and Harper Pro, a deploy goes to every node in the cluster unless you passreplicated=false. Each node fetches and installs the package itself, resolving the credential from the replicated secrets store. Harper core on its own does not replicate.restart=rollingrestarts nodes one at a time so the cluster keeps serving throughout. Userestart=truefor a single-node or dev instance. The preview recipe also uses inline restarts on clusters, with an explicit allowance for concurrent initial restarts. On Harper core alone, a rolling job that only restarts — before v5.4.0, or whencertificationisunavailable— fails withReplication not implemented, which fails the wait step.json=trueprints the deploy's result as JSON on stdout. Progress and authentication messages go to stderr, sodeploy.jsonholds only the result. A deploy that fails exits non-zero, which fails the step.- The wait step. With
restart=rolling,harper deployexits0once the node you called has taken the release and started the rolling job, which keeps running after the deploy step has passed. On v5.4.0 and later, unless the deploy'scertificationisunavailable, the other nodes take the release in that job, one at a time, and a node that rejects it fails the job, not the deploy step; the failed job'smessagelists each node's outcome underactivated. Otherwise every node installs the release before the deploy step passes, and the job only restarts them. The wait step polls the job'srestartJobIdwithget_jobuntil it endsCOMPLETEorERROR, and retries a poll that fails, such as one that reaches a node while it restarts. Eachharper get_jobauthenticates through the trust policy the same way. Withrestart=truethere is no job, and the deploy step itself fails when a node rejects the release, unless you passignore_replication_errors=true. - The job lives on one node. Everything else about a deploy is visible from any node, but Harper does not replicate jobs, so only the node that received the deploy knows the rolling job. Any other node answers
get_jobwith an empty list, which the wait step treats as not finished and polls again. Through a load-balanced cluster URL, the polls that reach the node that received the deploy see the job; if none do, the step says the job was not on the node that answered. harper#3060 tracks recording each node's outcome on the deployment record, which replicates. - Canary certification Added in: v5.4.0. On v5.4.0 and later,
restart=trueandrestart=rollingload the release in a canary worker on each node before that node serves it, except in the cases that section lists, so a release that fails to load is rejected. Withrestart=rolling, the node you called certifies it before the deploy responds, and each other node certifies it as the job reaches it. - The token for a private repository is served to git from memory on each node — it is never written into a URL, a lockfile, or a file on disk.
If this repository needs a build step to be runnable, stop — don't reach for install_allow_scripts. Use Path B.
Path B: build in CI, publish to GitHub Packages, deploy the artifact
The flow: push a tag → CI builds and tests → npm publish to GitHub Packages → harper deploy installs the published version from the registry.
Your package.json needs a scoped name and a publishConfig pointing at GitHub Packages:
{
"name": "@my-org/my-app",
"version": "1.2.3",
"publishConfig": { "registry": "https://npm.pkg.github.com" },
"files": ["config.yaml", "schema.graphql", "dist/"]
}
Use files deliberately — it defines exactly what ships in the artifact. Then:
# .github/workflows/deploy.yml
name: Build, publish, and deploy to Harper
on:
push:
tags: ['v*']
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
id-token: write
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: '@my-org'
- run: npm install -g harper
- run: npm ci
- run: npm run build
- run: npm test
- name: Publish to GitHub Packages
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy the published version
run: |
harper deploy project=my-app package="@my-org/my-app@${GITHUB_REF_NAME#v}" \
credentials='[{"registry":"https://npm.pkg.github.com","scope":"@my-org","secret":"deploy.my-app.npm.pkg.github.com"}]' \
restart=rolling json=true > deploy.json
- name: Wait for every node to take the release
run: |
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
harper get_job id="$JOB_ID" json=true > job.json || continue
case $(jq -r '.[0].status // "MISSING"' job.json) in
COMPLETE) exit 0 ;;
ERROR) jq -r '.[0].message' job.json; exit 1 ;;
MISSING) LAST="was not on the node that answered" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
exit 1
Note the two credentials play different roles, matching their lifetimes:
- Publishing uses the workflow's ephemeral
GITHUB_TOKEN(withpackages: write). Publishing happens entirely inside the job, so an ephemeral token is exactly right. - Installing uses the durable
read:packagestoken you stored in Harper asdeploy.my-app.npm.pkg.github.com— because installs can happen long after this workflow ends.
The deploy step installs the version the tag names, while npm publish publishes the version in package.json, so the two must agree: npm version 1.2.3 sets one and creates the other as v1.2.3.
scope: '@my-org' on setup-node sends only @my-org packages to GitHub Packages, so npm install -g harper still installs from the public registry. The scope in credentials does the same on each Harper node, so scoped dependencies from the same registry resolve too.
Check the deployment record
deploy.json also carries a deployment_id. Harper records each deployment — phase transitions, per-node outcomes, and install output — in a deployment record you can read with the ci_deploy release role:
harper get_deployment deployment_id="$(jq -r .deployment_id deploy.json)" json=true
Only the node you called writes that record, so with restart=rolling on v5.4.0 and later it can report success before the job has visited the other nodes, and keeps reporting it if one of them rejects the release. Wait on the job, not the record, to learn whether they took the release. list_deployments gives you the history of what was deployed and when, as recorded by the node that received each deploy.
Run CI deployments in an isolated worker
Added in: v5.3.0An isolated application runs in a dedicated worker thread. You can use it for your main application or for additional copies deployed by CI.
Prepare routing before converting a live application. An isolated application stops answering on Harper's shared HTTP ports. On Fabric, add, verify, and bind its hostname as a custom domain, with a hostname used by no other application; the default Fabric hostname does not route to its dedicated worker. On a self-managed instance, configure a proxy that terminates TLS and forwards to that application's Unix socket. Sending the application's Host header to Harper's shared HTTP port does not reach it. See Reaching an isolated application.
Check the instance's isolation requirements: a nonzero threads.count, http.securePort, tls.unixDomainSockets: true, and a platform that supports Unix sockets. For a TLS certificate array, use the root tls_unixDomainSockets setting described under tls. Each isolated application adds a worker on top of the shared pool and consumes one slot in threads.maxIsolated, which defaults to 8. Budget memory and disk on every node: dedicated workers count when Harper divides its default heap limit, and each application can keep replaced releases under staging retention.
Then add isolated=true and the unique host to the package deploy in Path A or Path B; for example, Path A becomes:
harper deploy project=my-app by_ref=true \
isolated=true host=app.example.com restart=rolling json=true > deploy.json
Keep the workflow's wait step when using restart=rolling. For a single-node instance, use restart=true instead. Changing isolation makes canary certification unavailable on v5.4.0 and later; see Canary certification.
Creating an isolated application, or turning isolation on or off, also restarts the shared workers. Later redeploys that keep the application isolated restart only its worker, except an activation retry whose release is already live. A package redeploy that omits isolated preserves it; pass isolated=false to return the application to the shared pool.
Per-PR previews with shared databases
Added in: v5.3.0A preview can run each PR's code at its own hostname while sharing the cluster's databases. PR 42, for example, deploys as my-app-pr-42 at pr-42.preview.example.com; new commits replace that copy, and closing or merging the PR triggers cleanup.
The preview shares data with every application using those databases, including production if it runs on the same cluster. Its writes, schema changes, and application startup work affect that shared data. Instance-wide roles declared by the PR also apply, and background work can run once per preview. Isolation separates worker state and exported routes; it does not sandbox code or isolate database access. This recipe is for trusted PRs from branches in the same repository.
Prepare routing and preview authentication
Choose a hostname suffix such as preview.example.com. For each preview, provision DNS, TLS, and routing for pr-<number>.<suffix>:
- Fabric: add, verify, and bind each hostname as a custom domain. The workflow below deploys the application; it does not register domains. Wildcard-domain automation is still tracked under PR preview deployments, so a wildcard DNS record alone is not sufficient.
- Self-managed: configure your proxy to route each hostname to the corresponding application's socket. For
my-app-pr-42and secure port9927, the HTTP socket is<rootPath>/sockets/app-my%2Dapp%2Dpr%2D42-9927.sock. That socket serves plain HTTP; the proxy terminates TLS.
On GitHub.com, create a separate preview GitHub environment and OIDC policy instead of reusing the production policy. This workflow uses pull_request_target, runs from the trusted default branch, and does not check out or execute PR code on the runner. In Settings > Environments > preview, allow main under Deployment branches and tags, and, when sharing a production cluster, require deployment reviewers if your repository's GitHub plan supports them. Without that approval gate, every same-repository writer and automation that opens a PR can run code with access to production data. Required reviewers also gate closure and retargeting cleanup: approve those runs, or the preview remains running. Every same-repository PR closure can request that approval, even if the PR never had a preview. Before closing a previewed PR, finish or cancel any older run waiting for approval so per-PR concurrency can admit cleanup. Replace main throughout this example if your default branch has another name. See GitHub environment rules and the pull_request_target event.
Ensure your Actions execution policy allows pull_request_target for .github/workflows/preview.yml. GitHub's default protection will block that event for affected public repositories starting November 2, 2026; private and internal repositories are unaffected. See GitHub's execution-protection rollout.
Cleanup needs a direct Operations API URL for every node, rather than a balanced cluster URL. Put these URLs in the HARPER_PREVIEW_NODE_TARGETS repository variable as a JSON array, including the port and trailing slash. On a single-node instance, the array contains its one URL. On Fabric, arrange direct instance access with your administrator or support before enabling this recipe; a cluster URL alone cannot safely check each node. Keep the list current when cluster membership changes.
From your machine, logged in as a super_user, create a role that can deploy and clean up previews:
harper add_role role=ci_preview permission='{"operations":["deploy_component","drop_component","get_components","system_information"]}'
harper add_user username=ci-preview role=ci_preview active=true password="$(openssl rand -base64 32)"
harper add_oidc_trust \
id=my-app-preview \
issuer=https://token.actions.githubusercontent.com \
audience=https://my-cluster.example.com:9925/ \
user=ci-preview \
claims='{"repository_id":"67890","workflow_ref":"my-org/my-app/.github/workflows/preview.yml@refs/heads/main","environment":"preview","event_name":"pull_request_target"}'
gh variable set HARPER_PREVIEW_DOMAIN --repo my-org/my-app --body preview.example.com
gh variable set HARPER_PREVIEW_NODE_TARGETS --repo my-org/my-app \
--body '["https://node-a.example.com:9925/","https://node-b.example.com:9925/"]'
Use your repository id, workflow path, and cluster URL, as in the release policy. Add a trust policy for each node URL as well, with a unique id, that exact URL as audience, and the same user and claims. On Pro and Fabric, policies, users, and roles replicate, so you can create these policies from any node. On independent instances, authenticate as a super_user at each node and repeat the setup with target=<node URL>. Ensure each node has the preview user, role, and matching policy. The CLI requests a token for the target it calls, so a policy with only the cluster URL as its audience does not authorize node URLs. The workflow uses the existing HARPER_CLI_TARGET variable. The preview role's operations apply to the whole cluster, not just projects whose names begin my-app-pr-; trust the people allowed to run this workflow accordingly. get_components can expose component configuration and files, so keep its response out of logs and artifacts.
Deploy on PR updates and remove on closure
This example is Path A for an application that runs from source. Add .github/workflows/preview.yml to your repository:
name: Preview on Harper
on:
pull_request_target:
types: [opened, reopened, synchronize, edited, closed]
jobs:
preview:
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.action == 'closed' ||
(github.event.action == 'edited' && github.event.changes.base) ||
(github.event.action != 'edited' && github.event.pull_request.base.ref == 'main'))
runs-on: ubuntu-latest
environment: preview
timeout-minutes: 20
permissions:
contents: read
pull-requests: read
id-token: write
concurrency:
group: harper-preview-${{ github.repository }}-${{ github.event.number }}
cancel-in-progress: false
env:
HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }}
PREVIEW_DOMAIN: ${{ vars.HARPER_PREVIEW_DOMAIN }}
PREVIEW_NODE_TARGETS: ${{ vars.HARPER_PREVIEW_NODE_TARGETS }}
PR_NUMBER: ${{ github.event.number }}
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g harper
- name: Update or remove the preview
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]]
PROJECT="my-app-pr-$PR_NUMBER"
PR=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and test("^https?://"))' <<< "$PREVIEW_NODE_TARGETS" > /dev/null
if [ "$(jq -r .state <<< "$PR")" = closed ] ||
[ "$(jq -r .base.ref <<< "$PR")" != main ]; then
cleanup_node() {
export HARPER_CLI_TARGET="$1"
harper get_components json=true > components.json || return 1
harper system_information attributes='["threads"]' json=true > system.json || return 1
jq -e '.error == null and (.entries | type == "array")' components.json > /dev/null || return 1
jq -e '.threads | type == "array"' system.json > /dev/null || return 1
SHOULD_RESTART=$(jq -r --arg project "$PROJECT" 'any(.threads[]; .application == $project)' system.json) || return 1
if jq -e --arg project "$PROJECT" 'any(.entries[]; .name == $project)' components.json > /dev/null ||
[ "$SHOULD_RESTART" = true ]; then
harper drop_component project="$PROJECT" restart="$SHOULD_RESTART" replicated=false json=true > drop.json || return 1
jq -e 'type == "object" and .error == null' drop.json > /dev/null || return 1
harper get_components json=true > after-components.json || return 1
harper system_information attributes='["threads"]' json=true > after-system.json || return 1
jq -e --arg project "$PROJECT" '.error == null and (.entries | type == "array") and (any(.entries[]; .name == $project) | not)' after-components.json > /dev/null || return 1
jq -e --arg project "$PROJECT" '(.threads | type == "array") and (any(.threads[]; .application == $project) | not)' after-system.json > /dev/null || return 1
fi
}
FAILED=0
while IFS= read -r NODE_TARGET; do
if ! cleanup_node "$NODE_TARGET"; then
echo "Preview cleanup failed: $NODE_TARGET" >> "$GITHUB_STEP_SUMMARY"
FAILED=1
fi
done < <(jq -r '.[]' <<< "$PREVIEW_NODE_TARGETS")
exit "$FAILED"
fi
SHA=$(jq -r .head.sha <<< "$PR")
HOST="pr-$PR_NUMBER.$PREVIEW_DOMAIN"
harper deploy project="$PROJECT" by_ref=true ref="$SHA" \
isolated=true host="$HOST" restart=true json=true > deploy.json
echo "Preview: https://$HOST" >> "$GITHUB_STEP_SUMMARY"
The workflow reads the PR's current state, base branch, and head SHA from GitHub, rather than deploying the default-branch commit in GITHUB_SHA. Its concurrency group serializes updates and cleanup for that PR without canceling a deploy midway. A delayed run sees a closed or retargeted PR and cleans up instead of recreating the preview. The job filter ignores title/body edits and unrelated updates on other base branches, while admitting closures and base-branch edits. The fresh API read remains authoritative.
Cleanup checks component files and running workers separately on each listed node, so it also handles a worker left running after files were removed. An absent node is skipped: dropping an absent preview with restart=true can restart its shared workers. Each necessary drop uses replicated=false, and requests a restart only when that node has the preview worker. Files without a worker are dropped with restart=false; the node's shared workers stay running. It then verifies the component and worker are both absent; a successful drop response alone is insufficient. The loop attempts the remaining nodes after an error and fails the job with each failed target in the summary. After a failed, timed-out, or interrupted run, rerun cleanup and check those nodes directly with an administrator account; do not reuse the project name until cleanup has completed on every node. An omitted node cannot be checked or cleaned by this workflow.
The deploy still propagates to peers by default on Fabric and Pro, and a failed peer install fails the CLI unless ignore_replication_errors is set. Do not enable that option here. Cleanup uses direct node calls specifically to recover from asymmetric deploy or drop state.
restart=true waits for the restart on the answering node, but a successful response does not establish that every peer loaded the application: a peer restart can be slow or fail, and a node can refuse isolation. This workflow prints the URL but does not probe preview health. Check application status on each node with an administrator account and verify the routed preview endpoint. This recipe uses inline restarts instead of the production workflows' rolling job. The first deploy of each preview can restart shared workers on all nodes concurrently, disrupting existing connections. Use a dedicated preview cluster if that interruption is unacceptable; adapting the rolling workflow also requires granting get_job to the preview role and retaining its wait step.
Fork PRs are skipped; keep that same-repository condition. The policy pins workflow_ref so a PR cannot change the trusted workflow to mint a token with different commands; event_name explicitly authorizes pull_request_target, which Harper otherwise refuses unless constrained. Provision the hostname before opening the preview URL; successful deployment alone does not make an unconfigured hostname reachable. Close unused previews to release worker slots. Dropping a running isolated preview stops its worker and normally reclaims retained installs; inspect node logs if storage cleanup fails. Removing an application does not undo its shared-database writes or remove DNS and Fabric custom-domain registrations; clean up that routing separately.
Private sources and built artifacts
For a private repository, each preview project needs its own grant to a durable source credential. Once you know the PR number, run setup from your machine for that project's name, using a dedicated read-only preview token:
harper deploy setup=true provider=github project=my-app-pr-42
Then add credential=true to the workflow's deploy command. It derives deploy.my-app-pr-42.git.github.com for PR 42. A secret granted only to my-app does not authorize my-app-pr-42; the production setup cannot be reused just by changing project. The opened run may happen before this setup and fail; provision the credential, then rerun the workflow. Preview code can read secrets granted to its component, so do not grant it the production source token. Rotation must update each active preview project's secret row. Dropping the component does not delete that row: after cleanup, an administrator should remove deploy.my-app-pr-42.git.github.com with delete_secret, including its grants. Reopening after secret deletion needs setup again. Credential provisioning, rotation, and deletion remain outside this workflow.
For applications that need a build, build and publish an immutable package version for each PR commit using Path B's delivery model, and trigger the trusted deploy only after publication finishes. Keep PR builds in a separate workflow; do not check out or run PR code in this pull_request_target job. Replace by_ref=true ref="$SHA" with package="@my-org/my-app@<preview-version>", retaining project, host, isolated=true, and cleanup. Set up the registry credential for each preview project and pass its credentials reference, as in Path B. A payload upload cannot set isolated=true.
Adding private database forks later
The recipe deliberately omits branchedDatabases. On v5.3.0 and v5.3.1, a package deployment ignores that key when loading the application (harper#3071). Follow Branched databases for current behavior and limitations; database forks can be added to the recipe once that deployment path is fixed. Revisit cleanup then as well: a drop with restart=false leaves fork storage in place.
Other CI systems
The Harper CLI detects GitHub Actions' identity token only. On another CI system it falls through to its other credential sources, so give the pipeline a stored credential for the ci-deploy user instead — a refresh token from harper login --for-ci, or that user's password — and keep it in the CI system's secret store. The deploy-only role still applies: name private-source credentials with secret references, since a literal token needs super_user.
Without the CLI, call the Operations API with curl. HARPER_OPS_URL is the cluster's Operations API endpoint (https://my-cluster.example.com:9925), HARPER_OPS_AUTH is the full Authorization header value for the ci-deploy user (Basic <base64 user:password>), and RELEASE_TAG is the tag being released, such as v1.2.3:
set -euo pipefail
curl --fail-with-body -s "$HARPER_OPS_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $HARPER_OPS_AUTH" \
-d @- <<EOF | tee deploy.json
{
"operation": "deploy_component",
"project": "my-app",
"package": "github:my-org/my-app#semver:${RELEASE_TAG}",
"credentials": [{ "host": "github.com", "secret": "deploy.my-app.git.github.com" }],
"restart": "rolling"
}
EOF
#semver:v1.2.3 resolves the reference through your repository's semver tags; #<sha> pins an exact commit. For a registry deploy, use the package and credentials from Path B. set -euo pipefail stops the script at the first failed command, including a curl whose output goes through tee, so a failed deploy never reaches the wait below.
Then wait for the job the same way, polling get_job with the same header:
set -euo pipefail
JOB_ID=$(jq -r '.restartJobId // empty' deploy.json)
[ -n "$JOB_ID" ] || exit 0
LAST="could not be read"
for attempt in $(seq 60); do
sleep 10
JOB=$(curl --fail-with-body -s "$HARPER_OPS_URL" \
-H "Content-Type: application/json" \
-H "Authorization: $HARPER_OPS_AUTH" \
-d "{\"operation\": \"get_job\", \"id\": \"$JOB_ID\"}") || continue
case $(echo "$JOB" | jq -r '.[0].status // "MISSING"') in
COMPLETE) exit 0 ;;
ERROR) echo "$JOB" | jq -r '.[0].message'; exit 1 ;;
MISSING) LAST="was not on the node that answered" ;;
*) LAST="was still running" ;;
esac
done
echo "Job $JOB_ID $LAST after 60 polls"
exit 1
If your CI system issues its own OIDC tokens, a client can trade one for an operation token with exchange_oidc_token, against a trust policy that pins the token's sub (see Other issuers).
Operational notes
- Going back to a previous release. Added in: v5.3.0 When a deploy replaces the live release, Harper keeps the replaced one under its
deployment_id. Activating that id puts it back with no rebuild or reinstall —harper deploy project=my-app deployment_id=<id> restart=rolling— andlist_deploymentsshows the ids. See Going back to a previous release for which releases are kept. To deploy a commit or version that is no longer kept, re-run the deploy with its tag or version — which is why pinned references (#semver:v1.2.3,@my-org/my-app@1.2.3, a commit SHA) beat branch references (#main) in a pipeline. - Rotating the deploy credential: run
harper deploy setup=trueagain (orset_secret, or an edit in Harper Studio) with the same name and a new value. The pipeline doesn't change. - Revoking the pipeline's access: drop the trust policy with
drop_oidc_trust, or deactivate theci-deployuser withalter_user. There is no CI secret to rotate. - Git-host credentials require git ≥ 2.31 on the Harper nodes, and are not supported on Windows nodes (the deploy fails with a clear error rather than degrading security). Registry credentials have no such constraints. Fabric instances satisfy both.
- Self-managed Harper core without the Pro secrets component:
secretreferences can't be resolved (no decryption custody), and a literaltokenis used transiently for that node's install only — it is not persisted for later installs. The durable-credential patterns in this guide assume custody (Fabric provides it).
Additional Resources
- CLI Authentication: workload identity (OIDC) — the client half of the exchange, and where it sits in credential precedence
- OIDC trusted publishing —
add_oidc_trustand the rules a policy must satisfy harper deploy— every CLI deploy parameterdeploy_componentand deploy credentials reference- Secrets store reference — the encrypted store behind
secretreferences - Deployment records —
list_deployments,get_deployment - GitHub: OpenID Connect in GitHub Actions
- GitHub: fine-grained personal access tokens
- GitHub Packages: npm registry