Deploy a Harper application from GitHub Actions, and give every pull request its own preview at https://<branch>.app.<cluster-domain>/.
The action authenticates with the job's OIDC identity token and nothing else. You store no username, password or token: the cluster trusts your workflow file, through a trust policy that a one-time setup command writes. The action talks only to your cluster and to GitHub.
Run this once from a checkout of the repository, signed in to the cluster as a super_user (harper login). It creates a deploy role and user and the trust policy github-actions-<app>, and sets the HARPER_CLI_TARGET repository variable:
harper deploy setup=true provider=github-actions project=shopThen commit .github/workflows/deploy.yaml, the file and the production environment that the policy pins:
name: Deploy
on:
push:
branches: [main]
permissions: {}
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_CLI_TARGET }}
app: shop
by_ref: trueWith by_ref: true the cluster fetches the commit itself, so the job needs no checkout. A private repository also needs credential: true, once harper deploy setup=true has sealed a credential.
An application that needs a build deploys a directory instead. Check out the code, build it, and name the output as path:
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_CLI_TARGET }}
app: shop
path: distPreviews need Harper 5.4 on the cluster.
Commit this as .github/workflows/preview.yaml on your default branch. GitHub runs pull_request_target workflows from there, so a PR cannot change what runs with the preview identity.
name: Preview
on:
pull_request_target:
types: [opened, reopened, synchronize, closed]
branches: [main]
permissions: {}
jobs:
deploy:
if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview
permissions:
id-token: write
pull-requests: write
deployments: write
concurrency:
group: harper-preview-${{ github.event.pull_request.number }}
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: shop
preview: true
cleanup:
if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: write
deployments: write
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: shop
preview: trueThen run setup once from a checkout that has that file, signed in as a super_user:
harper preview setup app=shopSetup reads the workflow and refuses one that would give a fork or a PR's code the preview identity. It then enables previews on the cluster, which creates the role and user <app>-ci-preview and the trust policies github-actions-<app>-preview (environment preview) and github-actions-<app>-preview-cleanup (environment preview-cleanup, drop only). It also sets the HARPER_PREVIEW_TARGET repository variable. To remove it all, previews included, run harper preview teardown app=shop.
Some setup options have a matching input:
harper preview setup |
Action input |
|---|---|
domain=<domain> |
domain: <domain> |
dataMode=shared |
branched_databases: false |
credential=true |
credential: true |
A public repository also has to allow pull_request_target.
| Event | What the action does |
|---|---|
opened, reopened, synchronize |
Deploys the PR's head commit by reference, only while the PR is open at that commit. Keeps one PR comment with the URL, updated in place, and a GitHub Deployment with environment_url. If the PR closed during the deploy, it drops what it deployed. |
closed |
Drops the preview, marks its Deployments inactive and updates the comment. It retries for two minutes while a deploy still holds the preview, and a PR that was reopened keeps its preview. |
schedule, workflow_dispatch |
Sweeps the previews of closed PRs. |
Anything from a fork, or another pull_request action |
Nothing. The cluster is never called. |
The PR comment and the Deployment are best effort: if either fails, the run warns and the deploy still counts. Deployments use the GitHub environment <app>-preview (deployment_environment), kept apart from the preview environment that gates the job, so its required reviewers and its own deployment records stay out of it.
Required reviewers on the preview environment gate every deploy, which you want when previews share a cluster with production. They must not gate cleanup, or a closed PR's preview waits for an approval. So cleanup runs in preview-cleanup, which should have no required reviewers, under a policy that can only drop. It also stays out of the deploy's concurrency group, where a deploy waiting for approval would hold it up. Keep that group on the deploy job: two deploys of one PR must not overlap, or the older one can leave its commit on the cluster. A deploy that is still running when the PR closes drops its own preview when it finishes.
In Settings > Environments, allow your default branch under Deployment branches and tags on both environments.
The cluster expires every preview on its own: seven days after its last deploy by default, or the application's maxTtlDays. That already covers a missed close event. A sweep removes those previews sooner.
The policies harper preview setup writes pin event_name: pull_request_target, so a scheduled run cannot use them, and a sweep also has to list what is deployed. It needs its own workflow file and its own trust policy.
Commit .github/workflows/preview-sweep.yaml:
name: Preview sweep
on:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions: {}
jobs:
sweep:
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: write
deployments: write
steps:
- uses: HarperFast/deploy-action@v1
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: shop
preview: trueThen add the policy as a super_user, with your repository's numeric id (gh repo view --json databaseId), name, branch and target:
harper add_oidc_trust id=github-actions-shop-preview-sweep \
issuer=https://token.actions.githubusercontent.com \
audience=https://cluster.example.com:9925/ \
user=shop-ci-preview \
'operations=["get_components","drop_component"]' \
'claims={"repository_id":"123456789","workflow_ref":"acme/shop/.github/workflows/preview-sweep.yaml@refs/heads/main","event_name":["schedule","workflow_dispatch"],"environment":"preview-cleanup"}'harper preview teardown does not know about this policy, so drop it yourself: harper drop_oidc_trust id=github-actions-shop-preview-sweep.
The preview job must not build a PR: that would run the PR's code with the preview identity. Publish each PR commit from a separate, unprivileged pull_request workflow, then deploy the result from the preview job, with the package or by pointing path at a downloaded artifact:
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: shop
preview: true
args: package=npm:@acme/shop@pr-${{ github.event.pull_request.number }}With a package the commit is still recorded on the preview. For a directory, also set by_ref: false.
GitHub's workflow execution protections add a default policy that blocks pull_request_target in public repositories that have no applicable event policy. It does not apply to private or internal repositories. GitHub first announced enforcement for November 2, 2026, and moved it to December 2, 2026. Once enforced, a blocked run fails with an error.
To keep previews working in a public repository, allow the event for the preview workflow. The policy lives on each repository, and needs a repository admin. An organization owner can write one that covers several repositories instead.
examples/preview-actions-policy.json is that policy. Create it with jq and the gh CLI, replacing OWNER/REPO:
curl --fail --silent --show-error --location https://raw.githubusercontent.com/HarperFast/deploy-action/main/examples/preview-actions-policy.json | jq '{name, enforcement, conditions, rules}' | gh api --method POST repos/OWNER/REPO/actions/policies --header 'X-GitHub-Api-Version: 2026-03-10' --input -If your preview workflow has another name, change the include path in a copy of the file, and run the same jq and gh api on that copy.
Download that file, then import it from the repository's Settings > Actions > Policies page. Check the form against the steps below, and click Create.
- Go to the repository's Settings > Actions > Policies, and create a new policy.
- Name it, and set Enforcement status to Active. A new policy can start out disabled, which allows nothing.
- Under Target workflows, use Add target to add the path
.github/workflows/preview.yaml. - Turn on Restrict events, and add
pull_request_targetto Allowed events. - Click Create.
The policy's event list applies to that workflow alone, and it becomes the only list of events that can start it. So if your preview workflow has other triggers, add them too. Other workflows are not affected.
Policy insights, under the same menu, shows which runs were or would have been blocked. An enterprise or organization policy can still block the event; see Control workflow execution.
| Input | Default | Description |
|---|---|---|
target |
required | The cluster's operations API URL. The trust policy's audience has to match it. Only a loopback address may use http. |
app |
required | The application's component name. |
preview |
false |
Run the preview lifecycle instead of a normal deploy. |
path |
. |
The directory to deploy as a payload, when not deploying by reference. |
by_ref |
see description | Have the cluster fetch the commit. Defaults to true for previews and when ref is set, otherwise false. |
ref |
The commit, branch or tag to deploy by reference. Previews always deploy the PR's head commit. | |
restart |
rolling |
How a normal deploy restarts Harper, as the CLI's restart= argument. |
credential |
false |
Use the sealed git credential to fetch a private repository. |
domain |
app.<target host> |
The preview domain. |
label |
A DNS label added to the preview host, to tell apart previews of several applications on one cluster. | |
ttl |
the CLI's, 7 days | Days until the cluster expires the preview, capped by the application's maxTtlDays. |
branched_databases |
true |
Give each preview its own database branch. false shares the cluster's data. |
comment |
true |
Keep one PR comment with the preview URL. |
github_deployment |
true |
Record each preview as a GitHub Deployment. |
deployment_environment |
<app>-preview |
The GitHub environment for those Deployments. |
args |
More CLI arguments for the deploy, one key=value per line. |
|
harper_version |
>=5.4.0-alpha.1 <6 |
The npm version or range of the harper CLI. |
timeout_minutes |
30 |
The longest each CLI command may run before the action stops it and fails. |
node_version |
24 |
The Node.js version for the CLI, left on PATH for later steps. Empty uses the runner's, which must be 22.18 or later. |
github_token |
github.token |
The token for PR comments and Deployments. |
| Output | Description |
|---|---|
outcome |
deployed, dropped (the PR is closed), kept (the PR reopened, so its preview stays), swept, or skipped (a fork, a superseded commit, or an action with nothing to do) |
url |
The preview URL, when one deployed. |
| Permission | Needed for |
|---|---|
id-token: write |
Authenticating to the cluster. Always required. |
pull-requests: write |
Reading the PR's state and keeping the PR comment. |
deployments: write |
GitHub Deployments. |
Start from permissions: {} at the top of the workflow, as the examples do. A normal deploy needs only id-token: write, plus contents: read if it checks out a private repository.
- The workflow is trusted, not the PR. Preview jobs run from the default branch and never check out PR code, and the action refuses
preview: trueonpull_request. The trust policy pins the workflow file, the branch,pull_request_targetand the environment. - Forks never get the identity. The
if:on each job is the gate: no token claim names the head repository. The action also skips a fork itself. - The PR's code still runs on the cluster, with the preview role's rights. That role can deploy and drop only that application's
<app>-pr-<n>previews. Withoutbranched_databases: false, a preview's writes stay in its own database branch, except in thesystemdatabase (users and roles), which cannot be branched. - A preview is the same site as what production serves from the cluster's host name. Use
__Host-cookie names and CSRF orOriginchecks there. - OIDC only. The CLI would prefer any other credential to the identity token. So the action strips
HARPER_CLI_*andCLI_TARGET_*credentials from its environment and refuses them intargetandargs. It gives the CLI an empty home directory, where noharper loginis saved, and refuses a deployed directory whose.envsets one. Previews by reference start the CLI in an empty directory. - The CLI is installed fresh, into an empty directory, with install scripts off, so a project's
.npmrchas no say in it.
- Runs on Linux and macOS runners, not Windows.
.github/workflows/preview.yamlis the workflow name the default policies pin. Another name needsworkflow=onharper preview setup.- Each application on a cluster needs its own preview slug: when two applications preview the same branch name, set
labelon one of them.