GitHub Actions Flow for Review Apps, Staging, and Production
This document describes the reusable GitHub Actions scaffolding generated by cpflow generate-github-actions.
The goal is to bring the Heroku Flow model into any cpflow project:
- Comment
+review-app-deployon a pull request to create or update a review app. - Push more commits to the PR to auto-redeploy that review app.
- Push to the staging branch to auto-deploy staging.
- Promote the already-built staging artifact to production from the Actions tab.
- Let a nightly workflow clean up stale review apps.
For public repositories, review apps are intentionally limited to branches in the base repository. Fork pull requests can receive help comments, but generated deploy workflows skip fork heads because review-app deployment builds Docker images with repository secrets. The skip is enforced by complementary guards on different trigger axes; see Review app security for repositories with external contributors before customizing these workflows. If a forked change needs a review app, a maintainer should first review the code and move the change into a trusted branch in the base repository.
Quick Start
End-to-end rollout in one view:
cpflow github-flow-readiness— exits non-zero if the repo is not ready to deploy.cpflow generate— creates.controlplane/if missing.cpflow generate-github-actions— addscpflow-*workflow wrappers. Review-app, staging, cleanup, and helper workflows call upstream reusable workflows; production promotion is a normal caller-repo job so it can own the protected production Environment.- Configure the GitHub Actions secrets and variables the workflows expect.
- Push the branch, then comment
+review-app-deployon a PR to spin up a review environment.
AI rollout: copy the AI rollout prompt when you want an agent to run this setup. The prompt works whether cpflow is already installed or the agent needs to install it first. If cpflow is already available in the target repo, cpflow ai-github-flow-prompt prints the same prompt with the default app prefix filled in.
See Bootstrap a Project for command details, Repo Readiness Checklist for what "ready" means, and AI Playbook to run the rollout through an agent.
Bootstrap a Project
Run these commands from the project root:
# Check the repo for common rollout blockers before generating files
cpflow github-flow-readiness
# Print the current AI rollout prompt for this repo, if you want to hand it to an agent
cpflow ai-github-flow-prompt
# Create .controlplane/ if it does not exist yet
cpflow generate
# Add reusable GitHub Actions for the Control Plane flow
cpflow generate-github-actions
# Or, run this instead when staging should trigger from a branch other than main/master:
cpflow generate-github-actions --staging-branch develop
These local bootstrap commands do not require cpln to be installed yet. Install and
log into the Control Plane CLI before any command that talks to the real platform.
cpflow github-flow-readiness is the fastest gate: it exits non-zero when the repo is
missing a production Dockerfile, missing Rails runtime files, pinned to a legacy
Ruby or Bundler toolchain, or depends on exact-pinned gem or npm versions that do
not appear to exist in the public registries.
The second command writes namespaced files so they can coexist with an app's existing CI:
.github/cpflow-help.md.github/workflows/cpflow-review-app-help.yml.github/workflows/cpflow-help-command.yml.github/workflows/cpflow-deploy-review-app.yml.github/workflows/cpflow-delete-review-app.yml.github/workflows/cpflow-deploy-staging.yml.github/workflows/cpflow-promote-staging-to-production.yml.github/workflows/cpflow-cleanup-stale-review-apps.ymlbin/pin-cpflow-github-refbin/test-cpflow-github-flow
cpflow generate also infers the app prefix from the repo directory, infers the
Docker base Ruby version from .ruby-version, .tool-versions, or the app's
Gemfile, preserves repo-defined frontend precompile hooks such as Shakapacker
precompile_hook commands or React on Rails auto bundle generation, and
switches to persistent SQLite db and storage templates when
config/database.yml shows SQLite in production.
cpflow github-flow-readiness checks public RubyGems and npm registry metadata
for exact-pinned direct dependencies. In air-gapped or egress-restricted
environments those checks may report unknown instead of failing hard; confirm
the deployment runner has the package access your app needs before rollout.
Repo Readiness Checklist
Before generating this flow, confirm that the target repository is already a deployable application rather than a partial sample:
- the repo can be cloned and installed from scratch with published gem and npm package versions
- the repo does not depend on unpublished or inaccessible package versions unless the deployment flow also provisions the credentials needed to fetch them
- the repo is not just a historical generator snapshot pinned to an obsolete Ruby or Bundler toolchain with no validated production build path
- the app has its real runtime scaffold checked in, for example a complete Rails
app with the boot files needed to run
bin/railsandbin/dev - the repo root maps to one deployable app; multi-app monorepos need a separate rollout decision before using this one-app-per-repo flow
- the production Dockerfile can build the app's assets and any SSR or renderer bundles that production needs
- any repo-defined frontend codegen or precompile hooks are preserved before
rails assets:precompile - the runtime workloads, release command, and required secrets are known well
enough to model in
.controlplane/
If any of those fail, stop and fix the application first. Do not merge
cpflow-* workflows into a repository that is not yet runnable from a clean
clone, because the result will be a misleading "deployment flow" for an app that
still cannot build or boot.
Required .controlplane/controlplane.yml Structure
The generated workflows assume that .controlplane/controlplane.yml defines:
- one staging app
- one review-app prefix with
match_if_app_name_starts_with: true - one production app with
upstreampointing to staging
Typical shape:
aliases:
common: &common
cpln_org: my-org-staging
default_location: aws-us-east-2
setup_app_templates:
- app
- postgres
- redis
- rails
app_workloads:
- rails
additional_workloads:
- postgres
- redis
apps:
my-app-staging:
<<: *common
my-app-review:
<<: *common
match_if_app_name_starts_with: true
hooks:
post_creation: bundle exec rails db:prepare
# pre_deletion intentionally omitted for shared databases: `cpflow delete` runs it before removing the workloads,
# so live connections can block the drop. Prefer admin-side cleanup. See docs/tips.md ("Share One Control Plane Postgres").
my-app-production:
<<: *common
allow_org_override_by_env: false
allow_app_override_by_env: false
cpln_org: my-org-production
upstream: my-app-staging
release_script: release_script.sh
Important points:
match_if_app_name_starts_with: trueis what allows a single config entry to backmy-app-review-123,my-app-review-456, and cleanup commands likecpflow cleanup-stale-apps -a my-app-review.- Review-app deploy, delete, and cleanup workflows infer the review app prefix from the single app entry with
match_if_app_name_starts_with: true. - Review-app workflows infer the staging Control Plane org from that review app entry's
cpln_org. upstream: my-app-stagingis what lets the production promotion workflow copy the exact staging artifact.- If your main web workload is not named
rails, set the optionalPRIMARY_WORKLOADrepository variable described below. - For public demos, starter staging apps, and long-lived review apps, prefer
capacityAI: truewith one warm replica and the workload's autoscaling metric disabled, so Control Plane can right-size CPU and memory allocation at that fixed replica count. See Enable Capacity AI for Demo and Starter Staging Apps.
GitHub Actions Secrets and Variables
GitHub Actions Secrets
For a normal generated review-app setup, configure one GitHub Actions secret:
CPLN_TOKEN_STAGING: service-account token scoped to the staging Control Plane org on controlplane.com. Its policies should allow only review/staging CI operations; it must not read production secrets or manage production workloads.
GitHub Actions Variables
No GitHub Actions variables are required for review apps when .controlplane/controlplane.yml
has exactly one review app entry with match_if_app_name_starts_with: true and
that entry has a cpln_org. The inferred values come from that config file:
the review-app prefix is the app key with match_if_app_name_starts_with: true,
and the staging org is that app's cpln_org value. Set these variables only
when you need to test a fork or clone against a different Control Plane org,
choose a different review-app prefix, expose a different public workload, or
disambiguate generated review-app config:
CPLN_ORG_STAGING: Control Plane org on controlplane.com for staging and review apps. Overrides the org inferred fromcpln_org, for examplecompany-stagingREVIEW_APP_PREFIX: override the inferred review-app prefix; required only when multiple review app prefixes exist incontrolplane.ymlPRIMARY_WORKLOAD: override the public workload used to discover the public endpoint and do review/production health checks; defaults torailsREVIEW_APP_HEALTH_CHECK_RETRIES: override review-app health polling attempts; defaults to24REVIEW_APP_HEALTH_CHECK_INTERVAL: override seconds between review-app health attempts; defaults to15REVIEW_APP_HEALTH_CHECK_ACCEPTED_STATUSES: override space-separated healthy HTTP statuses for review apps; defaults to200 301 302REVIEW_APP_HEALTH_CHECK_CURL_MAX_TIME: override per-request review-app curl timeout in seconds; defaults to10
If controlplane.yml defines more than one app with
match_if_app_name_starts_with: true, inference intentionally fails. Set
CPLN_ORG_STAGING and REVIEW_APP_PREFIX to tell the workflow which review-app
family to manage.
For staging deploys, also configure:
CPLN_ORG_STAGING: staging org name, for examplecompany-stagingSTAGING_APP_NAME: staging GVC name, for examplemy-app-stagingSTAGING_APP_BRANCH: optional branch that auto-deploys staging. If you use a custom branch, either pass it tocpflow generate-github-actions --staging-branch BRANCHduring generation or editcpflow-deploy-staging.ymlso itson.push.brancheslist includes the same branch.
For production promotion, also configure:
- a GitHub Environment named
production - required reviewers on that environment, limited to the people or team allowed to promote production
- "Prevent self-review" on that environment, so the person who starts the promotion cannot approve it
- optionally disable administrator bypass and restrict deployment branches/tags to your protected release branch
CPLN_TOKEN_PRODUCTIONas an environment secret onproduction, not as a repository or organization secretCPLN_ORG_PRODUCTIONas a production environment variable, for examplecompany-productionPRODUCTION_APP_NAMEas a production environment variable, for examplemy-app-production
Enter GitHub variables such as CPLN_ORG_STAGING,
CPLN_ORG_PRODUCTION, STAGING_APP_NAME, and PRODUCTION_APP_NAME
as plain single-line values. The generated production promotion workflow trims
accidental leading/trailing whitespace and line endings from Control Plane org
names before building registry URLs, but embedded line breaks are rejected
because they could change the target org name after normalization.
Production promotion copies the exact image currently deployed on the selected
staging workload. If that staging image is digest-pinned, the digest is used for
the source copy while the production tag is derived from the tag portion. Tags
with a _<commit> suffix keep that suffix in production; plain numeric tags are
also valid and promote to the next plain production tag. The copy step uses
docker buildx imagetools create --prefer-index=false --tag with isolated
Docker credentials, which preserves multi-architecture manifests, preserves
single-platform manifest format when supported, and avoids pulling image layers
onto the GitHub Actions runner.
Before copying the image, production promotion compares the environment variable names exposed by staging and production at both the GVC level and each configured app workload's container level. Variables present in staging are treated as required for production, while production-only variables emit warnings. A missing production workload variable such as a renderer password or runtime secret fails the promotion before the image copy starts.
Do not put CPLN_TOKEN_PRODUCTION in repository or organization secrets for
sensitive production systems. Production promotion intentionally runs as a
normal caller-repo workflow job with environment: production, then checks out
the pinned control-plane-flow release for shared actions. GitHub exposes the
production token to that job only after the production environment gate.
GitHub does not expose which secret scope supplied a nonempty value at runtime,
so a broader repository or organization secret with the same name can mask a
missing environment secret. Keep the production token absent from broader secret
scopes.
Do not move production promotion behind a cross-repo reusable workflow. GitHub
does not expose the caller repository's environment secrets to that called
workflow, so secrets.CPLN_TOKEN_PRODUCTION remains empty even when the
production Environment contains the secret. Generated reusable-workflow
callers still pass only the named secrets each upstream workflow needs and do
not use secrets: inherit; production promotion is the caller-owned exception.
If promotion fails in the Validate production token step with
CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the 'production' GitHub Environment.,
check the environment scope first. Also verify that the promote-to-production
job declares environment: production and that no same-named repository or
organization secret exists. Create or verify the environment secret with:
You need permission to manage repository environments and secrets to run these
commands.
gh secret set CPLN_TOKEN_PRODUCTION --repo OWNER/REPO --env production
# Paste the token value when prompted.
gh secret list --repo OWNER/REPO --env production
gh secret list --repo OWNER/REPO
gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true
First-Time Control Plane Bootstrap
GitHub settings only give the workflows permission to act. They do not create the persistent staging or production GVCs for you on the first merge.
Before the first staging deploy, bootstrap the staging app once:
cpflow setup-app -a my-app-staging --org my-org-staging --skip-post-creation-hook
setup-app reads the setup_app_templates list from
.controlplane/controlplane.yml. It creates the persistent staging GVC,
workloads, app identity, app secret dictionary, app secret policy, and policy
binding that grants the app identity reveal permission on that dictionary.
Use --skip-post-creation-hook for first-time bootstrap so a database hook does
not try to run before the first image exists.
After the persistent app exists, use apply-template for later template
updates. Adjust the template list to match your repo, such as adding worker,
sidekiq, renderer, redis, or other templates present under
.controlplane/templates:
cpflow apply-template app postgres rails -a my-app-staging --org my-org-staging --yes --add-app-identity
If you use apply-template to create or repair an existing app, also confirm
that the app identity has reveal permission on the app secret policy. Without
that binding, workloads that reference cpln://secret/<app-secrets>.* stay
paused until the policy is fixed.
Before the first production promotion, run the same kind of bootstrap for the production app in the production org:
cpflow setup-app -a my-app-production --org my-org-production --skip-post-creation-hook
Use production-only runtime secrets and values for the production app. The
protected GitHub Environment controls who can run the promotion workflow, but
the production app resources still need to exist before the first promotion.
After bootstrap, populate the production app secret dictionary with the values
referenced by .controlplane/templates, then run cpflow apply-template against
production when templates change so the workload env references remain persisted.
Production promotion checks for missing GVC and workload container env names
before copying the staging image, so a staging-only runtime variable will stop
the run early instead of deploying an image that cannot boot.
Review apps are different: the generated +review-app-deploy workflow creates
temporary PR apps as needed, including the identity and secret policy binding.
You still need the shared review-app runtime secret values described by your
templates, and the staging token must have access to create and update
review-app GVCs, workloads, images, identities, policies, and secrets in the
staging org.
If review apps share an existing staging database or another existing secret,
declare it with shared_secret_grants on the review app config entry. The
deploy workflow runs setup-app for new review apps and deploy-image for
image updates; those commands bind or repair the review app identity's reveal
permission on each configured shared policy. The delete and cleanup workflows
call cpflow delete, which removes those bindings as review apps go away. This
lets one shared database or license secret serve many short-lived review apps
without granting every review identity access to unrelated app secrets.
apps:
my-app-review:
match_if_app_name_starts_with: true
shared_secret_grants:
- name: database
secret_name: my-app-review-database-secrets
policy_name: my-app-review-database-secrets-policy
Then reference cpln://secret/{{SHARED_SECRET_DATABASE}}.DATABASE_URL from the
workload template.
Production Promotion Safety
CPLN_TOKEN_PRODUCTION can change live production workloads, images, releases,
and rollback state. Treat it differently from review-app and staging credentials.
The standard path is:
- Create the
productionGitHub Environment before setting the production token. - Add a small required-reviewer list or team with production authority.
- Enable prevent self-review.
- Disable administrator bypass if your org policy requires two-person control.
- Restrict deployable branches or tags to the protected release branch.
- Store
CPLN_TOKEN_PRODUCTIONonly as aproductionenvironment secret. - Store
CPLN_ORG_PRODUCTIONandPRODUCTION_APP_NAMEasproductionenvironment variables, or as repository variables only when those names are intentionally non-sensitive. - Keep GitHub variable values single-line; a pasted trailing newline is trimmed for Control Plane org names, but embedded line breaks are rejected before deployment, copy, health-check, or rollback steps run.
- Bootstrap or re-apply the persistent production app templates before first promotion so app workload container env references and Control Plane secret dictionaries exist in production.
- Expect promotion to preserve the selected staging image reference. Digest references are copied by digest, commit-suffixed tags keep the commit suffix, and plain numeric tags remain valid.
- Expect production health and rollback readiness polling to require Control
Plane
status.readyandstatus.readyLatestbefore checking the endpoint.
GitHub only exposes environment secrets to jobs that reference the environment
after configured protection rules pass. GitHub does not allow a caller job that
directly invokes a reusable workflow to set environment, and cross-repo
reusable workflows do not receive the caller repository's environment secrets.
For that reason, generated production promotion stays as a normal caller-repo
job with environment: production. See GitHub's docs for
managing environments,
deployment protection rules,
and reusable workflow limitations.
Application runtime secrets such as SECRET_KEY_BASE, API keys, or private
license keys belong in Control Plane secret dictionaries referenced by
controlplane.yml. They are not GitHub repository variables unless your
Docker build itself needs them.
Recommended org layout:
- keep review apps and staging in a staging org that developers can access
- keep production in a separate org with tighter access controls
- for public repositories, use a staging/review token generated from a service account whose policies only allow review/staging CI operations; use a dedicated review-app org only if you also customize the generated workflow/configuration to target that org separately
Optional repository secret for private dependency builds:
DOCKER_BUILD_SSH_KEY: private SSH key used when the Dockerfile needsRUN --mount=type=sshto fetch private GitHub dependencies during image build
Optional repository variables for private dependency builds:
DOCKER_BUILD_EXTRA_ARGS: optional newline-delimited singledocker buildtokens passed through tocpflow build-image, for example--build-arg=FOO=baror--secret=id=npmrc,src=.npmrcDOCKER_BUILD_SSH_KNOWN_HOSTS: optional multi-lineknown_hostscontent used withDOCKER_BUILD_SSH_KEYwhen the build needs SSH access to hosts other than GitHub.com
Advanced optional repository variables:
REVIEW_APP_DEPLOYING_ICON_URL: custom image URL for the animated icon in review-app PR comments. Ignore this for the standard setup; it is cosmetic only.CPLN_CLI_VERSION: pin only when Control Plane CLI compatibility requires it.CPFLOW_VERSION: pin a published RubyGems version only when intentionally overriding the default build-from-ref behavior.
Review App Security for Repositories with External Contributors
Review-app deployment and teardown can execute pull request code. Even when the workflow itself is trusted, the
Dockerfile, package scripts, Rails initializers, server-rendering code, application runtime, any release_script or
hooks.post_creation defined in the PR's .controlplane/controlplane.yml, and .controlplane/templates/*.yaml identity
and policy templates applied by cpflow setup-app at first deploy with CPLN_TOKEN_STAGING can all be changed by the
pull request being deployed. Teardown can also run a hooks.pre_deletion command through the latest PR-built image, even
when the hook command comes from the base-branch config. A PR author can embed malicious code in that image; at runtime
the code executes inside the review app workload and can read any secrets mounted into the workload environment.
The generated setup action also exports CPLN_TOKEN_STAGING as CPLN_TOKEN via GITHUB_ENV, making it available in the
process environment of every subsequent runner step for the rest of the job, so keep any custom runner steps after setup
trusted. PR-controlled release_script and hook commands normally run through cpflow run in remote Control Plane
containers from the latest image, not as runner shell steps, so they do not read that runner token directly unless a
custom workflow passes a local token through. Inside the Control Plane workload, a separate runtime CPLN_TOKEN is
available only when the workload spec has an identityLink; skip_secrets_setup skips automatic identity creation and
binding, but templates can still attach an identity. Because cpflow setup-app reads controlplane.yml from the PR
checkout, a PR can also set skip_secrets_setup: false or omit it to re-enable automatic identity binding unless the
trusted workflow passes --skip-secrets-setup as a CLI flag. Do not treat skip_secrets_setup in controlplane.yml as
a token-removal control or reliable security boundary. This is why workload-mounted secrets and the staging
service-account token must remain disposable and scoped to minimum permissions.
The generated flow uses these defaults:
- same-repository pull requests can update existing review apps automatically on each push; creating the first review app
requires either a
+review-app-deploycomment from a trusted commenter (OWNER,MEMBER, orCOLLABORATORassociation, regardless of permission level) or a manual workflow dispatch by a repository collaborator with write access. The trusted-commenter gate applies to every+review-app-deploycomment, whether or not a review app already exists. Later pushes to a base-repository branch PR redeploy automatically without another approval because the auto-push path (pull_requestevent) has no trusted-commenter check; only theissue_commentpath does; - fork pull requests cannot deploy via the generated
pull_requestpath because the caller workflow's job-levelif:condition explicitly skips fork-originated runs. Forissue_commentevents, the callerif:restricts commands to trusted author associations (OWNER,MEMBER,COLLABORATOR) but does not check fork status; the fork check for comment events is enforced inside the reusable workflow's source-validation step. These complementary guards cover different axes, so preserve both. A trusted commenter can comment on a fork PR and pass the caller'sauthor_associationcheck; the reusable workflow's source-validation step is what blocks that fork head from being deployed. Removing the source-validation guard opens a path to deploy untrusted code with repository-secret access, becauseissue_commentevents execute with base-repository secret access. Removing thepull_requestguard still lets untrusted fork code into the staging environment even though GitHub withholds repository secrets from forkpull_requestruns. Keep both workflow guards in place because the workflow builds Docker images with repository secrets; - review apps are also deleted automatically when the pull request closes; that PR-close path uses
pull_request_targetso it runs in the base-repository context and has repository-secret access for teardown. That is also why you must never check out PR or fork code in this job; see the customization guidance below. The PR-close path does not require a trusted commenter. The generatedcpflow-delete-review-app.ymlpinsGITHUB_TOKENpermissions to the minimum it needs (contents: read,issues: write,pull-requests: write); if you customize this workflow, preserve thatpermissions:block because omitting it can fall back to broader repository defaults; - manual workflow dispatch by a repository collaborator can also delete a review app without a
+review-app-deletecomment, and does not require a trusted commenter; - trusted comments on fork PRs still do not deploy the fork head; the workflow posts no PR comment in this case. The commenter receives a rocket reaction and the skip appears only in the Actions step summary. Review the fork code carefully, then move the change to a branch in the base repository if it needs a generated review app. That build will run with repository-secret access;
- production promotion is manual and uses production environment secrets separately from review and staging.
The generated review-app workflow targets the staging/review Control Plane org inferred from cpln_org in
controlplane.yml, or overridden by CPLN_ORG_STAGING, and uses the token from CPLN_TOKEN_STAGING. A fully separate
review-app org or token requires workflow/configuration customization; otherwise, keep review apps and staging in the
generated staging/review org and make that token disposable and unable to access production resources.
The PR-close teardown workflow runs trusted base-branch workflow code with repository secret access so it can delete fork
PR review apps. The generated cpflow-delete-control-plane-app composite action script refuses to call cpflow delete
on any app whose name does not match the review-app prefix. This shell-level guard is effective because the generated
delete workflow checks out base/default-branch code, not PR or fork code: its repository checkout has no ref: override,
so pull_request_target runs use the base branch. If you customize this workflow, never check out PR or fork code in the
same job as the delete step; doing so could let a PR replace the guard script itself and would also make
hooks.pre_deletion come from the PR's controlplane.yml. This is still not a token policy, so use a scoped staging
service account limited to review/staging operations. A configured hooks.pre_deletion command still runs through the
latest PR-built image on all delete paths: PR-close teardown, +review-app-delete comments, manual dispatch, and
scheduled cleanup. Review-app credentials must remain disposable even during deletion.
If you customize the generated pull_request_target workflow, never pass github.event.pull_request.head.sha or another
fork-controlled ref to actions/checkout, git fetch, git merge, git cherry-pick, or any other step that fetches,
materializes, or executes the ref. Those operations run untrusted fork code with repository secret access. Referencing the
SHA as an opaque identifier for deployment status updates, comments, or API calls is safe because SHA values are hex-only
and cannot contain shell metacharacters. Do not apply that reasoning to other attacker-controlled PR event fields such as
head.ref, head.label, title, body, head.repo.full_name, or repository descriptions; pass user-controlled strings
through environment variables instead of interpolating them directly into run: steps.
These defaults protect repository secrets from direct fork PR execution, but they do not make deployed PR code harmless. For repositories with external contributors, keep review-app credentials and runtime values disposable:
- do not mount production secrets, staging customer data, package-publishing tokens, payment keys, or monitoring tokens into review apps;
- do not use
DOCKER_BUILD_SSH_KEYwith a long-lived personal key or broad deploy key; see Docker Builds with Private Dependencies for the minimum-access deploy-key requirements; - do not use broad Control Plane
superuserstokens for review-app CI; use a review/staging service account scoped only to review/staging CI operations; - keep review databases, Redis instances, object stores, and renderer credentials separate from staging and production;
- rotate any credential that may have been exposed to a malicious review-app build;
- use scheduled cleanup so stale review apps stop consuming compute and secrets access. Cleanup deletes apps through
cpflow delete, so any configuredhooks.pre_deletionstill runs through the latest PR-built image; review-app credentials must remain disposable for this path too.
Secret indirection such as cpln://secret/... protects values in Control Plane configuration. It does not protect a
value after that secret is mounted into a workload that runs pull request code. See
Secrets and ENV Values - Review app secrets for review-app secret
handling guidance.
Docker Builds with Private Dependencies
Some apps need extra Docker build configuration before the generated workflows are turnkey. Common examples are:
pnpm,npm,yarn, or Bundler dependencies pulled from private GitHub repositories- Dockerfiles that already use
RUN --mount=type=ssh - builds that need extra
--build-arg,--secret, or relateddocker buildflags
The generated cpflow-build-docker-image action supports this without hardcoding app-specific logic:
- set
DOCKER_BUILD_SSH_KEYif the Docker build needs SSH access to GitHub - optionally set
DOCKER_BUILD_SSH_KNOWN_HOSTSwhen the SSH build host is not GitHub.com or you need custom host entries - set
DOCKER_BUILD_EXTRA_ARGSwhen you need extradocker buildflags
For example, a repo that installs private dependencies from GitHub during Docker build can set:
DOCKER_BUILD_SSH_KEY=<private deploy key secret>
DOCKER_BUILD_SSH_KNOWN_HOSTS=git.example.com ssh-ed25519 AAAA...
DOCKER_BUILD_EXTRA_ARGS=--build-arg=BUNDLE_WITHOUT=development:test
The action will start an SSH agent, add the key, write known_hosts, and pass --ssh=default to cpflow build-image. When DOCKER_BUILD_SSH_KNOWN_HOSTS is unset, the generated action uses pinned GitHub.com host keys by default. If your Dockerfile relies on RUN --mount=type=ssh, validate the build locally with cpflow build-image -a <app> --ssh=default before relying on CI.
Do not configure DOCKER_BUILD_SSH_KEY unless the Dockerfile truly needs it. When configured, it is available to all
review-app and staging Docker builds; follow the review-app security guidance above by using a read-only, revocable
deploy key scoped to the minimum private dependency access, and never use a personal SSH key.
Generated Workflow Behavior
cpflow-review-app-help.yml
- Posts a quick reference when a pull request opens, including on fork-based PRs.
- This is an onboarding comment only; it does not checkout PR code or receive Control Plane secrets. Remove this wrapper if a repo does not want automatic review-app command help on every new PR.
cpflow-help-command.yml
- Replies to
+review-app-helpon a pull request with the commands and required repo settings.
cpflow-deploy-review-app.yml
- Creates a review app when someone comments
+review-app-deployor via manual workflow dispatch by a repository collaborator. - For manual dispatch, provide the PR number; the workflow rejects fork PRs at runtime because it builds Docker images with repository secrets.
- Redeploys an existing review app automatically on later PR pushes.
- Creates a GitHub deployment and comments with the review URL and logs.
- Leaves PR pushes alone until the first review app is explicitly requested, which keeps demo-app costs down.
- Supports cost-conscious review apps when paired with one warm replica, Capacity AI, and a disabled autoscaling metric for public demos, starter staging apps, and long-lived review apps; see Enable Capacity AI for Demo and Starter Staging Apps.
- Accepts
+review-app-deployonly from trusted commenters (OWNER,MEMBER, orCOLLABORATOR). - Skips fork-based PR deploys because the workflow builds Docker images with repository secrets. A trusted comment on a fork PR still does not deploy the fork head, and manual dispatch must use a base-repository PR number; dispatching with a fork PR number causes a hard workflow failure. To give a fork PR a review app, review the code carefully first, then move the change to a branch in the base repository. The build will then run with repository-secret access.
cpflow-delete-review-app.yml
- Deletes the review app on
+review-app-delete. - Also supports manual workflow dispatch by a repository collaborator without requiring a trusted-commenter command.
- Also deletes it automatically when the pull request closes through a
pull_request_targetevent, so repository secrets are available for teardown;hooks.pre_deletionstill executes through the latest PR-built image on this path, so review-app credentials must remain disposable. - Accepts
+review-app-deleteonly from trusted commenters (OWNER,MEMBER, orCOLLABORATOR); manual dispatch and automatic PR-close teardown do not require a trusted commenter.
cpflow-deploy-staging.yml
- Builds and deploys the staging app on pushes to the generated staging branch filter.
- Falls back to
mainormasterwhenSTAGING_APP_BRANCHis unset and no custom branch was generated. - Custom staging branches must be present in the workflow's
on.push.branchesfilter; repository variables alone cannot trigger a branch GitHub Actions is not listening to. - Fails fast when required staging repo settings are missing instead of surfacing opaque
cpflowerrors.
cpflow-promote-staging-to-production.yml
- Manually promotes the staging artifact to production with a confirmation input.
- Runs the production job in the
productionGitHub Environment, so configured reviewers approve the job before production environment secrets are available. - Verifies that production has the GVC and app workload container env var names staging expects.
- Runs a health check against
PRIMARY_WORKLOADonly after Control Plane reports the latest workload version ready. - Attempts a rollback of every configured application workload if the new production image does not come up healthy.
- Creates a GitHub release after a successful promotion.
cpflow-deploy-review-app.yml
- Builds and deploys the pull request image after the review app exists or is created.
- Waits for
PRIMARY_WORKLOADto reportreadyLatest=trueand return an accepted HTTP status before marking the GitHub deployment successful. - Uses
REVIEW_APP_HEALTH_CHECK_*repository variables to tune review-app health checks independently from production promotion.
cpflow-cleanup-stale-review-apps.yml
- Runs nightly and on demand.
- Deletes stale review apps using
cpflow cleanup-stale-apps.
Generated review app names use <review-app-prefix>-<PR number>, for example
my-app-review-123. If an existing repository is migrating from older local
workflow glue that created names like <review-app-prefix>-pr-123, delete those
old review apps manually after merging the generated flow; the cleanup workflow
only targets the current prefix convention.
To inventory old-prefix review apps before cleanup, run:
cpln gvc query --org <staging-org> -o yaml --prop name~<review-app-prefix>-pr-
The PR-open help workflow posts the short command reference whenever the
generated wrapper exists. That is intentional for configured demo repos. Forks
or clones that copy the workflow before configuring Control Plane can remove
.github/workflows/cpflow-review-app-help.yml or uncomment and adapt the
wrapper-level if: guard shown in that file, for example
vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''.
Upstream Workflows And Actions
Most generated workflows are intentionally small wrappers. The deployment
logic, comment formatting, Control Plane CLI setup, Docker image build, and
cleanup helpers live in upstream reusable workflows and composite actions in
this repository. Production promotion is expanded into the caller repository so
it can own environment: production, but it still checks out the same upstream
ref for shared composite actions.
cpflow-setup-environment: installs Ruby, the Control Plane CLI, andcpflow, then logs into the target org. By default it buildscpflowfrom the checked-out upstreamcontrol-plane-flowref; set theCPFLOW_VERSIONrepository variable only when you want to force a published RubyGems release.cpflow-build-docker-image: builds and pushes the app image with the desired commit SHAcpflow-delete-control-plane-app: safely deletes temporary apps and refuses to touch names outside the configured review-app prefix
Version Pins: GitHub Ref vs RubyGems
Generated cpflow-* workflow files pin shakacode/control-plane-flow from
GitHub, not from the Ruby gem. Reusable workflow wrappers pin that source with
an upstream uses: ref:
uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@<ref>
Production promotion pins the same source in its Checkout control-plane-flow actions step because it is a caller-owned job, not a reusable workflow caller.
Those refs are the downstream lock. GitHub exposes a reusable workflow's own
repository, ref, and SHA to called jobs, so reusable upstream workflows check out
matching control-plane-flow source automatically from that context. Downstream
reusable-workflow wrappers should not pass control_plane_flow_ref; if you see
that input outside the production promotion setup step, regenerate with a newer
cpflow.
There are two locks, and they protect different things:
- The GitHub ref locks the reusable workflow and composite action code that GitHub runs.
- The RubyGems version locks the
cpflowCLI/runtime code only when you install or run that gem. It does not make GitHub load reusable workflow YAML from the gem.
That means a downstream app cannot rely on the gem alone for GitHub Actions behavior. The safe stable path is still gem-driven for generation, but developers must commit generated wrappers that reference the matching upstream release tag:
- Publish a
cpflowgem. - Install or bundle that released gem in the downstream project.
- Run
cpflow generate-github-actions. - Commit the generated wrappers that point to the matching upstream release tag
such as
v5.0.0.
That release tag should point to the same source that produced the RubyGems
release. Downstream production automation should use release tags, not main or
feature-branch refs.
Updating Generated GitHub Actions After Gem Updates
Whenever a downstream repo updates the cpflow gem, update the checked-in
GitHub Actions wrappers in the same PR. The gem version does not make GitHub load
new reusable workflow YAML by itself; GitHub loads the uses: ref committed in
.github/workflows/cpflow-*.yml.
Use the installed gem to refresh the generated wrappers:
cpflow update-github-actions
bin/test-cpflow-github-flow
If the app runs cpflow through Bundler, use:
bundle exec cpflow update-github-actions
bin/test-cpflow-github-flow bundle exec cpflow
cpflow update-github-actions regenerates the generated wrapper and helper
files from the installed gem, pins the wrapper uses: refs to v<gem-version>,
and preserves a single custom staging branch from the existing generated staging
workflow. Pass --staging-branch BRANCH when changing or restoring a custom
staging branch explicitly.
When keeping cpflow in an app Gemfile, leave a comment next to the gem entry
so future dependency bumps include the wrapper update:
# After bumping cpflow, run:
# bundle exec cpflow update-github-actions
# bin/test-cpflow-github-flow bundle exec cpflow
gem "cpflow", "5.0.1"
CPFLOW_VERSION is a runtime override. If a downstream repository sets the
CPFLOW_VERSION variable, the setup action runs gem install cpflow -v <version>. If it is unset, the setup action builds cpflow from the checked-out
control-plane-flow source selected by the reusable workflow's own SHA. For
normal releases, leave CPFLOW_VERSION unset while pinning the wrappers to the
matching v<version> tag, or set CPFLOW_VERSION to that same released gem
version without the leading v.
When setting CPFLOW_VERSION, use RubyGems version syntax, for example
5.0.0 or 5.0.0.rc.1; do not use v5.0.0 or dash-separated prereleases
because the value is passed directly to gem install cpflow -v.
The setup action fails early when CPFLOW_VERSION and the reusable workflow tag
are out of sync. CPFLOW_VERSION=5.0.0 is accepted only when the wrapper uses a
release tag such as @v5.0.0 (or GitHub resolves it to refs/tags/v5.0.0).
Release tags may use dot- or dash-separated prerelease suffixes, such as
v5.0.0.rc.1 or v5.0.0-rc.1; the gem version should still use dots. The
action also checks the remote control-plane-flow tag and the checked-out
action commit, so a moving branch named like v5.0.0 cannot be used with
CPFLOW_VERSION=5.0.0. That tag check uses outbound HTTPS to GitHub; restricted
runners that cannot reach GitHub should leave CPFLOW_VERSION unset and build
cpflow from the checked-out ref instead. When testing an unreleased upstream
commit SHA, leave CPFLOW_VERSION unset so the workflow builds cpflow from the
same source that supplies the reusable workflow and composite actions.
Testing Unreleased Upstream Changes Downstream
You can test a control-plane-flow PR in a downstream app before merging or
releasing it. Use an immutable commit SHA from the upstream PR branch:
-
Push the upstream PR branch and copy its full 40-character head SHA.
-
In a downstream test branch, run:
bin/pin-cpflow-github-ref <upstream-pr-sha>The helper updates every generated reusable-workflow
uses:ref plus the production workflow's pinnedcontrol-plane-flowcheckout and setup validation ref. It accepts release tags and full commit SHAs by default, rejects branch names such asmainorfeature/foo, and requires--allow-moving-reffor short-lived local experiments that should not be committed. -
Keep
CPFLOW_VERSIONunset so the workflow buildscpflowfrom the same upstream SHA that supplies the reusable workflow and composite actions. IfCPFLOW_VERSIONis set while the wrapper is pinned to a SHA, the setup action fails before deployment because the gem and action code cannot be proven to match. -
Run:
bin/test-cpflow-github-flowPass a local checkout command when you are validating unreleased generator code before it is installed:
bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow -
Open a downstream PR and trigger the real review app with a comment whose body is exactly:
+review-app-deploy -
Verify the deploy logs show the expected upstream commit SHA, the setup step prints the expected
cpflowsource/version, and the review app URL returns HTTP 200. -
After the upstream PR merges and a gem is released, regenerate the downstream wrappers from that released gem and commit the release tag. Use
bin/pin-cpflow-github-ref vX.Y.Zonly for a ref-only update when the generated templates are already current.
This tests the real reusable workflows, the production workflow's checked-out
shared composite actions, and the source-built cpflow gem from one immutable
upstream commit. It avoids merging upstream blind and avoids running production
automation against a moving branch.
Local Generated-Flow Checks
Run this after generation or after changing a downstream wrapper ref:
bin/test-cpflow-github-flow
The helper runs cpflow github-flow-readiness, parses generated workflow YAML,
checks composite action metadata for literal GitHub expressions in descriptions,
checks that all generated wrappers and the production control-plane-flow
checkout use one upstream ref consistently, rejects broad secrets: inherit
usage in generated cpflow wrappers, rejects obsolete control_plane_flow_ref
wrapper inputs, verifies production promotion remains a caller-owned
environment: production job, and runs
actionlint against .github/workflows/cpflow-*.yml. Its actionlint command
keeps the existing shellcheck ignore and also ignores stale local actionlint
false positives for GitHub's newer reusable-workflow job.workflow_* fields.
Applying This to React on Rails Demo Apps
This flow is a good fit for the React on Rails demo apps because they already follow the same basic assumptions:
- the deployable app is a Rails project
- the primary web workload is usually
rails - review environments should be temporary and opt-in
- staging should auto-follow a single branch
- production should promote the already-tested staging image
In practice, porting the flow into a demo app usually follows five phases.
Before generating:
- Confirm the repo passes the readiness checklist above.
- Generate
.controlplane/if the app does not have it yet. - Generate the
cpflow-*GitHub Actions files.
Verify the generated scaffold:
- Update
.controlplane/controlplane.ymlwith staging, review, and production entries. - Confirm that the generated Dockerfile picked a Ruby base image compatible with the app's declared Ruby requirement.
- For SQLite-backed apps, confirm that the generated scaffold switched to persistent
dbandstoragevolumes, mounted them into the main workload, and added a release script that runsrails db:prepare.
Adapt for the app's runtime:
- Keep Node available in the final app image whenever Rails asset compilation or SSR depends on ExecJS or frontend package managers at build or runtime.
- Preserve repo-defined frontend precompile hooks, such as Shakapacker
precompile_hookcommands or React on Railsconfig.auto_load_bundle = true, beforerails assets:precompile. - Add any additional app workloads the app needs at runtime, for example
sidekiq, a Node renderer, or any other process type that should deploy the same application image. - Adjust
PRIMARY_WORKLOADonly if the public workload is not namedrails.
Wire up GitHub secrets, variables, and private builds:
- Make sure the repo variables and secrets line up with the configured app names. For production promotion, store
CPLN_TOKEN_PRODUCTIONonly on a protectedproductionGitHub Environment with required reviewers. - If the Dockerfile pulls private dependencies over SSH, configure
DOCKER_BUILD_SSH_KEY, addDOCKER_BUILD_SSH_KNOWN_HOSTSwhen the host is not GitHub.com, and validate that the image can build withRUN --mount=type=ssh.
Validate and push:
- Validate the real production Docker build before relying on the workflows, especially if asset compilation or SSR requires Node, extra system packages, multiple processes, extra Docker build flags, or persistent writable paths.
- Expect review app deploys to run only for branches in the base repository; fork PRs still get help comments, but deploys are skipped because the workflow uses repository secrets.
- For public repositories, confirm that review-app secrets are disposable and that
CPLN_TOKEN_STAGINGcannot access production resources.
AI Playbook
If you want an AI agent to apply this flow to another project, use the
standalone AI rollout prompt. It captures the
exact wording, hard stop conditions, and definition of done for this workflow,
and it works whether cpflow is already installed or the agent needs to install
it first. When cpflow is already available in the target repo,
cpflow ai-github-flow-prompt prints the same prompt with that repo's default
app prefix already filled in.
Short version:
Set up Control Plane GitHub Flow for this repo. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone, with published package versions and a production Dockerfile that can really build the app. Stop and report blockers for unpublished packages, inaccessible private dependencies, legacy toolchains, or missing production build paths instead of generating workflows blindly. Then run `cpflow generate` if `.controlplane/` is missing, run `cpflow generate-github-actions`, adapt the generated scaffold to the real workloads, document the required GitHub secrets and variables, validate the real build path locally, push the branch, and check the GitHub Actions results. Keep production promotion safe by documenting `CPLN_TOKEN_PRODUCTION` as a protected `production` GitHub Environment secret, not a repository or organization secret.
Expand that prompt with app-specific requirements before editing files:
- verify the repo is a real deployable app, not a partial code sample or a demo pinned to unpublished package versions
- stop and report a scope decision when the repo is a monorepo or contains multiple deployable apps without an already-decided single flow target
- inspect the production Dockerfile and make sure it can build the app's assets in CI
- make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement
- preserve repo-defined frontend precompile hooks, such as Shakapacker
precompile_hookcommands or React on Railsconfig.auto_load_bundle = true - keep Node available in the final image if Rails or SSR depends on ExecJS, Yarn, or
pnpmafter the mainnpm installlayer - if
config/database.ymlshows SQLite in production, confirm thatcpflow generateemitted persistentdbandstoragevolumes plus arails db:preparerelease script; otherwise keep the default Postgres workload - inspect the production Dockerfile and package sources for private GitHub dependencies, and wire
DOCKER_BUILD_SSH_KEYplusDOCKER_BUILD_SSH_KNOWN_HOSTSwhen the build usesRUN --mount=type=sshagainst non-GitHub hosts - add extra
app_workloadsand template files for any runtime sidecars, workers, or renderer processes - make sure any sidecar process exposed to sibling workloads binds to
0.0.0.0instead of container-locallocalhost - make sure sidecar caches or bundle directories live in writable paths for the runtime user, such as
tmp/, instead of root-owned image paths - keep workflow files generic and put app names, org names, branch names, and Docker build knobs in repository
varsandsecrets
When the agent applies this to a project, it should avoid hardcoding app names or org names into the workflow files. Those belong in repository vars and secrets.