Beta. The App receives the same events and runs the same handlers as the action; it is new, and its deployment story is still settling. The action remains the recommended install.
The action runs inside each repository's own workflow. The App instead receives
webhooks for every repository an organization installs it on, mints an
installation token per event, and runs the same /commands, labelling, OWNERS
and automatic-merging code. Differences that matter:
- One install, no workflow file. Nothing to copy into each repository.
- Fork pull requests are first class. The installation token can write on a
fork's
pull_requestandpull_request_reviewevents, so thesweepjob the action needs for them is unnecessary. - Events the App creates trigger your workflows. Actions taken with
GITHUB_TOKENdo not; an App's do, so a label the App applies can start CI. - Scheduled jobs run for every installation at once.
node dist-server/index.js cronruns thePROW_JOBSjobs once for each repository the App is installed on and exits; or setPROW_CRON_INTERVALto run them inside the server. See Scheduled jobs. /label-syncreplaces the Run workflow button. A collaborator comments it on any issue or pull request; list it inPROW_COMMANDSto enable it.- No run log. Each event logs JSON lines to stdout; there is no Actions UI.
Settings → Developer settings → GitHub Apps → New GitHub App.
| Setting | Value |
|---|---|
| Webhook URL | https://<your host>/webhook |
| Webhook secret | a long random string; keep it for GITHUB_WEBHOOK_SECRET |
| Repository permissions | Contents write, Issues write, Pull requests write, Commit statuses write, Actions write, Checks read, Metadata read |
| Organization permissions | Members read (for @org/team entries in OWNERS) |
| Subscribe to events | Issues, Issue comment, Pull request, Pull request review, Check suite, Status, Push |
Generate a private key and download it. Note the App ID. Install the App on the organization (all repositories, or the ones Prow should manage).
| Variable | Required | Meaning |
|---|---|---|
GITHUB_APP_ID |
yes | the App ID |
GITHUB_APP_PRIVATE_KEY |
yes | the PEM; a single line with literal \n is accepted. PKCS#1 as GitHub serves it works on Node; Workers need PKCS#8 (see below) |
GITHUB_WEBHOOK_SECRET |
yes | the webhook secret |
GITHUB_API_URL |
no | the REST root for GitHub Enterprise Server, e.g. https://ghes.example/api/v3 |
PORT |
no | listening port, default 8080 |
REDIS_URL |
for several replicas | shared dedupe and locks, see One replica or several |
PROW_SHUTDOWN_DRAIN_MS |
no | how long the server keeps serving after SIGTERM, with /readyz at 503, default 5000 |
PROW_SHUTDOWN_GRACE_MS |
no | how long it then waits for in-flight events, default 25000 |
PROW_COMMANDS |
no | same as the action's prow-commands input |
PROW_JOBS |
no | same as jobs; what the scheduler runs. Default lgtm |
PROW_CRON_INTERVAL |
no | minutes between scheduled runs inside the server; unset means none (use the cron command) |
PROW_MERGE_METHOD |
no | same as merge-method |
PROW_CONFIG |
no | same as config |
PROW_DRY_RUN |
no | same as dry-run |
CAT_API_KEY |
no | same as cat-api-key |
prow.yaml is read exactly as for the action: the organization's .project or
.github repository, then the repository's own file.
The chart and the image are published with every release, signed with cosign and carrying SLSA provenance:
kubectl create secret generic prow-github \
--from-literal=appId=123456 \
--from-literal=webhookSecret="$(openssl rand -hex 32)" \
--from-file=privateKey=prow.private-key.pem
helm install prow oci://ghcr.io/cncf/prow-github-actions/charts/prow-github-app \
--version 3.4.0 \
--set github.existingSecret=prow-github \
--set ingress.enabled=true --set ingress.className=nginx \
--set 'ingress.hosts[0].host=prow.example.com' \
--set 'ingress.hosts[0].paths[0].path=/webhook' --set 'ingress.hosts[0].paths[0].pathType=Exact'The webhook URL for the App is https://prow.example.com/webhook. The chart
creates a Deployment (liveness /healthz, readiness /readyz), a Service, the
Ingress above, a ServiceAccount, and a CronJob that runs the scheduled jobs
hourly (cronjob.schedule, prow.jobs). The prow.* values are the action's
inputs under other names; github.apiUrl points at a GitHub Enterprise Server.
helm show values oci://ghcr.io/cncf/prow-github-actions/charts/prow-github-app --version 3.4.0
lists everything.
Two topologies:
# one replica: nothing shared, Recreate strategy; the default
replicaCount: 1# several replicas: a Redis every replica shares; the chart refuses replicaCount > 1 without one
replicaCount: 3
redis:
url: rediss://my-redis:6380 # or existingSecret / existingSecretKeyThe chart ships no Redis: any Redis 6+ or Valkey will do, and for development
kubectl create deployment redis --image=redis:7-alpine --port=6379 && kubectl expose deployment redis
gives one at redis://redis:6379. Upgrade with helm upgrade prow … --version X.Y.Z;
the pods roll when configuration or inline credentials change.
One-time step when upgrading from a chart before 3.5.0. The Deployment, the Service and the PodDisruptionBudget now select
app.kubernetes.io/component: server, so a CronJob run (labelledapp.kubernetes.io/component: jobs) is never sent webhook traffic or counted by the PDB. A Deployment's selector cannot change in place, so delete the Deployment once before the upgrade and let Helm recreate it (expect a short gap in webhook handling; GitHub's redelivery covers it):kubectl delete deployment prow-prow-github-app # <release>-prow-github-app helm upgrade prow oci://ghcr.io/cncf/prow-github-actions/charts/prow-github-app --version X.Y.Z …
The values schema refuses keys the chart does not know, so a misspelled value
fails the install instead of being ignored. A CronJob run is killed after
cronjob.activeDeadlineSeconds (3000, under the hourly default schedule), so a
hung run cannot hold back the next one.
Verify what you install:
cosign verify ghcr.io/cncf/prow-github-actions:3.4.0 \
--certificate-identity-regexp="https://github.com/cncf/prow-github-actions" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
gh attestation verify oci://ghcr.io/cncf/prow-github-actions:3.4.0 --repo cncf/prow-github-actions
gh attestation verify oci://ghcr.io/cncf/prow-github-actions/charts/prow-github-app:3.4.0 --repo cncf/prow-github-actionsdocker run -p 8080:8080 \
-e GITHUB_APP_ID=... -e GITHUB_WEBHOOK_SECRET=... \
-e GITHUB_APP_PRIVATE_KEY="$(cat prow.private-key.pem)" \
ghcr.io/cncf/prow-github-actions:3.4.0(or docker build -t prow-app . from the repository.) GET /healthz answers
ok (liveness); GET /readyz answers ok while the stores below are reachable,
and 503 when they are not or the server is shutting down (readiness).
POST /webhook verifies X-Hub-Signature-256, answers 202 at once and
handles the event afterwards; a body above GitHub's 25 MB cap gets 413, and an
event about no repository or installation (installation,
github_app_authorization and the like) gets 202 ignored. A pull request's
events run one at a time, in order, as the action's concurrency: group
arranges; other events run at once. A redelivered X-GitHub-Delivery id is
handled once it has succeeded; one whose handling failed runs again. On
SIGTERM the server answers /readyz with 503 and keeps serving for
PROW_SHUTDOWN_DRAIN_MS (default 5 000), then stops accepting, lets in-flight
events finish (PROW_SHUTDOWN_GRACE_MS, default 25 000) and exits. The chart
gives the pod 75 s (terminationGracePeriodSeconds): a 5 s preStop sleep,
the drain, and 15 s less than the pod's grace for in-flight events. For local testing, relay deliveries with smee.io or
gh webhook forward.
| one replica | several replicas | |
|---|---|---|
| delivery dedupe and the per-pull-request lock | in process | in a Redis every replica shares |
| configuration | nothing | REDIS_URL=redis://host:6379 (or rediss://) |
Without REDIS_URL, two replicas would each handle a redelivery and could race
on one pull request; the server warns at startup when PROW_REPLICAS is above
1 and REDIS_URL is unset. Any Redis 6+ or Valkey works; the server uses two
keys per event and nothing else: prow:delivery:<id> (ten minutes while the
event runs, a day once it has succeeded, removed if it failed) and
prow:lock:<owner/repo#n> (a minute, renewed while its holder runs; another
event on the pull request waits up to five minutes for it).
The action's schedule: trigger has no webhook equivalent, so the App runs the
jobs itself:
# once, for every installation and repository, then exit 0 (1 if any repository failed)
node dist-server/index.js cronThe chart's CronJob does exactly this (cronjob.schedule, hourly by default).
For a single container with no scheduler, PROW_CRON_INTERVAL=60 runs the jobs
inside the server every hour instead; a tick that comes while the previous run
is still going is skipped and logged.
Each repository runs under its own installation token and state, four at a time;
a repository that fails is logged and reported, and never stops the others.
Suspended installations and archived repositories are skipped. An installation whose token
cannot be minted or whose repositories cannot be listed is logged (installationId and the
error) and skipped; the other installations still run.
PROW_JOBS defaults to lgtm, the merger. The action also offers sweep, which
exists because GITHUB_TOKEN cannot write on a fork's pull_request events; the
App's token can, so the App handles those events directly and does not need
sweep. label-sync on a schedule works but is usually better run on a push
to prow.yaml (automatic) or with the /label-sync command.
The same adapter runs as a Worker, with the platform's services standing in for what a container does in memory:
| Concern | Container | Worker |
|---|---|---|
| Delivery dedupe | process memory | KV (DELIVERIES, 24 h) |
| Ordering a pull request's events | one replica | a Durable Object per pull request (PULL_REQUEST_LOCK), which runs them one at a time |
| Events about no issue or pull request | handled in the request | a Queue message, retried if its run fails |
| Scheduled jobs | cron command / interval |
Cron Trigger lists the installations → one Queue message per installation, which lists its own repositories |
The require_matching_label grace period |
waits in place | a delayed Queue message resumes the check, inside the pull request's Durable Object |
Set up once, from the repository (wrangler.toml is committed):
npx wrangler kv namespace create DELIVERIES # paste the id into wrangler.toml
npx wrangler queues create prow-jobs
openssl pkcs8 -topk8 -nocrypt -in prow.private-key.pem -out prow.pkcs8.pem # Workers sign with WebCrypto, which needs PKCS#8
npx wrangler secret put GITHUB_APP_ID
npx wrangler secret put GITHUB_APP_PRIVATE_KEY < prow.pkcs8.pem
npx wrangler secret put GITHUB_WEBHOOK_SECRET
npx wrangler deployThe webhook URL is https://<worker>.<account>.workers.dev/webhook. PROW_*
variables go under [vars]. Logs are JSON lines in Workers Logs. The Worker
answers 202 once the event is handed to its pull request's Durable Object or
to the queue. A queued run that fails is retried (max_retries), then
goes to the prow-jobs-dlq dead-letter queue, which Cloudflare creates on
first use; its failures are in the logs either way.
src/adapters/app/ implements the Platform the handlers run against (see
overview); src/server/node.ts is the HTTP
server and src/server/worker.ts the Worker. npm run pack:server builds
dist-server/index.js and npm run pack:worker dist-worker/worker.js; neither
is committed. __tests__/worker/ runs the Worker bundle in miniflare;
wranglerConfig.test.ts there also runs it under the bindings wrangler.toml
declares and keeps the file in step with what src/server/worker.ts reads and
with the setup commands above.