Skip to main content

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:

  1. Comment +review-app-deploy on a pull request to create or update a review app.
  2. Push more commits to the PR to auto-redeploy that review app.
  3. Push to the staging branch to auto-deploy staging.
  4. Promote the already-built staging artifact to production from the Actions tab.
  5. Let a nightly workflow clean up stale review apps.

Quick Start

End-to-end rollout in one view:

  1. cpflow github-flow-readiness — exits non-zero if the repo is not ready to deploy.
  2. cpflow generate — creates .controlplane/ if missing.
  3. cpflow generate-github-actions — adds cpflow-* 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.
  4. Configure the GitHub repository secrets and variables the workflows expect.
  5. Push the branch, then comment +review-app-deploy on a PR to spin up a review environment.

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.yml
  • bin/pin-cpflow-github-ref
  • bin/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/rails and bin/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 upstream pointing 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: true is what allows a single config entry to back my-app-review-123, my-app-review-456, and cleanup commands like cpflow 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-staging is what lets the production promotion workflow copy the exact staging artifact.
  • If your main web workload is not named rails, set the optional PRIMARY_WORKLOAD repository variable described below.
  • For public demos, starter staging apps, and long-lived review apps, prefer capacityAI: true with 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.

Required GitHub Repository Settings

For a normal generated review-app setup, configure one repository secret:

  • CPLN_TOKEN_STAGING: token for the staging Control Plane org

No GitHub repository 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: override the staging/review org inferred from cpln_org, for example company-staging
  • REVIEW_APP_PREFIX: override the inferred review-app prefix; required only when multiple review app prefixes exist in controlplane.yml
  • PRIMARY_WORKLOAD: override the public workload used to discover the public endpoint and do production health checks; defaults to rails

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 example company-staging
  • STAGING_APP_NAME: staging GVC name, for example my-app-staging
  • STAGING_APP_BRANCH: optional branch that auto-deploys staging. If you use a custom branch, either pass it to cpflow generate-github-actions --staging-branch BRANCH during generation or edit cpflow-deploy-staging.yml so its on.push.branches list 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_PRODUCTION as an environment secret on production, not as a repository or organization secret
  • CPLN_ORG_PRODUCTION as a production environment variable, for example company-production
  • PRODUCTION_APP_NAME as a production environment variable, for example my-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:

  1. Create the production GitHub Environment before setting the production token.
  2. Add a small required-reviewer list or team with production authority.
  3. Enable prevent self-review.
  4. Disable administrator bypass if your org policy requires two-person control.
  5. Restrict deployable branches or tags to the protected release branch.
  6. Store CPLN_TOKEN_PRODUCTION only as a production environment secret.
  7. Store CPLN_ORG_PRODUCTION and PRODUCTION_APP_NAME as production environment variables, or as repository variables only when those names are intentionally non-sensitive.
  8. 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.
  9. 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.
  10. 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.
  11. Expect production health and rollback readiness polling to require Control Plane status.ready and status.readyLatest before 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

Optional repository secret for private dependency builds:

  • DOCKER_BUILD_SSH_KEY: private SSH key used when the Dockerfile needs RUN --mount=type=ssh to fetch private GitHub dependencies during image build

Optional repository variables for private dependency builds:

  • DOCKER_BUILD_EXTRA_ARGS: optional newline-delimited single docker build tokens passed through to cpflow build-image, for example --build-arg=FOO=bar or --secret=id=npmrc,src=.npmrc
  • DOCKER_BUILD_SSH_KNOWN_HOSTS: optional multi-line known_hosts content used with DOCKER_BUILD_SSH_KEY when 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.

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 related docker build flags

The generated cpflow-build-docker-image action supports this without hardcoding app-specific logic:

  • set DOCKER_BUILD_SSH_KEY if the Docker build needs SSH access to GitHub
  • optionally set DOCKER_BUILD_SSH_KNOWN_HOSTS when the SSH build host is not GitHub.com or you need custom host entries
  • set DOCKER_BUILD_EXTRA_ARGS when you need extra docker build flags

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.

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-help on a pull request with the commands and required repo settings.

cpflow-deploy-review-app.yml

  • Creates a review app when someone comments +review-app-deploy.
  • 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-deploy only from trusted commenters (OWNER, MEMBER, or COLLABORATOR).
  • Skips fork-based PR deploys because the workflow builds Docker images with repository secrets.

cpflow-delete-review-app.yml

  • Deletes the review app on +review-app-delete.
  • Also deletes it automatically when the pull request closes.
  • Accepts +review-app-delete only from trusted commenters (OWNER, MEMBER, or COLLABORATOR).

cpflow-deploy-staging.yml

  • Builds and deploys the staging app on pushes to the generated staging branch filter.
  • Falls back to main or master when STAGING_APP_BRANCH is unset and no custom branch was generated.
  • Custom staging branches must be present in the workflow's on.push.branches filter; 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 cpflow errors.

cpflow-promote-staging-to-production.yml

  • Manually promotes the staging artifact to production with a confirmation input.
  • Runs the production job in the production GitHub 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_WORKLOAD only 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-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, and cpflow, then logs into the target org. By default it builds cpflow from the checked-out upstream control-plane-flow ref; set the CPFLOW_VERSION repository 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 SHA
  • cpflow-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 cpflow CLI/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:

  1. Publish a cpflow gem.
  2. Install or bundle that released gem in the downstream project.
  3. Run cpflow generate-github-actions.
  4. 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:

  1. Push the upstream PR branch and copy its full 40-character head SHA.

  2. 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 pinned control-plane-flow checkout and setup validation ref. It accepts release tags and full commit SHAs by default, rejects branch names such as main or feature/foo, and requires --allow-moving-ref for short-lived local experiments that should not be committed.

  3. Keep CPFLOW_VERSION unset so the workflow builds cpflow from the same upstream SHA that supplies the reusable workflow and composite actions. If CPFLOW_VERSION is 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.

  4. Run:

    bin/test-cpflow-github-flow

    Pass 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
  5. Open a downstream PR and trigger the real review app with a comment whose body is exactly:

    +review-app-deploy
  6. Verify the deploy logs show the expected upstream commit SHA, the setup step prints the expected cpflow source/version, and the review app URL returns HTTP 200.

  7. 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.Z only 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:

  1. Confirm the repo passes the readiness checklist above.
  2. Generate .controlplane/ if the app does not have it yet.
  3. Generate the cpflow-* GitHub Actions files.

Verify the generated scaffold:

  1. Update .controlplane/controlplane.yml with staging, review, and production entries.
  2. Confirm that the generated Dockerfile picked a Ruby base image compatible with the app's declared Ruby requirement.
  3. For SQLite-backed apps, confirm that the generated scaffold switched to persistent db and storage volumes, mounted them into the main workload, and added a release script that runs rails db:prepare.

Adapt for the app's runtime:

  1. 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.
  2. Preserve repo-defined frontend precompile hooks, such as Shakapacker precompile_hook commands or React on Rails config.auto_load_bundle = true, before rails assets:precompile.
  3. 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.
  4. Adjust PRIMARY_WORKLOAD only if the public workload is not named rails.

Wire up GitHub secrets, variables, and private builds:

  1. Make sure the repo variables and secrets line up with the configured app names. For production promotion, store CPLN_TOKEN_PRODUCTION only on a protected production GitHub Environment with required reviewers.
  2. If the Dockerfile pulls private dependencies over SSH, configure DOCKER_BUILD_SSH_KEY, add DOCKER_BUILD_SSH_KNOWN_HOSTS when the host is not GitHub.com, and validate that the image can build with RUN --mount=type=ssh.

Validate and push:

  1. 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.
  2. 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.

AI Playbook

If you want an AI agent to apply this flow to another project, start with cpflow github-flow-readiness, then use the standalone AI rollout prompt. It captures the exact wording, hard stop conditions, and definition of done for this workflow. You can also run cpflow ai-github-flow-prompt from inside the target repo to print the current 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_hook commands or React on Rails config.auto_load_bundle = true
  • keep Node available in the final image if Rails or SSR depends on ExecJS, Yarn, or pnpm after the main npm install layer
  • if config/database.yml shows SQLite in production, confirm that cpflow generate emitted persistent db and storage volumes plus a rails db:prepare release script; otherwise keep the default Postgres workload
  • inspect the production Dockerfile and package sources for private GitHub dependencies, and wire DOCKER_BUILD_SSH_KEY plus DOCKER_BUILD_SSH_KNOWN_HOSTS when the build uses RUN --mount=type=ssh against non-GitHub hosts
  • add extra app_workloads and template files for any runtime sidecars, workers, or renderer processes
  • make sure any sidecar process exposed to sibling workloads binds to 0.0.0.0 instead of container-local localhost
  • 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 vars and secrets

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.