diff --git a/README.md b/README.md index 1c150137..5147c92d 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,17 @@ _If you need a free demo account for Control Plane (no CC required), you can con --- -Be sure to see the [demo app](https://github.com/shakacode/react-webpack-rails-tutorial/tree/master/.controlplane), which includes simple YAML configurations and setup for `cpflow`. +To bootstrap a new project, run three commands from the repo root: -Also, check [how the `cpflow` gem (this project) is used in the Github actions](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.github/actions/deploy-to-control-plane/action.yml). +1. **`cpflow github-flow-readiness`** — gate for common rollout blockers (missing Rails runtime scaffold, legacy Ruby or Bundler toolchains, unpublished exact-pinned gems or npm packages, missing production Dockerfiles). Exits non-zero on any blocker. +2. **`cpflow generate`** — creates `.controlplane/` scaffolding. Infers the app prefix from the repo directory, the base Ruby version from `.ruby-version`/`.tool-versions`/`Gemfile`, the JS package manager from `package.json`, repo-defined frontend precompile hooks (Shakapacker `precompile_hook`, React on Rails auto bundle generation), and switches to persistent `db` + `storage` volume templates when `config/database.yml` shows SQLite in production. +3. **`cpflow generate-github-actions`** — adds the reusable GitHub Actions pipeline for review apps, staging deploys, and manual production promotion. + +The generated scaffold is a starting point. After generation, adapt `.controlplane/` for app-specific workloads (Sidekiq, Node renderer), wire any private-dependency Docker build settings (SSH key, optional known-host overrides), and verify that the production Docker build succeeds. + +See [CI automation](./docs/ci-automation.md) for the full setup and required GitHub secrets and variables. For an AI agent rollout, see the [AI rollout prompt](./docs/ai-github-flow-prompt.md) or run `cpflow ai-github-flow-prompt` inside the target repo to print a copy-paste prompt with that repo's default app prefix filled in. + +For a live reference, see the [demo app](https://github.com/shakacode/react-webpack-rails-tutorial/tree/master/.controlplane) and its [GitHub Actions flow](https://github.com/shakacode/react-webpack-rails-tutorial/tree/master/.github). Here is a brief [video overview](https://www.youtube.com/watch?v=llaQoAV_6Iw). --- @@ -64,6 +72,7 @@ Additionally, the documentation includes numerous examples and practical tips fo - Extensive Heroku-to-Control Plane migration examples included in the documentation. - Convention-driven configuration to simplify workflows and reduce custom scripting requirements. - Easy to understand Heroku to Control Plane conventions in setup and naming. +- GitHub Actions generator for on-demand review apps, automatic staging deploys, and manual promotion to production. - **Safe, production-ready** equivalents of `heroku run` and `heroku run:detached` for Control Plane. - Automatic sequential release tagging for Docker images. - A project-aware CLI that enables working on multiple projects. @@ -118,7 +127,9 @@ _Note, if you want to use Terraform with cpflow, you will start the same way bel 3. Install [Ruby](https://www.ruby-lang.org/en/) (required for these helpers). -4. Install Control Plane CLI, and configure access ([docs here](https://shakadocs.controlplane.com/quickstart/quick-start-3-cli#getting-started-with-the-cli)). +4. Purely local bootstrap commands can run before Control Plane CLI is installed. That includes `cpflow help`, `cpflow version`, `cpflow github-flow-readiness`, `cpflow ai-github-flow-prompt`, `cpflow generate`, `cpflow generate-github-actions`, and `cpflow terraform generate`. Install Control Plane CLI before any `cpflow` command that talks to Control Plane infrastructure. + +5. Install Control Plane CLI, and configure access ([docs here](https://shakadocs.controlplane.com/quickstart/quick-start-3-cli#getting-started-with-the-cli)). ```sh # Install CLI @@ -131,11 +142,11 @@ cpln login npm update -g @controlplane/cli ``` -5. Run `cpln image docker-login --org ` to ensure that you have access to the Control Plane Docker registry. +6. Run `cpln image docker-login --org ` to ensure that you have access to the Control Plane Docker registry. -6. Install Control Plane Flow `cpflow` CLI as a [Ruby gem](https://rubygems.org/gems/cpflow): `gem install cpflow`. If you want to use `cpflow` from Rake tasks in a Rails project, use `Bundler.with_unbundled_env { `cpflow help` } or else you'll get an error that `cpflow` cannot be found. While you can add `cpflow` to your Gemfile, it's not recommended because it might trigger conflicts with other gems. +7. Install Control Plane Flow `cpflow` CLI as a [Ruby gem](https://rubygems.org/gems/cpflow): `gem install cpflow`. If you want to use `cpflow` from Rake tasks in a Rails project, use `Bundler.with_unbundled_env { `cpflow help` } or else you'll get an error that `cpflow` cannot be found. While you can add `cpflow` to your Gemfile, it's not recommended because it might trigger conflicts with other gems. -7. You will need a production-ready Dockerfile. If you're using Rails, consider the default one that ships with Rails 8. You can use [this Dockerfile](https://github.com/shakacode/rails-v8-kamal-v2-terraform-gcp-tutorial/blob/master/Dockerfile) as an example for your project. Ensure that you have Docker running. +8. You will need a production-ready Dockerfile. If you're using Rails, consider the default one that ships with Rails 8. You can use [this Dockerfile](https://github.com/shakacode/rails-v8-kamal-v2-terraform-gcp-tutorial/blob/master/Dockerfile) as an example for your project. Ensure that you have Docker running. **Note:** Do not confuse the `cpflow` CLI with the `cpln` CLI. The `cpflow` CLI is the Control Plane Flow playbook CLI. The `cpln` CLI is the Control Plane CLI. @@ -148,20 +159,22 @@ The `cpflow` gem is based on several configuration files within a `/.controlplan .controlplane/ ├─ templates/ │ ├─ app.yml -│ ├─ postgres.yml +│ ├─ postgres.yml or db.yml + storage.yml │ ├─ rails.yml ├─ controlplane.yml ├─ Dockerfile ├─ entrypoint.sh +├─ release_script.sh ``` -1. `controlplane.yml` describes the overall application. Be sure to have `` as the value for `aliases.common.cpln_org`, or set it with the `CPLN_ORG` environment variable. -2. `Dockerfile` builds the production application. `entrypoint.sh` is an _example_ entrypoint script for the production application, referenced in your Dockerfile. -3. `templates` directory contains the templates for the various workloads, such as `rails.yml` and `postgres.yml`. -4. `templates/app.yml` defines your project's GVC (like a Heroku app). More importantly, it contains ENV values for the app. -5. `templates/rails.yml` defines your Rails workload. It may inherit ENV values from the parent GVC, which is populated from the `templates/app.yml`. This file also configures scaling, sizing, firewalls, and other workload-specific values. -6. For other workloads (like lines in a Heroku `Procfile`), you create additional template files. For example, you can base a `templates/sidekiq.yml` on the `templates/rails.yml` file. -7. You can have other files in the `templates` directory, such as `redis.yml` and `postgres.yml`, which could setup Redis and Postgres for a testing application. +1. `controlplane.yml` describes the overall application. The generated version includes staging, review, and production entries named from the repo directory, plus `setup_app_templates` and `release_script` defaults. Be sure to update `` as the value for `aliases.common.cpln_org`, or set it with the `CPLN_ORG` environment variable. +2. `Dockerfile` builds the production application. The generated example includes Node.js, package-manager auto-detection (`npm`, Yarn, or `pnpm`), a Ruby base-image hint derived from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`, and any detected repo-defined asset precompile hook such as a Shakapacker `precompile_hook` or React on Rails auto bundle generation step so Rails apps with frontend assets can precompile from a clean clone. `entrypoint.sh` is an _example_ entrypoint script for the production application, referenced in your Dockerfile. +3. `release_script.sh` is the generated release-phase entrypoint used by `cpflow deploy-image --run-release-phase` and `cpflow promote-app-from-upstream --run-release-phase`. +4. `templates` directory contains the templates for the various workloads, such as `rails.yml` and either `postgres.yml` or the SQLite `db.yml` and `storage.yml` pair. +5. `templates/app.yml` defines your project's GVC (like a Heroku app). More importantly, it contains ENV values for the app. +6. `templates/rails.yml` defines your Rails workload. It may inherit ENV values from the parent GVC, which is populated from the `templates/app.yml`. This file also configures scaling, sizing, firewalls, and other workload-specific values. +7. For other workloads (like lines in a Heroku `Procfile`), you create additional template files. For example, you can base a `templates/sidekiq.yml` on the `templates/rails.yml` file. +8. You can have other files in the `templates` directory, such as `redis.yml`, `postgres.yml`, or SQLite-backed `db.yml` and `storage.yml`, depending on the application runtime. Here's a complete example of all supported config keys explained for the `controlplane.yml` file: @@ -330,6 +343,21 @@ apps: ## Workflow +### Bootstrap a New Repo + +```sh +# Check the repo for common rollout blockers before generating files +cpflow github-flow-readiness + +# Create the .controlplane/ scaffolding +cpflow generate + +# Create reusable GitHub Actions for review apps, staging, and production promotion +cpflow generate-github-actions +``` + +`cpflow github-flow-readiness` exits non-zero when it finds blockers such as unpublished exact-pinned packages or a missing production Dockerfile, so use it as the gate before generation. Then review the generated `.controlplane/controlplane.yml` entries, adjust any app-specific workloads, and configure the GitHub repository variables and secrets described in [CI automation](./docs/ci-automation.md), including the optional Docker build settings for private GitHub dependencies and custom SSH known hosts. `cpflow generate` already switches to persistent `db` and `storage` volumes when `config/database.yml` shows SQLite in production and preserves detected frontend precompile hooks, but you should still confirm that the generated Dockerfile picked a Ruby base image compatible with the app's declared Ruby requirement and that the emitted workload set matches the real app. If you want an AI agent to do this end to end, start with the [AI rollout prompt](./docs/ai-github-flow-prompt.md) or run `cpflow ai-github-flow-prompt` in the target repo rather than giving a vague "set up CI" request. + For a live example, see the [react-webpack-rails-tutorial](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.controlplane/readme.md) repository. You can use this repository as a reference for setting up your own project. @@ -387,7 +415,7 @@ cpflow build-image -a tutorial-app --commit ABCD ### Real World -Most companies will configure their CI system to handle the above steps. Please [contact Shakacode](mailto:controlplane@shakacode.com) for examples of how to do this. +Most teams will automate the above steps in CI. Run `cpflow generate-github-actions` to scaffold the GitHub Actions flow, then follow [CI automation](./docs/ci-automation.md) to wire it to your `.controlplane/controlplane.yml` and repository settings. You can also join our [**Slack channel**](https://reactrails.slack.com/join/shared_invite/enQtNjY3NTczMjczNzYxLTlmYjdiZmY3MTVlMzU2YWE0OWM0MzNiZDI0MzdkZGFiZTFkYTFkOGVjODBmOWEyYWQ3MzA2NGE1YWJjNmVlMGE) for ShakaCode open source projects. diff --git a/docs/ai-github-flow-prompt.md b/docs/ai-github-flow-prompt.md new file mode 100644 index 00000000..4e7cc107 --- /dev/null +++ b/docs/ai-github-flow-prompt.md @@ -0,0 +1,61 @@ +# AI Rollout Prompt for Control Plane GitHub Flow + +Use this file when you want an AI agent to add the reusable `cpflow` review-app, +staging, and production-promotion flow to a repository. + +If `cpflow` is already installed in the target repo, you can print the current +copy-paste version of this prompt with: + +```sh +cpflow ai-github-flow-prompt +``` + +That local-only command works even before `cpln` is installed and fills in the +repo-name default app prefix for the current checkout. You can also run +`cpflow github-flow-readiness` first to check the same blocker categories the +prompt tells the agent to stop on. + +## Recommended Prompt + +```text +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: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout. + +If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions`, keep review apps opt-in via `/deploy-review-app`, use `STAGING_APP_BRANCH` or the default branch for staging deploys, and list the GitHub secrets and variables that must be configured. + +Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold uses persistent `db` and `storage` volumes plus a release script that runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`. + +Run the real local validations you can: Docker build if feasible, repo tests or smoke checks, YAML validation, and any CI-equivalent build steps. Push the branch and check the GitHub Actions results. Only stop early for a real external blocker or a product decision that changes scope. +``` + +## Hard Stop Conditions + +Stop and report the blocker instead of generating `cpflow-*` workflow files when: + +- the repo is a partial sample or generator snapshot rather than a deployable app +- the app depends on unpublished or inaccessible gem or npm package versions +- the repo is pinned to a legacy Ruby or Bundler toolchain that you cannot validate in the current environment +- there is no production Dockerfile and the app's production build path is still undefined +- the repo is a monorepo or contains multiple deployable apps and the flow target is not already decided +- the local checkout does not match the intended remote repository +- the app needs product decisions about workload shape, secrets, or promotion behavior that are not already implied by the repo + +## Definition of Done + +The rollout is done when all of the following are true: + +- `.controlplane/` exists and matches the actual app shape +- `.github/actions/cpflow-*` and `.github/workflows/cpflow-*` are in place +- review apps are opt-in, staging auto-deploys from one branch, and production promotion is manual +- required GitHub secrets and variables are documented for the repo +- the production image build path is validated for the real app +- repo-specific runtime concerns are handled, such as SQLite volumes, sidekiq workloads, SSR runtime Node access, React on Rails pack generation hooks, or private dependency fetches +- the branch is pushed and the relevant GitHub checks are either green or blocked only by an external system failure + +## React on Rails Notes + +For React on Rails and React on Rails Pro apps, explicitly verify: + +- SSR or renderer workloads do not lose Node or package-manager access in the final image +- sidecar renderers or worker processes bind to `0.0.0.0`, not container-local `localhost` +- writable caches, bundle outputs, or SQLite files live in runtime-writable paths +- old demo repos are treated as legacy exceptions unless they can still build from a clean clone diff --git a/docs/ci-automation.md b/docs/ci-automation.md index a85b724a..483d608b 100644 --- a/docs/ci-automation.md +++ b/docs/ci-automation.md @@ -1,28 +1,326 @@ -# CI Automation, Review Apps, Staging, and Promoting to Production - -## Setting up Tokens for CI Automation - -This example uses Github Actions. The same applies to Circle CI and other similar CI/CD tools. - -1. Ensure that you have two orgs: - 1. `company-staging` (for staging deployments, developers have access) - 2. `company-production` (for production deployments, limited access) -2. Create the token for staging org and set on Github repository secrets and variables: - 1. Go to the Control Plane UI for your organization's staging org - 2. Make a new service account called `github-actions-staging` - 3. Assign to the group `superusers` - 4. Click "Keys" and create a one with description "Github Actions" and copy the token (or download it). - 5. Add this key to your Github repository **secrets** as `CPLN_TOKEN_STAGING` - 6. Add another key to your Github repository **variables** as `CPLN_ORG_STAGING` with the name of the staging org, like `company-staging` -3. Create the token for production org, and set on Github repository secrets and variables. - 1. Go to the Control Plane UI for your organization's production org - 2. Make a new service account called `github-actions-production` - 3. Assign to the group `superusers` - 4. Click "Keys" and create a one with description "Github Actions" and copy the token (or download it). - 5. Add this key to your Github repository **secrets** as `CPLN_TOKEN_PRODUCTION` - 6. Add another key to your Github repository **variables** as `CPLN_ORG_PRODUCTION` with the name of the production org, like `company-production` -4. Create a few more ENV **variables** for the app name and the app prefix: - 1. `STAGING_APP_NAME` - the name of the app in Control Plane for staging, which is the GVC name, like `app-name-staging` - 2. `PRODUCTION_APP_NAME` - the name of the app in Control Plane for production, which is the GVC name, like `app-name-production` - 3. `REVIEW_APP_PREFIX` - the prefix for the review apps in Control Plane. The Review apps are named `$REVIEW_APP_PREFIX-pr-$PR_NUMBER` -5. All in all, you should have 2 secrets and 5 variables set in your Github repository +# 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 `/deploy-review-app` 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 the `cpflow-*` composite actions and workflows. +4. Configure the GitHub [repository secrets and variables](#required-github-repository-settings) the workflows expect. +5. Push the branch, then comment `/deploy-review-app` on a PR to spin up a review environment. + +See [Bootstrap a Project](#bootstrap-a-project) for command details, [Repo Readiness Checklist](#repo-readiness-checklist) for what "ready" means, and [AI Playbook](#ai-playbook) to run the rollout through an agent. + +## Bootstrap a Project + +Run these commands from the project root: + +```sh +# 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 +``` + +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/actions/cpflow-build-docker-image/action.yml` +- `.github/actions/cpflow-delete-control-plane-app/action.yml` +- `.github/actions/cpflow-delete-control-plane-app/delete-app.sh` +- `.github/actions/cpflow-setup-environment/action.yml` +- `.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` + +`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. + +## 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: + +```yaml +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: bundle exec rails db:drop + + 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: + +- `REVIEW_APP_PREFIX` in GitHub Actions must match the review config key prefix, for example `my-app-review`. +- `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`. +- `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. + +## Required GitHub Repository Settings + +Configure these repository secrets: + +- `CPLN_TOKEN_STAGING`: token for the staging Control Plane org +- `CPLN_TOKEN_PRODUCTION`: token for the production Control Plane org + +Configure these repository variables: + +- `CPLN_ORG_STAGING`: staging org name, for example `company-staging` +- `CPLN_ORG_PRODUCTION`: production org name, for example `company-production` +- `STAGING_APP_NAME`: staging GVC name, for example `my-app-staging` +- `PRODUCTION_APP_NAME`: production GVC name, for example `my-app-production` +- `REVIEW_APP_PREFIX`: review-app prefix, for example `my-app-review` +- `STAGING_APP_BRANCH`: optional branch that auto-deploys staging; defaults to `main` or `master` if unset +- `PRIMARY_WORKLOAD`: optional workload name used to discover the public endpoint and do production health checks; defaults to `rails` +- `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 + +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 + +## 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: + +```text +DOCKER_BUILD_SSH_KEY= +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 --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. + +`cpflow-help-command.yml` + +- Replies to `/help` on a pull request with the commands and required repo settings. + +`cpflow-deploy-review-app.yml` + +- Creates a review app when someone comments `/deploy-review-app`. +- 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. +- Accepts `/deploy-review-app` 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 `/delete-review-app`. +- Also deletes it automatically when the pull request closes. +- Accepts `/delete-review-app` only from trusted commenters (`OWNER`, `MEMBER`, or `COLLABORATOR`). + +`cpflow-deploy-staging.yml` + +- Builds and deploys the staging app on pushes to `STAGING_APP_BRANCH`. +- Falls back to `main` or `master` when `STAGING_APP_BRANCH` is unset. +- 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. +- Verifies that production has the env var names staging expects. +- Runs a health check against `PRIMARY_WORKLOAD`. +- 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`. + +## Composite Actions + +The generated workflows share these local composite actions: + +- `cpflow-setup-environment`: installs Ruby, the Control Plane CLI, and the `cpflow` gem, then logs into the target org +- `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 + +## 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:** + +4. Update `.controlplane/controlplane.yml` with staging, review, and production entries. +5. Confirm that the generated Dockerfile picked a Ruby base image compatible with the app's declared Ruby requirement. +6. 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:** + +7. 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. +8. 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`. +9. 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. +10. Adjust `PRIMARY_WORKLOAD` only if the public workload is not named `rails`. + +**Wire up GitHub secrets, variables, and private builds:** + +11. Make sure the repo variables and secrets line up with the configured app names. +12. 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:** + +13. 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. +14. 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](./ai-github-flow-prompt.md). 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: + +```text +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. +``` + +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`. diff --git a/docs/commands.md b/docs/commands.md index 78f1cb5a..da98429d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,6 +11,18 @@ This `-a` option is used in most of the commands and will pick all other app con ## Commands +### `ai-github-flow-prompt` + +Prints a copy-paste prompt for an AI agent to roll out the reusable Control Plane GitHub Flow: +- verifies the repo is deployable from a clean clone before generating files +- scaffolds `.controlplane/` and `cpflow-*` GitHub Actions files when the repo qualifies +- stops on external blockers or product decisions instead of forcing a broken rollout + +```sh +# Prints the recommended AI rollout prompt for the current repo +cpflow ai-github-flow-prompt +``` + ### `apply-template` - Applies application-specific configs from templates (e.g., for every review-app) @@ -93,13 +105,16 @@ cpflow config -a $APP_NAME - Copies an image (by default the latest) from a source org to the current org - The source app must be specified either through the `CPLN_UPSTREAM` env var or `upstream` in the `.controlplane/controlplane.yml` file -- Additionally, the token for the source org must be provided through `--upstream-token` or `-t` +- The token for the source org must be provided through `--upstream-token`/`-t` or the `CPLN_UPSTREAM_TOKEN` env var - A `cpln` profile will be temporarily created to pull the image from the source org ```sh # Copies the latest image from the source org to the current org. cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN +# Equivalent call using an env var (avoids exposing the token via the OS process table). +CPLN_UPSTREAM_TOKEN=$UPSTREAM_TOKEN cpflow copy-image-from-upstream -a $APP_NAME + # Copies a specific image from the source org to the current org. cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN --image appimage:123 ``` @@ -165,13 +180,43 @@ if [ cpflow exists -a $APP_NAME ]; ... ### `generate` -Creates base Control Plane config and template files +Creates base Control Plane config and template files for a Rails project: +- infers the app prefix from the current directory and wires staging, review, and production entries +- infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile` +- preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation +- detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload ```sh -# Creates .controlplane directory with Control Plane config and other templates +# Creates .controlplane directory with Control Plane config and starter templates cpflow generate ``` +### `generate-github-actions` + +Creates GitHub Actions templates for a Heroku Flow style Control Plane pipeline: +- on-demand review apps for pull requests +- automatic staging deploys from your main branch +- manual promotion from staging to production +- nightly cleanup and PR help workflows + +```sh +# Creates .github/actions and .github/workflows files for the Control Plane flow +cpflow generate-github-actions +``` + +### `github-flow-readiness` + +Checks the current repository for common rollout blockers before adding the Control Plane GitHub flow: +- Rails runtime scaffold present +- modern Ruby and Bundler toolchain +- installable exact-pinned direct gem and npm package versions +- production Dockerfile presence and SQLite production hints + +```sh +# Checks the current repo for common rollout blockers +cpflow github-flow-readiness +``` + ### `info` - Displays the diff between defined/available apps/workloads (apps equal GVCs) diff --git a/lib/command/ai_github_flow_prompt.rb b/lib/command/ai_github_flow_prompt.rb new file mode 100644 index 00000000..41f50ed9 --- /dev/null +++ b/lib/command/ai_github_flow_prompt.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../core/repo_introspection" + +module Command + class AiGithubFlowPrompt < Base + NAME = "ai-github-flow-prompt" + DESCRIPTION = "Prints the recommended AI prompt for adding the Control Plane GitHub Flow to a repo" + LONG_DESCRIPTION = <<~DESC + Prints a copy-paste prompt for an AI agent to roll out the reusable Control Plane GitHub Flow: + - verifies the repo is deployable from a clean clone before generating files + - scaffolds `.controlplane/` and `cpflow-*` GitHub Actions files when the repo qualifies + - stops on external blockers or product decisions instead of forcing a broken rollout + DESC + EXAMPLES = <<~EX + ```sh + # Prints the recommended AI rollout prompt for the current repo + cpflow ai-github-flow-prompt + ``` + EX + WITH_INFO_HEADER = false + VALIDATIONS = [].freeze + REQUIRES_STARTUP_CHECKS = false + + def call + puts prompt + end + + private + + def prompt + <<~PROMPT + 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: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout. + + If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions`, keep review apps opt-in via `/deploy-review-app`, use `STAGING_APP_BRANCH` or the default branch for staging deploys, and list the GitHub secrets and variables that must be configured. + + Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold uses persistent `db` and `storage` volumes plus a release script that runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`. + + Run the real local validations you can: Docker build if feasible, repo tests or smoke checks, YAML validation, and any CI-equivalent build steps. Push the branch and check the GitHub Actions results. Only stop early for a real external blocker or a product decision that changes scope. + PROMPT + end + + def inferred_app_prefix + RepoIntrospection.inferred_app_prefix(Dir.pwd) + end + end +end diff --git a/lib/command/base.rb b/lib/command/base.rb index 348dbc51..92262845 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -40,6 +40,8 @@ class Base # rubocop:disable Metrics/ClassLength WITH_INFO_HEADER = true # Which validations to run before the command VALIDATIONS = %w[config].freeze + # Whether or not to run CLI startup checks such as cpln availability and update checks + REQUIRES_STARTUP_CHECKS = true def initialize(config) @config = config diff --git a/lib/command/copy_image_from_upstream.rb b/lib/command/copy_image_from_upstream.rb index e5d0769f..8ddebaf1 100644 --- a/lib/command/copy_image_from_upstream.rb +++ b/lib/command/copy_image_from_upstream.rb @@ -5,14 +5,14 @@ class CopyImageFromUpstream < Base NAME = "copy-image-from-upstream" OPTIONS = [ app_option(required: true), - upstream_token_option(required: true), + upstream_token_option, image_option ].freeze DESCRIPTION = "Copies an image (by default the latest) from a source org to the current org" LONG_DESCRIPTION = <<~DESC - Copies an image (by default the latest) from a source org to the current org - The source app must be specified either through the `CPLN_UPSTREAM` env var or `upstream` in the `.controlplane/controlplane.yml` file - - Additionally, the token for the source org must be provided through `--upstream-token` or `-t` + - The token for the source org must be provided through `--upstream-token`/`-t` or the `CPLN_UPSTREAM_TOKEN` env var - A `cpln` profile will be temporarily created to pull the image from the source org DESC EXAMPLES = <<~EX @@ -20,6 +20,9 @@ class CopyImageFromUpstream < Base # Copies the latest image from the source org to the current org. cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN + # Equivalent call using an env var (avoids exposing the token via the OS process table). + CPLN_UPSTREAM_TOKEN=$UPSTREAM_TOKEN cpflow copy-image-from-upstream -a $APP_NAME + # Copies a specific image from the source org to the current org. cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN --image appimage:123 ``` @@ -30,7 +33,9 @@ def call # rubocop:disable Metrics/MethodLength @upstream = ENV.fetch("CPLN_UPSTREAM", nil) || config[:upstream] @upstream_org = ENV.fetch("CPLN_ORG_UPSTREAM", nil) || config.find_app_config(@upstream)&.dig(:cpln_org) + @upstream_token = config.options[:upstream_token] || ENV.fetch("CPLN_UPSTREAM_TOKEN", nil) ensure_upstream_org! + ensure_upstream_token! create_upstream_profile fetch_upstream_image_url @@ -51,6 +56,12 @@ def ensure_upstream_org! "and CPLN_ORG_UPSTREAM env var is not set." end + def ensure_upstream_token! + return if @upstream_token + + raise "Missing upstream token. Pass `--upstream-token`/`-t` or set the `CPLN_UPSTREAM_TOKEN` env var." + end + def create_upstream_profile step("Creating upstream profile") do loop do @@ -58,7 +69,7 @@ def create_upstream_profile break unless cp.profile_exists?(@upstream_profile) end - cp.profile_create(@upstream_profile, config.options[:upstream_token]) + cp.profile_create(@upstream_profile, @upstream_token) end end diff --git a/lib/command/generate.rb b/lib/command/generate.rb index 37b06f00..13737227 100644 --- a/lib/command/generate.rb +++ b/lib/command/generate.rb @@ -1,32 +1,182 @@ # frozen_string_literal: true +require_relative "generator_helpers" +require_relative "../core/repo_introspection" + module Command - class Generator < Thor::Group + class Generator < Thor::Group # rubocop:disable Metrics/ClassLength include Thor::Actions + include GeneratorHelpers + + BASE_TEMPLATE_FILES = %w[ + Dockerfile + controlplane.yml + entrypoint.sh + release_script.sh + templates/app.yml + templates/rails.yml + ].freeze + POSTGRES_TEMPLATE_FILES = %w[templates/postgres.yml].freeze + SQLITE_TEMPLATE_FILES = %w[ + controlplane.yml + release_script.sh + templates/app.yml + templates/db.yml + templates/rails.yml + templates/storage.yml + ].freeze + + # Fallback Ruby version when the repo doesn't pin one via `.ruby-version`, + # `.tool-versions`, or the `Gemfile`. Keep this on a supported release line + # (https://www.ruby-lang.org/en/downloads/branches/). + DEFAULT_RUBY_VERSION = "3.3.6" def copy_files - directory("generator_templates", ".controlplane", verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true") + generated_paths = copy_template_files("generator_templates", base_template_files) + generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project? + substitute_template_variables(generated_paths) + make_shell_scripts_executable(generated_paths) end def self.source_root Cpflow.root_path.join("lib") end + + private + + def copy_template_files(root_dir, relative_paths) + relative_paths.map { |relative_path| copy_template_file(root_dir, relative_path) } + end + + def copy_template_file(root_dir, relative_path) + destination_path = File.join(".controlplane", relative_path) + empty_directory(File.dirname(destination_path), verbose: false) + copy_file( + File.join(root_dir, relative_path), + destination_path, + force: true, + verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true" + ) + destination_path + end + + def base_template_files + return BASE_TEMPLATE_FILES + POSTGRES_TEMPLATE_FILES unless sqlite_project? + + # Exclude files that the SQLite pass rewrites to avoid copying Postgres-flavoured templates + # that would be immediately overwritten by generator_templates_sqlite/. + BASE_TEMPLATE_FILES - SQLITE_TEMPLATE_FILES + end + + def template_variables + { + "__APP_PREFIX__" => inferred_app_prefix, + "__RUBY_VERSION__" => inferred_ruby_version, + "__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run + } + end + + def inferred_app_prefix + RepoIntrospection.inferred_app_prefix(Dir.pwd) + end + + def inferred_ruby_version + ruby_version_from_ruby_version_file || + ruby_version_from_tool_versions || + ruby_version_from_gemfile || + DEFAULT_RUBY_VERSION + end + + def sqlite_project? + @sqlite_project ||= sqlite_database_in_production? + end + + def asset_precompile_hook_run + command = normalized_asset_precompile_hook_command + return "" unless command + + "RUN #{command}\n\n" + end + + def ruby_version_from_ruby_version_file + return unless File.file?(".ruby-version") + + parse_ruby_version(File.read(".ruby-version")) + end + + def ruby_version_from_tool_versions + return unless File.file?(".tool-versions") + + ruby_line = File.readlines(".tool-versions", chomp: true).find { |line| line.match?(/^\s*ruby\s+/) } + return unless ruby_line + + parse_ruby_version(ruby_line.sub(/^\s*ruby\s+/, "")) + end + + def ruby_version_from_gemfile + return unless File.file?("Gemfile") + + ruby_line = File.readlines("Gemfile", chomp: true).find { |line| line.match?(/^\s*ruby\s+/) } + return unless ruby_line + + parse_ruby_version(ruby_line.sub(/^\s*ruby\s+/, "")) + end + + def parse_ruby_version(source) + RepoIntrospection.parse_ruby_version_string(source) + end + + def sqlite_database_in_production? + RepoIntrospection.sqlite_database_in_production?(Dir.pwd) + end + + def normalized_asset_precompile_hook_command + command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook + return unless command + + command.start_with?("rake ") ? "bundle exec #{command}" : command + end + + def shakapacker_precompile_hook + return unless File.file?("config/shakapacker.yml") + + config = File.read("config/shakapacker.yml") + match = config.match(/^\s*precompile_hook:\s*["']?(.+?)["']?\s*$/) + match && match[1] + end + + def react_on_rails_auto_bundle_hook + return unless react_on_rails_auto_load_bundle? + + "bundle exec rake react_on_rails:generate_packs" + end + + def react_on_rails_auto_load_bundle? + return false unless File.file?("config/initializers/react_on_rails.rb") + + File.read("config/initializers/react_on_rails.rb").match?(/config\.auto_load_bundle\s*=\s*true\b/) + end end class Generate < Base NAME = "generate" DESCRIPTION = "Creates base Control Plane config and template files" LONG_DESCRIPTION = <<~DESC - Creates base Control Plane config and template files + Creates base Control Plane config and template files for a Rails project: + - infers the app prefix from the current directory and wires staging, review, and production entries + - infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile` + - preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation + - detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload DESC EXAMPLES = <<~EX ```sh - # Creates .controlplane directory with Control Plane config and other templates + # Creates .controlplane directory with Control Plane config and starter templates cpflow generate ``` EX WITH_INFO_HEADER = false VALIDATIONS = [].freeze + REQUIRES_STARTUP_CHECKS = false def call if controlplane_directory_exists? diff --git a/lib/command/generate_github_actions.rb b/lib/command/generate_github_actions.rb new file mode 100644 index 00000000..d975136b --- /dev/null +++ b/lib/command/generate_github_actions.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "pathname" + +require_relative "generator_helpers" + +module Command + class GithubActionsGenerator < Thor::Group + include Thor::Actions + include GeneratorHelpers + + def copy_files + copy_template_files(generated_files) + substitute_template_variables(generated_files) + make_shell_scripts_executable(generated_files) + end + + def self.source_root + Cpflow.root_path.join("lib") + end + + private + + def copy_template_files(relative_paths) + relative_paths.each do |relative_path| + empty_directory(File.dirname(relative_path), verbose: false) + copy_file( + File.join("github_flow_templates", relative_path), + relative_path, + force: true, + verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true" + ) + end + end + + def template_variables + { + "__CPFLOW_VERSION__" => ::Cpflow::VERSION + } + end + + def generated_files + GenerateGithubActions::GENERATED_FILES + end + end + + class GenerateGithubActions < Base + NAME = "generate-github-actions" + DESCRIPTION = "Creates GitHub Actions templates for review apps, staging deploys, and production promotion" + LONG_DESCRIPTION = <<~DESC + Creates GitHub Actions templates for a Heroku Flow style Control Plane pipeline: + - on-demand review apps for pull requests + - automatic staging deploys from your main branch + - manual promotion from staging to production + - nightly cleanup and PR help workflows + DESC + EXAMPLES = <<~EX + ```sh + # Creates .github/actions and .github/workflows files for the Control Plane flow + cpflow generate-github-actions + ``` + EX + WITH_INFO_HEADER = false + VALIDATIONS = [].freeze + REQUIRES_STARTUP_CHECKS = false + + # Resolve template root from __dir__ rather than Cpflow.root_path because this file is + # loaded before `module Cpflow` finishes defining its class methods. + TEMPLATE_ROOT = Pathname.new(File.expand_path("../github_flow_templates", __dir__)) + # Fail loudly on a broken install rather than silently generating zero files. + raise "cpflow template directory not found: #{TEMPLATE_ROOT}" unless TEMPLATE_ROOT.directory? + + GENERATED_FILES = Dir.glob(TEMPLATE_ROOT.join("**", "*").to_s, File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .map { |path| Pathname.new(path).relative_path_from(TEMPLATE_ROOT).to_s } + .sort + .freeze + + def call + if existing_files.any? + files = existing_files.map { |path| "- #{path}" }.join("\n") + Shell.warn("The following files already exist:\n#{files}\n\n" \ + "Remove or rename them before running `cpflow #{NAME}` again.") + return + end + + GithubActionsGenerator.start + end + + private + + def existing_files + @existing_files ||= GENERATED_FILES.select { |path| File.exist?(path) } + end + end +end diff --git a/lib/command/generator_helpers.rb b/lib/command/generator_helpers.rb new file mode 100644 index 00000000..5cd36cb5 --- /dev/null +++ b/lib/command/generator_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Command + module GeneratorHelpers + private + + def substitute_template_variables(file_paths, replacements = template_variables) + Array(file_paths).each do |path| + next unless File.file?(path) + + contents = File.read(path) + updated_contents = replacements.reduce(contents) do |memo, (placeholder, value)| + # Block form avoids regex-style back-reference interpretation (\1, \&, \\) in `value`. + memo.gsub(placeholder) { value } + end + + next if updated_contents == contents + + File.write(path, updated_contents) + end + end + + def make_shell_scripts_executable(file_paths) + Array(file_paths).each do |path| + next unless File.file?(path) && File.extname(path) == ".sh" + + FileUtils.chmod(0o755, path) + end + end + end +end diff --git a/lib/command/github_flow_readiness.rb b/lib/command/github_flow_readiness.rb new file mode 100644 index 00000000..9c920d9a --- /dev/null +++ b/lib/command/github_flow_readiness.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Command + class GithubFlowReadiness < Base + NAME = "github-flow-readiness" + DESCRIPTION = "Checks whether the current repo is ready for the Control Plane GitHub flow rollout" + LONG_DESCRIPTION = <<~DESC + Checks the current repository for common rollout blockers before adding the Control Plane GitHub flow: + - Rails runtime scaffold present + - modern Ruby and Bundler toolchain + - installable exact-pinned direct gem and npm package versions + - production Dockerfile presence and SQLite production hints + DESC + EXAMPLES = <<~EX + ```sh + # Checks the current repo for common rollout blockers + cpflow github-flow-readiness + ``` + EX + WITH_INFO_HEADER = false + VALIDATIONS = [].freeze + REQUIRES_STARTUP_CHECKS = false + + def call + service = GithubFlowReadinessService.new + + service.results.each do |result| + puts "[#{result.status.to_s.upcase}] #{result.message}" + end + + puts + puts service.summary + + exit(ExitCode::ERROR_DEFAULT) if service.blockers? + end + end +end diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index 57144f06..700b7226 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -14,6 +14,7 @@ class Generate < Base - Generates terraform configuration files based on `controlplane.yml` and `templates/` config DESC WITH_INFO_HEADER = false + REQUIRES_STARTUP_CHECKS = false def call Array(config.app || config.apps.keys).each do |app| diff --git a/lib/command/version.rb b/lib/command/version.rb index 86c2b910..e3e5c650 100644 --- a/lib/command/version.rb +++ b/lib/command/version.rb @@ -10,6 +10,7 @@ class Version < Base DESC WITH_INFO_HEADER = false VALIDATIONS = [].freeze + REQUIRES_STARTUP_CHECKS = false def call puts Cpflow::VERSION diff --git a/lib/core/github_flow_readiness_service.rb b/lib/core/github_flow_readiness_service.rb new file mode 100644 index 00000000..39a4cb28 --- /dev/null +++ b/lib/core/github_flow_readiness_service.rb @@ -0,0 +1,491 @@ +# frozen_string_literal: true + +require "bundler" +require "cgi" +require "yaml" + +require_relative "repo_introspection" + +class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength + Result = Struct.new(:status, :message, keyword_init: true) + RegistryCheck = Struct.new( + :dependencies, + :empty_message, + :missing_prefix, + :unknown_prefix, + :success_noun, + :availability_proc, + :registry_name, + keyword_init: true + ) + + LEGACY_RUBY_VERSION = Gem::Version.new("3.0.0") + LEGACY_BUNDLER_VERSION = Gem::Version.new("2.0.0") + PUBLIC_RUBYGEMS_REMOTE = "https://rubygems.org" + REQUIRED_RAILS_PATHS = ["Gemfile", "config/application.rb", "config.ru"].freeze + DOCKERFILE_PATHS = ["Dockerfile", ".controlplane/Dockerfile"].freeze + + attr_reader :root_path + + def initialize(root_path: Dir.pwd) + @root_path = Pathname.new(root_path) + end + + def results + @results ||= build_results + end + + def blockers? + results.any? { |result| result.status == :fail } + end + + def summary + if blockers? + "Blockers found. Fix them before generating the Control Plane GitHub flow." + else + "No blocking readiness issues detected. Validate the real production build path before merging." + end + end + + private + + def build_results + [ + rails_app_result, + ruby_version_result, + bundler_version_result, + dockerfile_result, + sqlite_result, + *gem_source_results, + gem_exact_pin_result, + npm_exact_pin_result + ].compact + end + + def rails_app_result + missing_paths = missing_paths_for(REQUIRED_RAILS_PATHS) + return rails_app_present_result if missing_paths.empty? + + Result.new(status: :fail, message: "Missing Rails runtime scaffold: #{format_path_list(missing_paths)}.") + end + + def ruby_version_result + toolchain_version_result( + version: inferred_ruby_version, + threshold: LEGACY_RUBY_VERSION, + missing_message: "Could not determine the app Ruby version.", + ok_message: ->(version) { "Ruby #{version} is modern enough for rollout." }, + fail_message: lambda do |version| + "Ruby #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow." + end + ) + end + + def bundler_version_result + toolchain_version_result( + version: lockfile_bundler_version, + threshold: LEGACY_BUNDLER_VERSION, + missing_message: "Could not determine the Bundler version from `Gemfile.lock`.", + ok_message: ->(version) { "Bundler #{version} is modern enough for rollout." }, + fail_message: lambda do |version| + "Bundler #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow." + end + ) + end + + def dockerfile_result + dockerfile_path = first_existing_path(DOCKERFILE_PATHS) + return dockerfile_present_result(dockerfile_path) if dockerfile_path + + missing_dockerfile_result + end + + def sqlite_result + return unless sqlite_database_in_production? + + Result.new( + status: :info, + message: "Production database config uses SQLite. `cpflow generate` will scaffold " \ + "persistent `db` and `storage` volumes." + ) + end + + def gem_source_results + non_public_dependencies = gem_dependencies.reject { |dependency| public_rubygems_dependency?(dependency) } + return [] if non_public_dependencies.empty? + + names = non_public_dependencies.map { |dependency| dependency[:name] }.sort + + [ + Result.new( + status: :warn, + message: "Direct Ruby dependencies using git/path or non-public gem sources need manual review: " \ + "#{names.map { |name| "`#{name}`" }.join(', ')}." + ) + ] + end + + def gem_exact_pin_result + exact_pin_registry_result(rubygems_registry_check) + end + + def npm_exact_pin_result + return package_json_parse_error_result if package_json_parse_error? + + exact_pin_registry_result(npm_registry_check) + end + + def gem_dependencies + @gem_dependencies ||= load_gem_dependencies + end + + def gem_source_type(source) + return :rubygems if source.nil? || source.is_a?(Bundler::Source::Rubygems) + return :path if source.is_a?(Bundler::Source::Path) + return :git if source.is_a?(Bundler::Source::Git) + + :other + end + + def gem_source_remotes(source) + return [] unless source.respond_to?(:remotes) + + Array(source.remotes).map { |remote| normalize_remote(remote) } + end + + def public_rubygems_dependency?(dependency) + return false unless dependency[:source_type] == :rubygems + + remotes = dependency[:source_remotes] + remotes.empty? || remotes.all? { |remote| remote == PUBLIC_RUBYGEMS_REMOTE } + end + + def exact_npm_dependencies + package_json = parsed_package_json + return [] unless package_json + + collect_exact_dependencies( + package_json.fetch("dependencies", {}), + package_json.fetch("devDependencies", {}) + ) + end + + def inferred_ruby_version + ruby_version_from_ruby_version_file || + ruby_version_from_tool_versions || + ruby_version_from_gemfile + end + + def ruby_version_from_ruby_version_file + file_path = root_path.join(".ruby-version") + return unless file_path.file? + + parse_ruby_version(file_path.read) + end + + def ruby_version_from_tool_versions + file_path = root_path.join(".tool-versions") + return unless file_path.file? + + ruby_line = file_path.readlines(chomp: true).find { |line| line.match?(/^\s*ruby\s+/) } + return unless ruby_line + + parse_ruby_version(ruby_line.sub(/^\s*ruby\s+/, "")) + end + + def ruby_version_from_gemfile + file_path = root_path.join("Gemfile") + return unless file_path.file? + + ruby_line = file_path.readlines(chomp: true).find { |line| line.match?(/^\s*ruby\s+/) } + return unless ruby_line + + parse_ruby_version(ruby_line.sub(/^\s*ruby\s+/, "")) + end + + def parse_ruby_version(source) + version = RepoIntrospection.parse_ruby_version_string(source) + return unless version + + Gem::Version.new(version) + end + + def lockfile_bundler_version + file_path = root_path.join("Gemfile.lock") + return unless file_path.file? + + lines = file_path.readlines(chomp: true) + bundler_index = lines.index("BUNDLED WITH") + return unless bundler_index + + version = lines[(bundler_index + 1)..]&.find { |line| !line.strip.empty? }&.strip + return unless version + + Gem::Version.new(version) + end + + def sqlite_database_in_production? + RepoIntrospection.sqlite_database_in_production?(root_path.to_s) + end + + def rubygems_requirement_available?(dependency) + versions = fetch_rubygems_versions(dependency[:name]) + return nil unless versions + + requirement = dependency[:requirement] + versions.any? { |version| requirement.satisfied_by?(Gem::Version.new(version)) } + end + + def npm_version_available?(name, version) + versions = fetch_npm_versions(name) + return nil unless versions + + versions.include?(version) + end + + def fetch_rubygems_versions(name) + @rubygems_versions ||= {} + return @rubygems_versions[name] if @rubygems_versions.key?(name) + + @rubygems_versions[name] = fetch_versions_from_rubygems(name) + end + + def fetch_npm_versions(name) + @npm_versions ||= {} + return @npm_versions[name] if @npm_versions.key?(name) + + @npm_versions[name] = fetch_versions_from_npm(name) + end + + def fetch_versions_from_rubygems(name) + uri = URI("https://rubygems.org/api/v1/versions/#{CGI.escape(name)}.json") + response = http_get(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body).map { |entry| entry["number"] } + rescue JSON::ParserError + nil + end + + def fetch_versions_from_npm(name) + uri = URI("https://registry.npmjs.org/#{npm_package_path_segment(name)}") + response = http_get(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body).fetch("versions", {}).keys + rescue JSON::ParserError + nil + end + + # npm registry expects scoped packages as "@scope%2Fpkg" — leave "@" literal and only encode "/". + def npm_package_path_segment(name) + name.gsub("/", "%2F") + end + + def http_get(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 5 + http.read_timeout = 5 + http.get(uri.request_uri) + rescue StandardError => e + warn "github_flow_readiness: HTTP GET #{uri} failed: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"] + nil + end + + def format_dependencies(dependencies) + dependencies.map { |dependency| "`#{dependency[:name]}@#{dependency[:exact_version]}`" }.join(", ") + end + + def format_path_list(paths) + paths.map { |path| "`#{path}`" }.join(", ") + end + + def missing_paths_for(paths) + paths.reject { |relative_path| root_path.join(relative_path).file? } + end + + def first_existing_path(paths) + paths.find { |relative_path| root_path.join(relative_path).file? } + end + + def rails_app_present_result + Result.new(status: :pass, message: "Rails app scaffold found (#{format_path_list(REQUIRED_RAILS_PATHS)}).") + end + + def dockerfile_present_result(dockerfile_path) + Result.new(status: :pass, message: "Found production Dockerfile at `#{dockerfile_path}`.") + end + + def missing_dockerfile_result + Result.new( + status: :fail, + message: "No production Dockerfile found at `Dockerfile` or `.controlplane/Dockerfile`. " \ + "Add and validate one before generating the Control Plane GitHub flow." + ) + end + + def toolchain_version_result(version:, threshold:, missing_message:, ok_message:, fail_message:) + return Result.new(status: :warn, message: missing_message) unless version + return Result.new(status: :pass, message: ok_message.call(version)) if version >= threshold + + Result.new(status: :fail, message: fail_message.call(version)) + end + + def load_gem_dependencies + lockfile_path = root_path.join("Gemfile.lock") + return [] unless lockfile_path.file? + + parse_gem_dependencies(lockfile_path) + rescue StandardError => e + warn "cpflow: failed to parse Gemfile.lock: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"] + [] + end + + # Parse Gemfile.lock via Bundler::LockfileParser rather than Bundler::Dsl#eval_gemfile. + # `eval_gemfile` instance_evals the user's Gemfile, which executes arbitrary Ruby. Readiness + # checks run against untrusted project trees, so we keep the trust boundary at "parse the + # lockfile only" — no Ruby from the user's repo is ever executed here. + def parse_gem_dependencies(lockfile_path) + parser = Bundler::LockfileParser.new(lockfile_path.read) + parser.dependencies.values.map do |dependency| + spec = parser.specs.find { |locked_spec| locked_spec.name == dependency.name } + build_gem_dependency(dependency, source: spec&.source) + end + end + + def build_gem_dependency(dependency, source:) + { + name: dependency.name, + exact_version: exact_gem_version(dependency), + requirement: dependency.requirement, + source_type: gem_source_type(source), + source_remotes: gem_source_remotes(source) + } + end + + def exact_gem_version(dependency) + dependency.requirement.requirements.first.last.to_s if dependency.requirement.exact? + end + + def exact_rubygems_dependencies + gem_dependencies.select do |dependency| + public_rubygems_dependency?(dependency) && dependency[:exact_version] + end + end + + def parsed_package_json + return @parsed_package_json if instance_variable_defined?(:@parsed_package_json) + + package_json_path = root_path.join("package.json") + @package_json_parse_error = false + return @parsed_package_json = nil unless package_json_path.file? + + @parsed_package_json = JSON.parse(package_json_path.read) + rescue JSON::ParserError + @package_json_parse_error = true + @parsed_package_json = nil + end + + def package_json_parse_error? + # `@package_json_parse_error` is set as a side effect of memoizing parsed_package_json; + # trigger it here so the flag reflects the parse result before we read it. + parsed_package_json + @package_json_parse_error + end + + def package_json_parse_error_result + Result.new( + status: :warn, + message: "Could not parse `package.json`; exact-pinned direct npm package readiness could not be fully verified." + ) + end + + def collect_exact_dependencies(*dependency_sets) + dependency_sets.flat_map { |dependencies| exact_dependency_entries(dependencies) } + end + + def exact_dependency_entries(dependencies) + dependencies.filter_map do |name, version| + { name: name, exact_version: version } if exact_version_string?(version) + end + end + + def exact_version_string?(version) + version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/) + end + + def normalize_remote(remote) + remote.to_s.sub(%r{/+\z}, "") + end + + def rubygems_registry_check + build_registry_check( + dependencies: exact_rubygems_dependencies, + empty_message: "No exact-pinned direct Ruby gems to verify.", + missing_prefix: "Direct Ruby gem versions not available on RubyGems", + unknown_prefix: "Could not verify some exact-pinned Ruby gems against RubyGems", + success_noun: "direct Ruby gem", + availability_proc: method(:rubygems_requirement_available?), + registry_name: "RubyGems" + ) + end + + def npm_registry_check + build_registry_check( + dependencies: exact_npm_dependencies, + empty_message: "No exact-pinned direct npm packages to verify.", + missing_prefix: "Direct npm package versions not available on npm", + unknown_prefix: "Could not verify some exact-pinned npm packages against npm", + success_noun: "direct npm package", + availability_proc: method(:npm_dependency_available?), + registry_name: "npm" + ) + end + + def build_registry_check(**attributes) + RegistryCheck.new(**attributes) + end + + def npm_dependency_available?(dependency) + npm_version_available?(dependency[:name], dependency[:exact_version]) + end + + def exact_pin_registry_result(check) + return Result.new(status: :info, message: check.empty_message) if check.dependencies.empty? + + grouped_dependencies = partition_dependencies(check.dependencies, check.availability_proc) + unavailable_dependencies = grouped_dependencies.fetch(:unavailable) + unknown_dependencies = grouped_dependencies.fetch(:unknown) + return registry_unavailable_result(check, unavailable_dependencies) if unavailable_dependencies.any? + return registry_unknown_result(check, unknown_dependencies) if unknown_dependencies.any? + + Result.new(status: :pass, message: registry_success_message(check)) + end + + def partition_dependencies(dependencies, availability_proc) + dependencies.each_with_object(unavailable: [], unknown: []) do |dependency, grouped_dependencies| + case availability_proc.call(dependency) + when false + grouped_dependencies[:unavailable] << dependency + when nil + grouped_dependencies[:unknown] << dependency + end + end + end + + def registry_success_message(check) + dependency_count = check.dependencies.length + noun = "#{check.success_noun}#{'s' if dependency_count != 1}" + + "Checked #{dependency_count} exact-pinned #{noun}; all appear available on #{check.registry_name}." + end + + def registry_unavailable_result(check, dependencies) + Result.new(status: :fail, message: "#{check.missing_prefix}: #{format_dependencies(dependencies)}.") + end + + def registry_unknown_result(check, dependencies) + Result.new(status: :warn, message: "#{check.unknown_prefix}: #{format_dependencies(dependencies)}.") + end +end diff --git a/lib/core/repo_introspection.rb b/lib/core/repo_introspection.rb new file mode 100644 index 00000000..e9df0054 --- /dev/null +++ b/lib/core/repo_introspection.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "yaml" + +module RepoIntrospection + DEFAULT_APP_PREFIX = "my-app" + + # Pure string → version-string extractor. Strips a leading `ruby-` prefix and returns + # the first `MAJOR.MINOR[.PATCH]` found in the source, or nil. + def self.parse_ruby_version_string(source) + normalized = source.strip.sub(/\Aruby-/, "") + normalized[/\d+\.\d+(?:\.\d+)?/] + end + + # Returns a Control Plane-safe app prefix derived from the basename of `root`: + # lower-cased, with non-alphanumeric runs collapsed to dashes and stripped from + # the ends. Falls back to DEFAULT_APP_PREFIX when the result is empty. + def self.inferred_app_prefix(root) + sanitized = File.basename(root) + .downcase + .gsub(/[^a-z0-9]+/, "-") + .gsub(/\A-+|-+\z/, "") + + sanitized.empty? ? DEFAULT_APP_PREFIX : sanitized + end + + # Returns true if `config/database.yml` under `root` configures SQLite for production + # (either directly or via `<<: *default`). ERB in the YAML is stubbed before parsing. + def self.sqlite_database_in_production?(root) + path = File.join(root, "config/database.yml") + return false unless File.file?(path) + + parsed = safe_load_database_yml(File.read(path)) + return false unless parsed.is_a?(Hash) + + production = parsed["production"] + return false unless production.is_a?(Hash) + + sqlite_adapter_in_hash?(production) || sqlite_adapter_in_hash?(parsed["default"]) + end + + def self.safe_load_database_yml(raw_contents) + stubbed = raw_contents.gsub(/<%=.*?%>/m, "__erb__").gsub(/<%.*?%>/m, "") + YAML.safe_load(stubbed, aliases: true, permitted_classes: [Symbol]) + rescue Psych::SyntaxError + nil + end + + def self.sqlite_adapter_in_hash?(config) + return false unless config.is_a?(Hash) + + adapter = config["adapter"] + adapter.is_a?(String) && adapter.strip.start_with?("sqlite3") + end +end diff --git a/lib/cpflow.rb b/lib/cpflow.rb index 140d1891..0e4804d0 100644 --- a/lib/cpflow.rb +++ b/lib/cpflow.rb @@ -53,9 +53,8 @@ def self.start(*args) ENV["CPLN_SKIP_UPDATE_CHECK"] = "true" ENV["NODE_NO_WARNINGS"] = "1" - check_cpln_version - check_cpflow_version fix_help_option + run_startup_checks if requires_startup_checks? super end @@ -120,6 +119,61 @@ def self.subcommand? end private_class_method :subcommand? + def self.run_startup_checks + check_cpln_version + check_cpflow_version + end + private_class_method :run_startup_checks + + def self.requires_startup_checks?(argv = ARGV) + return false if argv.empty? + return false if help_request?(argv) + return false if version_flag?(argv) + + command_class = command_class_for_argv(argv) + command_class ? command_class::REQUIRES_STARTUP_CHECKS : false + end + private_class_method :requires_startup_checks? + + def self.help_request?(argv) + help_mappings = Thor::HELP_MAPPINGS + ["help"] + help_mappings.include?(argv.first) + end + private_class_method :help_request? + + def self.version_flag?(argv) + %w[--version -v].include?(argv.first) + end + private_class_method :version_flag? + + def self.command_class_for_argv(argv) + first_arg = argv[0] + return if first_arg.nil? + + return subcommand_class_for_argv(first_arg, argv[1]) if subcommand_names.include?(first_arg) + + top_level_command_class_for(first_arg) + end + private_class_method :command_class_for_argv + + def self.subcommand_class_for_argv(subcommand_name, command_name) + return if command_name.nil? + + all_base_commands[:"#{subcommand_name}_#{command_name.tr('-', '_')}"] || + all_base_commands.values.find do |command_class| + subcommand_name == command_class::SUBCOMMAND_NAME && command_name == command_class::NAME + end + end + private_class_method :subcommand_class_for_argv + + def self.top_level_command_class_for(command_name) + all_base_commands[command_name.tr("-", "_").to_sym] || + all_base_commands.values.find do |command_class| + command_class::SUBCOMMAND_NAME.nil? && command_name == command_class::NAME + end + end + private_class_method :top_level_command_class_for + # Needed to silence deprecation warning def self.exit_on_failure? true diff --git a/lib/generator_templates/Dockerfile b/lib/generator_templates/Dockerfile index d284cbb4..7588155e 100644 --- a/lib/generator_templates/Dockerfile +++ b/lib/generator_templates/Dockerfile @@ -1,23 +1,73 @@ -FROM ruby:3.1.2 +ARG RUBY_VERSION=__RUBY_VERSION__ -RUN apt-get update +# Node and Ruby base images share the same Debian release so that glibc, libssl, and +# other system libraries line up between stages. +FROM docker.io/library/node:22-bookworm-slim AS node +FROM ruby:$RUBY_VERSION-slim-bookworm WORKDIR /app +# Keep Node.js available both for asset compilation and for SSR runtimes that +# rely on ExecJS in production. Narrowed to just what the node stage actually +# ships under /usr/local so we don't drag in unused Debian libs from that image. +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/bin/npm /usr/local/bin/npm +COPY --from=node /usr/local/bin/npx /usr/local/bin/npx +COPY --from=node /usr/local/bin/corepack /usr/local/bin/corepack +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=node /usr/local/include/node /usr/local/include/node + +# Expose Corepack-managed shims so later build steps can call yarn/pnpm +# directly during asset precompilation hooks. +RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \ + chmod +x /usr/bin/yarn && \ + printf '%s\n' '#!/bin/sh' 'exec corepack pnpm "$@"' > /usr/bin/pnpm && \ + chmod +x /usr/bin/pnpm + # install ruby gems COPY Gemfile* ./ RUN bundle config set without 'development test' && \ - bundle config set with 'staging production' && \ + bundle config set with 'production' && \ bundle install --jobs=3 --retry=3 COPY . ./ +# Install JavaScript dependencies only when the project actually has them. +# Pin `packageManager` in package.json to take the corepack path and avoid the hardcoded +# yarn@1.22.22 / pnpm@9.12.3 fallbacks below; those fallbacks exist for projects that +# haven't adopted corepack and should be reviewed periodically as they will go stale. +RUN if [ -f package.json ]; then \ + package_manager="$(node -p "require('./package.json').packageManager || ''")"; \ + if [ -f yarn.lock ]; then \ + if printf '%s' "$package_manager" | grep -q '^yarn@'; then \ + corepack prepare "$package_manager" --activate && \ + (corepack yarn install --immutable || corepack yarn install --frozen-lockfile); \ + else \ + npm install -g yarn@1.22.22 && \ + (yarn install --immutable || yarn install --frozen-lockfile); \ + fi; \ + elif [ -f pnpm-lock.yaml ]; then \ + if printf '%s' "$package_manager" | grep -q '^pnpm@'; then \ + corepack prepare "$package_manager" --activate && \ + corepack pnpm install --frozen-lockfile; \ + else \ + corepack prepare pnpm@9.12.3 --activate && \ + corepack pnpm install --frozen-lockfile; \ + fi; \ + elif [ -f package-lock.json ]; then \ + npm ci; \ + else \ + npm install; \ + fi; \ + fi + ENV RAILS_ENV=production # compiling assets requires any value for ENV of SECRET_KEY_BASE ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK +__ASSET_PRECOMPILE_HOOK_RUN__ RUN rails assets:precompile # add entrypoint diff --git a/lib/generator_templates/controlplane.yml b/lib/generator_templates/controlplane.yml index c3ef573e..80dba262 100644 --- a/lib/generator_templates/controlplane.yml +++ b/lib/generator_templates/controlplane.yml @@ -1,62 +1,46 @@ # Keys beginning with "cpln_" correspond to your settings in Control Plane. +# +# Generated baseline for a Rails app that uses PostgreSQL in production. +# Rename the app keys below if you want something other than the repo name. -# You can opt out of allowing the use of CPLN_ORG and CPLN_APP env vars -# to avoid any accidents with the wrong org / app. allow_org_override_by_env: true allow_app_override_by_env: true aliases: common: &common - # Organization name for staging (customize to your needs). - # Production apps will use a different organization, specified below, for security. cpln_org: my-org-staging - - # Example apps use only one location. Control Plane offers the ability to use multiple locations. - # TODO: Allow specification of multiple locations. default_location: aws-us-east-2 + setup_app_templates: + - app + - postgres + - rails - # Configure the workload name used as a template for one-off scripts, like a Heroku one-off dyno. one_off_workload: rails - - # Workloads that are for the application itself and are using application Docker images. - # These are updated with the new image when running the `deploy-image` command, - # and are also used by the `info` and `ps:` commands in order to get all of the defined workloads. - # On the other hand, if you have a workload for Redis, that would NOT use the application Docker image - # and not be listed here. app_workloads: - rails - - # Additional "service type" workloads, using non-application Docker images. - # These are only used by the `info` and `ps:` commands in order to get all of the defined workloads. additional_workloads: - postgres - # Configure the workload name used when maintenance mode is on (defaults to "maintenance") maintenance_workload: maintenance + release_script: release_script.sh -apps: - my-app-staging: - # Use the values from the common section above. - <<: *common - my-app-review: - <<: *common - # If `match_if_app_name_starts_with` is `true`, then use this config for app names starting with this name, - # e.g., "my-app-review-pr123", "my-app-review-anything-goes", etc. - match_if_app_name_starts_with: true - my-app-production: - <<: *common + stale_app_image_deployed_days: 5 + image_retention_days: 7 - # You can also opt out of allowing the use of CPLN_ORG and CPLN_APP env vars per app. - # It's recommended to leave this off for production, to avoid any accidents. + production: &production + <<: *common allow_org_override_by_env: false allow_app_override_by_env: false - - # Use a different organization for production. cpln_org: my-org-production - # Allows running the command `cpflow promote-app-from-upstream -a my-app-production` - # to promote the staging app to production. - upstream: my-app-staging - my-app-other: + upstream: __APP_PREFIX__-staging + +apps: + __APP_PREFIX__-staging: <<: *common - # You can specify a different `Dockerfile` relative to the `.controlplane/` directory (defaults to "Dockerfile"). - dockerfile: ../some_other/Dockerfile + + __APP_PREFIX__-review: + <<: *common + match_if_app_name_starts_with: true + + __APP_PREFIX__-production: + <<: *production diff --git a/lib/generator_templates/entrypoint.sh b/lib/generator_templates/entrypoint.sh old mode 100644 new mode 100755 index e0cb9601..2fce44f5 --- a/lib/generator_templates/entrypoint.sh +++ b/lib/generator_templates/entrypoint.sh @@ -4,5 +4,5 @@ echo " -- Preparing database" rails db:prepare -echo " -- Finishing entrypoint.sh, executing '$@'" +echo " -- Finishing entrypoint.sh, executing '$*'" exec "$@" diff --git a/lib/generator_templates/release_script.sh b/lib/generator_templates/release_script.sh new file mode 100644 index 00000000..fe271847 --- /dev/null +++ b/lib/generator_templates/release_script.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +log() { + echo "[$(date +%Y-%m-%d:%H:%M:%S)]: $1" +} + +error_exit() { + log "$1" 1>&2 + exit 1 +} + +log "Running release_script.sh per controlplane.yml" + +if [ -x ./bin/rails ]; then + log "Run DB migrations" + SECRET_KEY_BASE="${SECRET_KEY_BASE:-precompile_placeholder}" ./bin/rails db:prepare || \ + error_exit "Failed to run DB migrations" +else + error_exit "./bin/rails does not exist or is not executable" +fi + +log "Completed release_script.sh per controlplane.yml" diff --git a/lib/generator_templates/templates/app.yml b/lib/generator_templates/templates/app.yml index 5a257f92..fb3f4f15 100644 --- a/lib/generator_templates/templates/app.yml +++ b/lib/generator_templates/templates/app.yml @@ -2,20 +2,17 @@ kind: gvc name: {{APP_NAME}} spec: - # For using templates for test apps, put ENV values here, stored in git repo. - # Production apps will have values configured manually after app creation. env: - name: DATABASE_URL - # Password does not matter because host postgres.{{APP_NAME}}.cpln.local can only be accessed - # locally within CPLN GVC, and postgres running on a CPLN workload is something only for a - # test app that lacks persistence. value: 'postgres://the_user:the_password@postgres.{{APP_NAME}}.cpln.local:5432/{{APP_NAME}}' - name: RAILS_ENV value: production + - name: RAILS_LOG_TO_STDOUT + value: "true" - name: RAILS_SERVE_STATIC_FILES - value: 'true' - - # Part of standard configuration + value: "true" + - name: SECRET_KEY_BASE + value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE staticPlacement: locationLinks: - {{APP_LOCATION_LINK}} diff --git a/lib/generator_templates/templates/rails.yml b/lib/generator_templates/templates/rails.yml index e559b8fe..c2e72b95 100644 --- a/lib/generator_templates/templates/rails.yml +++ b/lib/generator_templates/templates/rails.yml @@ -6,31 +6,22 @@ spec: type: standard containers: - name: rails - # 300m is a good starting place for a test app. You can experiment with CPU configuration - # once your app is running. cpu: 300m - env: - - name: LOG_LEVEL - value: debug - # Inherit other ENV values from GVC inheritEnv: true image: {{APP_IMAGE_LINK}} - # 512 corresponds to a standard 1x dyno type memory: 512Mi ports: - number: 3000 protocol: http defaultOptions: - # Start out like this for "test apps" autoscaling: - # Max of 1 effectively disables autoscaling, so a like a Heroku dyno count of 1 + minScale: 1 maxScale: 1 capacityAI: false + timeoutSeconds: 60 firewallConfig: external: - # Default to allow public access to Rails server inboundAllowCIDR: - 0.0.0.0/0 - # Could configure outbound for more security outboundAllowCIDR: - 0.0.0.0/0 diff --git a/lib/generator_templates_sqlite/controlplane.yml b/lib/generator_templates_sqlite/controlplane.yml new file mode 100644 index 00000000..76ef9b96 --- /dev/null +++ b/lib/generator_templates_sqlite/controlplane.yml @@ -0,0 +1,46 @@ +# Keys beginning with "cpln_" correspond to your settings in Control Plane. +# +# Generated baseline for a Rails app that uses SQLite in production. This setup +# keeps `/app/db` and `/app/storage` on persistent volumes. + +allow_org_override_by_env: true +allow_app_override_by_env: true + +aliases: + common: &common + cpln_org: my-org-staging + default_location: aws-us-east-2 + setup_app_templates: + - app + - db + - storage + - rails + + one_off_workload: rails + app_workloads: + - rails + additional_workloads: [] + + maintenance_workload: maintenance + release_script: release_script.sh + + stale_app_image_deployed_days: 5 + image_retention_days: 7 + + production: &production + <<: *common + allow_org_override_by_env: false + allow_app_override_by_env: false + cpln_org: my-org-production + upstream: __APP_PREFIX__-staging + +apps: + __APP_PREFIX__-staging: + <<: *common + + __APP_PREFIX__-review: + <<: *common + match_if_app_name_starts_with: true + + __APP_PREFIX__-production: + <<: *production diff --git a/lib/generator_templates_sqlite/release_script.sh b/lib/generator_templates_sqlite/release_script.sh new file mode 100644 index 00000000..3f94bf64 --- /dev/null +++ b/lib/generator_templates_sqlite/release_script.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +log() { + echo "[$(date +%Y-%m-%d:%H:%M:%S)]: $1" +} + +error_exit() { + log "$1" 1>&2 + exit 1 +} + +log "Running release_script.sh per controlplane.yml" + +mkdir -p db storage + +if [ -x ./bin/rails ]; then + log "Run DB migrations" + SECRET_KEY_BASE="${SECRET_KEY_BASE:-precompile_placeholder}" ./bin/rails db:prepare || \ + error_exit "Failed to run DB migrations" +else + error_exit "./bin/rails does not exist or is not executable" +fi + +log "Completed release_script.sh per controlplane.yml" diff --git a/lib/generator_templates_sqlite/templates/app.yml b/lib/generator_templates_sqlite/templates/app.yml new file mode 100644 index 00000000..bf87f46b --- /dev/null +++ b/lib/generator_templates_sqlite/templates/app.yml @@ -0,0 +1,15 @@ +kind: gvc +name: {{APP_NAME}} +spec: + env: + - name: RAILS_ENV + value: production + - name: RAILS_LOG_TO_STDOUT + value: "true" + - name: RAILS_SERVE_STATIC_FILES + value: "true" + - name: SECRET_KEY_BASE + value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE + staticPlacement: + locationLinks: + - {{APP_LOCATION_LINK}} diff --git a/lib/generator_templates_sqlite/templates/db.yml b/lib/generator_templates_sqlite/templates/db.yml new file mode 100644 index 00000000..921675b2 --- /dev/null +++ b/lib/generator_templates_sqlite/templates/db.yml @@ -0,0 +1,6 @@ +kind: volumeset +name: app-db +spec: + fileSystemType: ext4 + initialCapacity: 5 + performanceClass: general-purpose-ssd diff --git a/lib/generator_templates_sqlite/templates/rails.yml b/lib/generator_templates_sqlite/templates/rails.yml new file mode 100644 index 00000000..b1b31976 --- /dev/null +++ b/lib/generator_templates_sqlite/templates/rails.yml @@ -0,0 +1,32 @@ +kind: workload +name: rails +spec: + type: standard + containers: + - name: rails + cpu: 300m + inheritEnv: true + image: {{APP_IMAGE_LINK}} + memory: 512Mi + ports: + - number: 3000 + protocol: http + volumes: + - path: /app/db + recoveryPolicy: retain + uri: cpln://volumeset/app-db + - path: /app/storage + recoveryPolicy: retain + uri: cpln://volumeset/app-storage + defaultOptions: + autoscaling: + minScale: 1 + maxScale: 1 + capacityAI: false + timeoutSeconds: 60 + firewallConfig: + external: + inboundAllowCIDR: + - 0.0.0.0/0 + outboundAllowCIDR: + - 0.0.0.0/0 diff --git a/lib/generator_templates_sqlite/templates/storage.yml b/lib/generator_templates_sqlite/templates/storage.yml new file mode 100644 index 00000000..8956013e --- /dev/null +++ b/lib/generator_templates_sqlite/templates/storage.yml @@ -0,0 +1,6 @@ +kind: volumeset +name: app-storage +spec: + fileSystemType: ext4 + initialCapacity: 10 + performanceClass: general-purpose-ssd diff --git a/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml b/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml new file mode 100644 index 00000000..cc75f7a0 --- /dev/null +++ b/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml @@ -0,0 +1,106 @@ +name: Build Docker Image +description: Builds and pushes the app image for a Control Plane workload + +inputs: + app_name: + description: Name of the application + required: true + org: + description: Control Plane organization name + required: true + commit: + description: Commit SHA to tag the image with + required: true + pr_number: + description: Pull request number for status messaging + required: false + docker_build_extra_args: + description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar. + required: false + docker_build_ssh_key: + description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh + required: false + docker_build_ssh_known_hosts: + description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys. + required: false + +runs: + using: composite + steps: + # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present + # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any + # command runs, so keeping the key out of env there avoids even admin-triggered exposure. + - name: Prepare SSH agent for Docker build + if: ${{ inputs.docker_build_ssh_key != '' }} + shell: bash + env: + DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }} + run: | + set -euo pipefail + + umask 077 + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then + printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts + else + printf '%s\n' \ + 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \ + 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \ + 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \ + > ~/.ssh/known_hosts + fi + chmod 600 ~/.ssh/known_hosts + + # Write the key via inline template interpolation. Quoted heredoc terminator + # prevents shell expansion; GitHub substitutes the raw value into the script body + # before bash sees it, and propagates masking from the originating ${{ secrets.* }}. + cat > ~/.ssh/cpflow_build_key <<'CPFLOW_SSH_KEY_EOF' + ${{ inputs.docker_build_ssh_key }} + CPFLOW_SSH_KEY_EOF + chmod 600 ~/.ssh/cpflow_build_key + + - name: Build Docker image + shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + COMMIT_SHA: ${{ inputs.commit }} + CONTROL_PLANE_ORG: ${{ inputs.org }} + DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + + PR_INFO="" + docker_build_args=() + + if [[ -n "${PR_NUMBER}" ]]; then + PR_INFO=" for PR #${PR_NUMBER}" + fi + + if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then + while IFS= read -r arg; do + arg="${arg%$'\r'}" + [[ -n "${arg}" ]] || continue + + if [[ "${arg}" =~ [[:space:]] ]]; then + echo "docker_build_extra_args entries must be single docker-build tokens. " \ + "Use key=value forms like --build-arg=FOO=bar." >&2 + exit 1 + fi + + docker_build_args+=("${arg}") + done <<< "${DOCKER_BUILD_EXTRA_ARGS}" + fi + + if [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then + eval "$(ssh-agent -s)" + trap 'ssh-agent -k >/dev/null; rm -f "${HOME}/.ssh/cpflow_build_key"' EXIT + ssh-add "${HOME}/.ssh/cpflow_build_key" + docker_build_args+=("--ssh=default") + fi + + echo "🏗️ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..." + cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}" + echo "✅ Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})" diff --git a/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml b/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml new file mode 100644 index 00000000..63981dd5 --- /dev/null +++ b/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml @@ -0,0 +1,24 @@ +name: Delete Control Plane App +description: Deletes a Control Plane app and all associated resources + +inputs: + app_name: + description: Name of the application to delete + required: true + cpln_org: + description: Control Plane organization name + required: true + review_app_prefix: + description: Prefix used for review app names + required: true + +runs: + using: composite + steps: + - name: Delete application + shell: bash + run: ${{ github.action_path }}/delete-app.sh + env: + APP_NAME: ${{ inputs.app_name }} + CPLN_ORG: ${{ inputs.cpln_org }} + REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }} diff --git a/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh new file mode 100755 index 00000000..d7233cbc --- /dev/null +++ b/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -euo pipefail + +: "${APP_NAME:?APP_NAME environment variable is required}" +: "${CPLN_ORG:?CPLN_ORG environment variable is required}" +: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}" + +expected_prefix="${REVIEW_APP_PREFIX}-" +if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then + echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2 + echo "App name: $APP_NAME" >&2 + echo "Expected prefix: ${expected_prefix}" >&2 + exit 1 +fi + +echo "🔍 Checking if application exists: $APP_NAME" +# Contract this relies on from `cpflow exists`: +# - Exit status 0 → app exists (stdout may contain an informational banner). +# - Exit status non-zero, no +# recognizable error tokens → app does not exist; treat as a no-op success. +# - Exit status non-zero with +# tokens like "Double check +# your org", "Unknown API +# token format", "ERROR", +# "Error:", "Traceback", or +# "Net::" → a real failure; surface and exit 1. +# Keep this list in sync if `cpflow exists` starts emitting new error patterns, or swap this +# for a structured interface (e.g. `cpflow exists --quiet --json`) once one is available. +exists_output="" +if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then + case "$exists_output" in + *"Double check your org"*|*"Unknown API token format"*|*"ERROR"*|*"Error:"*|*"Traceback"*|*"Net::"*) + echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2 + printf '%s\n' "$exists_output" >&2 + exit 1 + ;; + esac + + if [[ -n "$exists_output" ]]; then + printf '%s\n' "$exists_output" + fi + + echo "⚠️ Application does not exist: $APP_NAME" + exit 0 +fi + +if [[ -n "$exists_output" ]]; then + printf '%s\n' "$exists_output" +fi + +echo "🗑️ Deleting application: $APP_NAME" +cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes + +echo "✅ Successfully deleted application: $APP_NAME" diff --git a/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml b/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml new file mode 100644 index 00000000..772bd7e6 --- /dev/null +++ b/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml @@ -0,0 +1,75 @@ +name: Setup Control Plane Environment +description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile + +inputs: + token: + description: Control Plane token + required: true + org: + description: Control Plane organization + required: true + ruby_version: + description: >- + Ruby version used for cpflow. When empty (the default), ruby/setup-ruby auto-detects + from .ruby-version, .tool-versions, or the Gemfile. + required: false + default: "" + cpln_cli_version: + # Bump this default when a new @controlplane/cli release lands that you want to roll out. + description: "@controlplane/cli version" + required: false + default: "3.3.1" + cpflow_version: + description: cpflow gem version + required: false + default: "__CPFLOW_VERSION__" + +runs: + using: composite + steps: + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby_version }} + + - name: Install Control Plane CLI and cpflow gem + shell: bash + run: | + set -euo pipefail + + sudo npm install -g @controlplane/cli@${{ inputs.cpln_cli_version }} + cpln --version + + gem install cpflow -v ${{ inputs.cpflow_version }} + cpflow --version + + - name: Setup Control Plane profile and registry login + shell: bash + env: + # Pass the token via CPLN_TOKEN so cpln picks it up from the environment + # rather than `--token`, which would leak it into /proc//cmdline and ps output. + CPLN_TOKEN: ${{ inputs.token }} + ORG: ${{ inputs.org }} + run: | + set -euo pipefail + + if [[ -z "$CPLN_TOKEN" ]]; then + echo "Error: Control Plane token not provided" >&2 + exit 1 + fi + + if [[ -z "$ORG" ]]; then + echo "Error: Control Plane organization not provided" >&2 + exit 1 + fi + + create_output="" + if ! create_output="$(cpln profile create default --org "$ORG" 2>&1)"; then + if ! echo "$create_output" | grep -qi "already exists"; then + echo "$create_output" >&2 + exit 1 + fi + fi + + cpln profile update default --org "$ORG" + cpln image docker-login --org "$ORG" diff --git a/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml new file mode 100644 index 00000000..d8dc5a95 --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml @@ -0,0 +1,47 @@ +name: Cleanup Stale Review Apps + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +concurrency: + group: cpflow-cleanup-stale-review-apps + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.REVIEW_APP_PREFIX }}" ]] || missing+=("variable:REVIEW_APP_PREFIX") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Remove stale review apps + shell: bash + run: | + set -euo pipefail + cpflow cleanup-stale-apps -a "${{ vars.REVIEW_APP_PREFIX }}" --org "${{ vars.CPLN_ORG_STAGING }}" --yes diff --git a/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml b/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml new file mode 100644 index 00000000..7de68c61 --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml @@ -0,0 +1,130 @@ +name: Delete Review App + +on: + pull_request_target: + types: [closed] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number targeted for deletion + required: true + type: number + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + # Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave + # partial state behind. Let the in-progress deletion finish before the next run starts. + cancel-in-progress: false + +env: + APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + +jobs: + delete-review-app: + if: | + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/delete-review-app' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_target' && github.event.action == 'closed') || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.REVIEW_APP_PREFIX }}" ]] || missing+=("variable:REVIEW_APP_PREFIX") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Set workflow links + uses: actions/github-script@v7 + with: + script: | + const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + core.exportVariable("WORKFLOW_URL", workflowUrl); + core.exportVariable( + "CONSOLE_URL", + `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/-info` + ); + + - name: Create initial PR comment + id: create-comment + uses: actions/github-script@v7 + with: + script: | + const comment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body: "🗑️ Deleting Control Plane review app..." + }); + core.setOutput("comment-id", comment.data.id); + + - name: Delete review app + uses: ./.github/actions/cpflow-delete-control-plane-app + with: + app_name: ${{ env.APP_NAME }} + cpln_org: ${{ vars.CPLN_ORG_STAGING }} + review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }} + + - name: Finalize delete status + if: always() + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + const success = "${{ job.status }}" === "success"; + const body = success + ? [ + `✅ Review app for PR #${process.env.PR_NUMBER} is deleted`, + "", + `[Open organization console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n") + : [ + `❌ Failed to delete review app for PR #${process.env.PR_NUMBER}`, + "", + `[Open organization console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping delete status comment update because no comment id was created."); + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); diff --git a/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml b/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml new file mode 100644 index 00000000..5ffe0f31 --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml @@ -0,0 +1,397 @@ +name: Deploy Review App to Control Plane + +run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to deploy + required: true + type: number + +permissions: + contents: read + deployments: write + issues: write + pull-requests: write + +concurrency: + group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + cancel-in-progress: true + +env: + APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + +jobs: + deploy: + # Skip synchronize/opened events from fork PRs at the job level — they cannot access + # repository secrets anyway, so running any steps just burns billable minutes. Users + # can still manually deploy a fork PR via `/deploy-review-app` (gated below by + # author_association) or workflow_dispatch. + if: | + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/deploy-review-app' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) + runs-on: ubuntu-latest + + steps: + - name: Initial checkout + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + id: config + shell: bash + run: | + set -euo pipefail + + missing=() + + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.REVIEW_APP_PREFIX }}" ]] || missing+=("variable:REVIEW_APP_PREFIX") + + if [[ ${#missing[@]} -gt 0 ]]; then + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + { + echo "Control Plane review app automation is not configured yet." + echo + echo "Missing required GitHub configuration:" + printf -- '- `%s`\n' "${missing[@]}" + echo + echo "Pushes to this pull request will skip review app deploys until the repository is configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Resolve PR ref and commit + if: steps.config.outputs.ready == 'true' + id: resolve-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + case "${{ github.event_name }}" in + workflow_dispatch) + pr_number="${{ github.event.inputs.pr_number }}" + ;; + issue_comment) + pr_number="${{ github.event.issue.number }}" + ;; + pull_request) + pr_number="${{ github.event.pull_request.number }}" + ;; + *) + echo "Unsupported event type: ${{ github.event_name }}" >&2 + exit 1 + ;; + esac + + pr_data="$(gh pr view "$pr_number" --json headRefOid,headRepository,headRepositoryOwner)" + pr_sha="$(echo "$pr_data" | jq -r '.headRefOid')" + pr_repository="$(echo "$pr_data" | jq -r '[.headRepositoryOwner.login, .headRepository.name] | join("/")')" + same_repo="false" + + if [[ "$pr_repository" == "$GITHUB_REPOSITORY" ]]; then + same_repo="true" + fi + + echo "PR_NUMBER=$pr_number" >> "$GITHUB_ENV" + echo "APP_NAME=${{ vars.REVIEW_APP_PREFIX }}-$pr_number" >> "$GITHUB_ENV" + echo "PR_SHA=$pr_sha" >> "$GITHUB_ENV" + echo "same_repo=${same_repo}" >> "$GITHUB_OUTPUT" + + - name: Validate review app deployment source + if: steps.config.outputs.ready == 'true' + id: source + shell: bash + run: | + set -euo pipefail + + if [[ "${{ steps.resolve-pr.outputs.same_repo }}" == "true" ]]; then + echo "allowed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "allowed=false" >> "$GITHUB_OUTPUT" + { + echo "Review app deploys are skipped for fork pull requests." + echo "This workflow builds Docker images with repository secrets, so review app deploys only run for branches in the base repository." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "Review app deploys from fork pull requests are not allowed because this workflow uses repository secrets." >&2 + exit 1 + + - name: Checkout PR commit + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ env.PR_SHA }} + + - name: Setup environment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Detect release phase support + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + id: release-phase + shell: bash + run: | + set -euo pipefail + + # Anchor to start-of-line so commented-out release_script: entries don't enable --run-release-phase. + if cpflow config -a "${APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Check if review app exists + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + id: check-app + shell: bash + run: | + set -euo pipefail + + if cpflow exists -a "${APP_NAME}" --org "${CPLN_ORG}"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip auto deploy until a review app is created + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request' + shell: bash + run: | + { + echo "Review app ${APP_NAME} does not exist yet." + echo "Create it with a PR comment that is exactly /deploy-review-app." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Setup review app if it does not exist yet + id: setup-review-app + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request' + shell: bash + run: | + set -euo pipefail + cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" + + - name: Create initial PR comment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: create-comment + uses: actions/github-script@v7 + with: + script: | + const result = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body: "🚀 Starting Control Plane review app deployment..." + }); + core.setOutput("comment-id", result.data.id); + + - name: Set deployment links + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + core.exportVariable("WORKFLOW_URL", workflowUrl); + core.exportVariable( + "CONSOLE_URL", + `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/gvc/${process.env.APP_NAME}/-info` + ); + + - name: Initialize GitHub deployment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: init-deployment + uses: actions/github-script@v7 + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: process.env.PR_SHA, + environment: `review/${process.env.APP_NAME}`, + auto_merge: false, + required_contexts: [], + description: `Control Plane review app for PR #${process.env.PR_NUMBER}` + }); + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: "in_progress", + description: "Deployment started" + }); + + return deployment.data.id; + + - name: Update PR comment with build status + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + const body = [ + `🏗️ Building Docker image for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}`, + "", + `[View build logs](${process.env.WORKFLOW_URL})`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})` + ].join("\n"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); + + - name: Build Docker image + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: ./.github/actions/cpflow-build-docker-image + with: + app_name: ${{ env.APP_NAME }} + org: ${{ vars.CPLN_ORG_STAGING }} + commit: ${{ env.PR_SHA }} + pr_number: ${{ env.PR_NUMBER }} + docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }} + docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }} + docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }} + + - name: Update PR comment with deploy status + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + const body = [ + "🚀 Deploying review app to Control Plane...", + "", + `[View deploy logs](${process.env.WORKFLOW_URL})`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})` + ].join("\n"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); + + - name: Deploy to Control Plane + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + env: + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" + + - name: Retrieve app URL + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: workload + shell: bash + run: | + set -euo pipefail + workload_name="${PRIMARY_WORKLOAD:-rails}" + workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')" + echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT" + + - name: Finalize deployment status + if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + const deploymentId = "${{ steps.init-deployment.outputs.result }}"; + const appUrl = "${{ steps.workload.outputs.workload_url }}"; + const success = "${{ job.status }}" === "success"; + + if (deploymentId) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: Number(deploymentId), + state: success ? "success" : "failure", + environment: `review/${process.env.APP_NAME}`, + environment_url: success && appUrl ? appUrl : undefined, + log_url: process.env.WORKFLOW_URL, + description: success ? "Review app ready" : "Review app deployment failed" + }); + } + + const successBody = [ + "## Review app ready", + "", + appUrl ? `[Open review app](${appUrl})` : "Review app deployed, but no endpoint URL was detected.", + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + const failureBody = [ + `❌ Review app deployment failed for PR #${process.env.PR_NUMBER}`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: success ? successBody : failureBody + }); diff --git a/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml b/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml new file mode 100644 index 00000000..9a494f75 --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml @@ -0,0 +1,134 @@ +name: Deploy Staging to Control Plane + +run-name: Deploy Control Plane staging app + +on: + push: + # GitHub does not allow repository vars in branch filters. Default to the common + # deploy branches; if STAGING_APP_BRANCH is set to something else, add it to this list + # after generation so the validate-branch job does not burn a runner on every push. + branches: ["main", "master"] + workflow_dispatch: + +permissions: + contents: read + +env: + APP_NAME: ${{ vars.STAGING_APP_NAME }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + STAGING_APP_BRANCH: ${{ vars.STAGING_APP_BRANCH }} + +concurrency: + group: cpflow-deploy-staging-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + validate-branch: + runs-on: ubuntu-latest + outputs: + is_deployable: ${{ steps.check-branch.outputs.is_deployable }} + steps: + - name: Check whether this branch should deploy staging + id: check-branch + shell: bash + run: | + set -euo pipefail + + if [[ -n "${STAGING_APP_BRANCH}" ]]; then + if [[ "${GITHUB_REF_NAME}" == "${STAGING_APP_BRANCH}" ]]; then + echo "is_deployable=true" >> "$GITHUB_OUTPUT" + else + echo "Branch '${GITHUB_REF_NAME}' does not match STAGING_APP_BRANCH='${STAGING_APP_BRANCH}'" + echo "is_deployable=false" >> "$GITHUB_OUTPUT" + fi + elif [[ "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "master" ]]; then + echo "is_deployable=true" >> "$GITHUB_OUTPUT" + else + echo "Branch '${GITHUB_REF_NAME}' is not main/master and no STAGING_APP_BRANCH is configured" + echo "is_deployable=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate required secrets and variables + if: steps.check-branch.outputs.is_deployable == 'true' + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.STAGING_APP_NAME }}" ]] || missing+=("variable:STAGING_APP_NAME") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + build: + needs: validate-branch + if: needs.validate-branch.outputs.is_deployable == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Build Docker image + uses: ./.github/actions/cpflow-build-docker-image + with: + app_name: ${{ env.APP_NAME }} + org: ${{ vars.CPLN_ORG_STAGING }} + commit: ${{ github.sha }} + docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }} + docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }} + docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }} + + deploy: + needs: [validate-branch, build] + if: needs.validate-branch.outputs.is_deployable == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Detect release phase support + id: release-phase + shell: bash + run: | + set -euo pipefail + + # Anchor to start-of-line so commented-out release_script: entries don't enable --run-release-phase. + if cpflow config -a "${APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy staging image + env: + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" diff --git a/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml b/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml new file mode 100644 index 00000000..1430886f --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml @@ -0,0 +1,82 @@ +name: Review App Help Command + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to post help on + required: true + type: number + +permissions: + issues: write + pull-requests: write + +jobs: + help: + if: | + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/help' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - name: Post help message + uses: actions/github-script@v7 + with: + script: | + const helpText = [ + "# Control Plane GitHub Flow", + "", + "## PR commands", + "", + "`/deploy-review-app`", + "- Creates the review app if it does not exist", + "- Builds the PR commit image", + "- Deploys the image and comments with the review URL", + "", + "`/delete-review-app`", + "- Deletes the review app when the PR is done", + "- This also runs automatically when the PR closes", + "", + "## Repository secrets", + "", + "- `CPLN_TOKEN_STAGING`", + "- `CPLN_TOKEN_PRODUCTION`", + "- `DOCKER_BUILD_SSH_KEY` (optional, when Docker builds fetch private dependencies over SSH)", + "", + "## Repository variables", + "", + "- `CPLN_ORG_STAGING`", + "- `CPLN_ORG_PRODUCTION`", + "- `STAGING_APP_NAME`", + "- `PRODUCTION_APP_NAME`", + "- `REVIEW_APP_PREFIX`", + "- `STAGING_APP_BRANCH` (optional, defaults to `main` or `master`)", + "- `PRIMARY_WORKLOAD` (optional, defaults to `rails`)", + "- `DOCKER_BUILD_EXTRA_ARGS` (optional Docker build flags)", + "- `DOCKER_BUILD_SSH_KNOWN_HOSTS` (optional when SSH build hosts are not GitHub.com)", + "", + "## Workflow behavior", + "", + "- Review apps are opt-in and created with `/deploy-review-app`", + "- New commits redeploy existing review apps automatically", + "- Pushes to the staging branch deploy staging automatically", + "- Promotion to production is manual via the Actions tab", + "- A nightly workflow removes stale review apps" + ].join("\n"); + + const prNumber = context.eventName === "workflow_dispatch" + ? Number(context.payload.inputs.pr_number) + : context.issue.number; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: helpText + }); diff --git a/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml b/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml new file mode 100644 index 00000000..6cc212fb --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml @@ -0,0 +1,371 @@ +name: Promote Staging to Production + +on: + workflow_dispatch: + inputs: + confirm_promotion: + description: Type "promote" to confirm promotion of staging to production + required: true + type: string + +permissions: + contents: write + +env: + # Override these by editing this file or by setting the matching repository variable. + # Defaults give a ~6 minute window, which covers most Rails cold boots (asset precompile + # + db:migrate + workload readiness). + HEALTH_CHECK_RETRIES: 24 + HEALTH_CHECK_INTERVAL: 15 + # Space-separated list of HTTP statuses considered healthy. Override via the + # HEALTH_CHECK_ACCEPTED_STATUSES repo variable (e.g. "200" for a plain /health endpoint, + # or "200 302 401 403" for apps that auth-gate /). + HEALTH_CHECK_ACCEPTED_STATUSES: ${{ vars.HEALTH_CHECK_ACCEPTED_STATUSES || '200 301 302' }} + ROLLBACK_READINESS_RETRIES: 24 + ROLLBACK_READINESS_INTERVAL: 15 + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + +concurrency: + group: cpflow-promote-staging-to-production + cancel-in-progress: false + +jobs: + promote-to-production: + if: github.event.inputs.confirm_promotion == 'promote' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ secrets.CPLN_TOKEN_PRODUCTION }}" ]] || missing+=("secret:CPLN_TOKEN_PRODUCTION") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.CPLN_ORG_PRODUCTION }}" ]] || missing+=("variable:CPLN_ORG_PRODUCTION") + [[ -n "${{ vars.STAGING_APP_NAME }}" ]] || missing+=("variable:STAGING_APP_NAME") + [[ -n "${{ vars.PRODUCTION_APP_NAME }}" ]] || missing+=("variable:PRODUCTION_APP_NAME") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + - name: Setup production environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + org: ${{ vars.CPLN_ORG_PRODUCTION }} + + # Runs after Setup production environment so the pinned Ruby (>= 3.1) is on PATH. + # YAML.load_file(..., aliases: true) is not supported on Ruby 3.0 (system Ruby on ubuntu-22.04). + - name: Resolve production app workloads + id: workloads + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + workloads="$(ruby - "${PRODUCTION_APP_NAME}" <<'RUBY' + require "yaml" + + app = ARGV.fetch(0) + data = YAML.load_file(".controlplane/controlplane.yml", aliases: true) + apps = data["apps"] || {} + app_config = apps[app] + + unless app_config + warn "Error: app '#{app}' is not defined under `apps:` in `.controlplane/controlplane.yml`." + warn " Fix the PRODUCTION_APP_NAME repository variable or add the app to controlplane.yml." + exit 1 + end + + workloads = Array(app_config["app_workloads"]) + workloads = ["rails"] if workloads.empty? + puts workloads.join(",") + RUBY + )" + + echo "names=${workloads}" >> "$GITHUB_OUTPUT" + + - name: Detect release phase support + id: release-phase + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + # Anchor the match so commented-out release_script: lines don't accidentally enable --run-release-phase. + if cpflow config -a "${PRODUCTION_APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Verify production environment variables + env: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + + if [[ -z "${staging_vars}" ]]; then + echo "Staging GVC exposes no environment variables; skipping parity check." + exit 0 + fi + + missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + + if [[ -n "${missing_vars}" ]]; then + echo "::error::Production is missing environment variables that exist in staging" + echo "${missing_vars}" + exit 1 + fi + + - name: Capture current production image + id: capture-current + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} + shell: bash + run: | + set -euo pipefail + + selected_workload="${PRIMARY_WORKLOAD:-}" + selected_image="" + selected_version="" + first_image="" + first_version="" + rollback_state='{}' + + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + workload_json="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" + workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image')" + workload_images="$(echo "${workload_json}" | jq -c '.spec.containers | map(.image)')" + workload_version="$(echo "${workload_json}" | jq -r '.version')" + + if [[ -z "${first_image}" ]]; then + first_image="${workload_image}" + first_version="${workload_version}" + fi + + if [[ -n "${selected_workload}" && "${workload_name}" == "${selected_workload}" ]]; then + selected_image="${workload_image}" + selected_version="${workload_version}" + fi + + rollback_state="$( + jq -c \ + --arg workload "${workload_name}" \ + --arg image "${workload_image}" \ + --arg version "${workload_version}" \ + --argjson images "${workload_images}" \ + '. + {($workload): {image: $image, version: $version, images: $images}}' \ + <<< "${rollback_state}" + )" + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + current_image="${selected_image:-${first_image}}" + current_version="${selected_version:-${first_version}}" + + echo "current_image=${current_image}" >> "$GITHUB_OUTPUT" + echo "current_version=${current_version}" >> "$GITHUB_OUTPUT" + # Randomize the heredoc delimiter so a stray "EOF" line inside rollback_state can't terminate it early. + delim="EOF_$(openssl rand -hex 8)" + { + echo "rollback_state<<${delim}" + echo "${rollback_state}" + echo "${delim}" + } >> "$GITHUB_OUTPUT" + + - name: Copy image from staging + env: + # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc//cmdline. + CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" + + - name: Deploy image to production + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${PRODUCTION_APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" + + - name: Wait for deployment health + id: health-check + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + workload_name="${PRIMARY_WORKLOAD:-rails}" + read -r -a accepted_statuses <<< "${HEALTH_CHECK_ACCEPTED_STATUSES}" + + for attempt in $(seq 1 "${HEALTH_CHECK_RETRIES}"); do + echo "Health check attempt ${attempt}/${HEALTH_CHECK_RETRIES}" + + endpoint="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.endpoint // empty')" + if [[ -n "${endpoint}" ]]; then + http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${endpoint}" 2>/dev/null || echo 000)" + echo "Endpoint: ${endpoint}, HTTP status: ${http_status}" + + for accepted in "${accepted_statuses[@]}"; do + if [[ "${http_status}" == "${accepted}" ]]; then + echo "healthy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + fi + + if [[ "${attempt}" -lt "${HEALTH_CHECK_RETRIES}" ]]; then + sleep "${HEALTH_CHECK_INTERVAL}" + fi + done + + echo "healthy=false" >> "$GITHUB_OUTPUT" + exit 1 + + - name: Roll back on failure + if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' + env: + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end + # if any failed. A single cpln hiccup shouldn't leave other workloads mid-promotion. + set -uo pipefail + + rollback_failures=0 + + while IFS=$'\t' read -r workload_name previous_images; do + rollback_args=() + + while IFS=$'\t' read -r index image; do + rollback_args+=(--set "spec.containers[${index}].image=${image}") + done < <(echo "${previous_images}" | jq -r 'to_entries[] | "\(.key)\t\(.value)"') + + if ! cpln workload update "${workload_name}" \ + --gvc "${PRODUCTION_APP_NAME}" \ + --org "${CPLN_ORG_PRODUCTION}" \ + "${rollback_args[@]}"; then + echo "::warning::Rollback failed for workload '${workload_name}'; continuing with remaining workloads." >&2 + rollback_failures=$((rollback_failures + 1)) + fi + done < <(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.images | @json)"') + + if [[ "${rollback_failures}" -gt 0 ]]; then + echo "::error::${rollback_failures} workload(s) failed to roll back; inspect the logs above." >&2 + exit 1 + fi + + - name: Wait for rollback readiness + if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' + env: + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') + + for workload_name in "${workloads[@]}"; do + [[ -n "${workload_name}" ]] || continue + + echo "Waiting for rollback of workload '${workload_name}' to become ready..." + ready=false + + for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do + deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')" + if [[ "${deployment_ready}" == "true" ]]; then + ready=true + break + fi + + if [[ "${attempt}" -lt "${ROLLBACK_READINESS_RETRIES}" ]]; then + sleep "${ROLLBACK_READINESS_INTERVAL}" + fi + done + + if [[ "${ready}" != "true" ]]; then + echo "::warning::Workload '${workload_name}' did not report ready after rollback." + fi + done + + - name: Create GitHub release + if: success() && steps.health-check.outputs.healthy == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RUN_ID: ${{ github.run_id }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + release_date="$(date '+%Y-%m-%d')" + timestamp="$(date '+%H%M%S')" + release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}" + + gh release create "${release_tag}" \ + --title "Production Release ${release_date} ${timestamp}" \ + --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}." + + - name: Promotion summary + if: always() + env: + HEALTHY: ${{ steps.health-check.outputs.healthy }} + PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} + PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }} + shell: bash + run: | + { + echo "## Promotion Summary" + echo + if [[ "${HEALTHY}" == "true" ]]; then + echo "✅ Status: deployment successful" + else + echo "❌ Status: deployment failed" + fi + echo + echo "Previous image: \`${PREVIOUS_IMAGE}\`" + echo "Previous version: ${PREVIOUS_VERSION}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml b/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml new file mode 100644 index 00000000..e3958cc2 --- /dev/null +++ b/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml @@ -0,0 +1,37 @@ +name: Show Review App Commands on PR Open + +on: + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + show-help: + runs-on: ubuntu-latest + steps: + - name: Post quick reference + uses: actions/github-script@v7 + with: + script: | + const body = [ + "# Control Plane review app commands", + "", + "`/deploy-review-app`", + "Create the review app or redeploy the PR branch to it.", + "", + "`/delete-review-app`", + "Delete the review app and its temporary resources.", + "", + "`/help`", + "Show the required GitHub variables, secrets, and workflow behavior." + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); diff --git a/spec/command/ai_github_flow_prompt_spec.rb b/spec/command/ai_github_flow_prompt_spec.rb new file mode 100644 index 00000000..111f9ee4 --- /dev/null +++ b/spec/command/ai_github_flow_prompt_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" +require "pathname" +require "tmpdir" + +describe Command::AiGithubFlowPrompt, :enable_validations, :without_config_file do + def inside_dir(path) + original_working_dir = Dir.pwd + Dir.chdir(path) + yield if block_given? + ensure + Dir.chdir(original_working_dir) + end + + let(:playground) { Pathname.new(Dir.mktmpdir("cpflow-ai-github-flow-prompt")) } + + after do + FileUtils.remove_entry(playground.to_s) if playground.exist? + end + + it "prints the AI rollout prompt with the inferred repo-name app prefix" do + inside_dir(playground.join("sample-project").tap(&:mkpath)) do + result = run_cpflow_command(described_class::NAME) + + expect(result[:status]).to eq(0) + expect(result[:stdout]).to include("cpflow github-flow-readiness") + expect(result[:stdout]).to include("repo-name default (`sample-project`)") + expect(result[:stdout]).to include("cpflow generate-github-actions") + expect(result[:stdout]).to include("monorepo without an already-decided single app boundary") + expect(result[:stdout]).to include("DOCKER_BUILD_SSH_KNOWN_HOSTS") + expect(result[:stdout]).to include("config/shakapacker.yml") + expect(result[:stdout]).to include("config.auto_load_bundle = true") + expect(result[:stdout]).to include("Only stop early for a real external blocker") + expect(result[:stderr]).to be_empty + end + end + + it "skips startup checks for the local-only prompt command" do + inside_dir(playground) do + result = run_cpflow_command(described_class::NAME) + + expect(result[:status]).to eq(0) + expect(Cpflow::Cli).not_to have_received(:check_cpln_version) + expect(Cpflow::Cli).not_to have_received(:check_cpflow_version) + end + end +end diff --git a/spec/command/generate_github_actions_spec.rb b/spec/command/generate_github_actions_spec.rb new file mode 100644 index 00000000..4987271f --- /dev/null +++ b/spec/command/generate_github_actions_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "spec_helper" +require "pathname" +require "tmpdir" +require "yaml" + +describe Command::GenerateGithubActions, :enable_validations, :without_config_file do + def inside_dir(path) + original_working_dir = Dir.pwd + Dir.chdir(path) + yield if block_given? + ensure + Dir.chdir(original_working_dir) + end + + def build_action_path + playground.join(".github/actions/cpflow-build-docker-image/action.yml") + end + + def review_app_workflow_path + playground.join(".github/workflows/cpflow-deploy-review-app.yml") + end + + def staging_workflow_path + playground.join(".github/workflows/cpflow-deploy-staging.yml") + end + + def delete_review_workflow_path + playground.join(".github/workflows/cpflow-delete-review-app.yml") + end + + def help_workflow_path + playground.join(".github/workflows/cpflow-help-command.yml") + end + + def promote_workflow_path + playground.join(".github/workflows/cpflow-promote-staging-to-production.yml") + end + + def setup_action_path + playground.join(".github/actions/cpflow-setup-environment/action.yml") + end + + def delete_app_script_path + playground.join(".github/actions/cpflow-delete-control-plane-app/delete-app.sh") + end + + def generated_yaml_paths + Dir.glob(playground.join(".github/**/*.yml")).sort + end + + let(:playground) { Pathname.new(Dir.mktmpdir("cpflow-github-actions")) } + + after do + FileUtils.remove_entry(playground.to_s) if playground.exist? + end + + context "when the cpflow GitHub Actions files do not exist yet" do + before do + inside_dir(playground) do + Cpflow::Cli.start([described_class::NAME]) + end + end + + it "creates the expected workflow and action files" do + expect(review_app_workflow_path).to exist + expect(build_action_path).to exist + expect(setup_action_path).to exist + expect(delete_app_script_path).to exist + expect(delete_app_script_path).to be_executable + end + + it "substitutes the cpflow version placeholder" do + expect(setup_action_path.read).to include(%(default: "#{Cpflow::VERSION}")) + end + + it "exposes Docker build action inputs" do + contents = build_action_path.read + expect(contents).to include("docker_build_extra_args:") + expect(contents).to include("docker_build_ssh_key:") + expect(contents).to include("docker_build_ssh_known_hosts:") + end + + it "documents the docker_build_extra_args usage and validates tokens" do + contents = build_action_path.read + expect(contents).to include("--build-arg=FOO=bar") + expect(contents).to include('docker_build_args+=("--ssh=default")') + expect(contents).to include( + "docker_build_extra_args entries must be single docker-build tokens." + ) + end + + it "pins the default SSH known_hosts entries without ssh-keyscan" do + contents = build_action_path.read + expect(contents).to include('printf \'%s\n\' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}"') + expect(contents).not_to include("ssh-keyscan") + expect(contents).to include("github.com ssh-ed25519") + end + + it "wires Docker build inputs through the review-app workflow" do + contents = review_app_workflow_path.read + expect(contents).to include("docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }}") + expect(contents).to include("docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }}") + expect(contents).to include( + "docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }}" + ) + end + + it "gates review-app deploys by author_association and skips fork PRs" do + contents = review_app_workflow_path.read + expect(contents).to include("github.event.comment.author_association") + expect(contents).to include("Review app deploys are skipped for fork pull requests.") + end + + it "handles missing PR comment ids gracefully in the review-app workflow" do + expect(review_app_workflow_path.read).to include( + "Skipping PR comment update because no comment id was created." + ) + end + + it "configures delete-review-app concurrency and handles missing comment ids" do + contents = delete_review_workflow_path.read + expect(contents).to include("concurrency:") + expect(contents).to include( + "Skipping delete status comment update because no comment id was created." + ) + end + + it "wires the help workflow author_association gate and Docker build env" do + contents = help_workflow_path.read + expect(contents).to include("github.event.comment.author_association") + expect(contents).to include("DOCKER_BUILD_EXTRA_ARGS") + expect(contents).to include("DOCKER_BUILD_SSH_KNOWN_HOSTS") + end + + it "wires Docker build inputs through the staging workflow" do + contents = staging_workflow_path.read + expect(contents).to include("docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }}") + expect(contents).to include("docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }}") + expect(contents).to include( + "docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }}" + ) + end + + it "documents the branch-filter trade-off and sets staging concurrency/vars" do + contents = staging_workflow_path.read + expect(contents).to include("GitHub does not allow repository vars in branch filters") + expect(contents).to include("cpflow-deploy-staging-${{ github.ref_name }}") + expect(contents).to include("variable:STAGING_APP_NAME") + end + + it "configures the promote workflow's concurrency, release tagging, and rollback guard" do + contents = promote_workflow_path.read + expect(contents).to include("group: cpflow-promote-staging-to-production") + expect(contents).to include( + 'release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}"' + ) + expect(contents).to include( + "failure() && steps.capture-current.outputs.rollback_state != '' && " \ + "steps.capture-current.outputs.rollback_state != '{}'" + ) + end + + it "writes the delete-app script with the not-found guard message" do + expect(delete_app_script_path.read).to include("⚠️ Application does not exist") + end + + it "produces valid YAML for every generated workflow and action file" do + generated_yaml_paths.each do |path| + expect { YAML.load_file(path, aliases: true) }.not_to raise_error + end + end + + it "skips startup checks for the local-only GitHub Actions generator command" do + expect(Cpflow::Cli).not_to have_received(:check_cpln_version) + expect(Cpflow::Cli).not_to have_received(:check_cpflow_version) + end + end + + context "when one of the generated files already exists" do + before do + FileUtils.mkdir_p(review_app_workflow_path.dirname) + review_app_workflow_path.write("existing-content\n") + end + + it "warns and leaves the project untouched" do + inside_dir(playground) do + expect do + Cpflow::Cli.start([described_class::NAME]) + end.to output(/already exist/).to_stderr + + expect(review_app_workflow_path.read).to eq("existing-content\n") + expect(setup_action_path).not_to exist + end + end + end + + context "when the repository already has unrelated GitHub files" do + before do + playground.join(".github").mkpath + playground.join(".github/existing.yml").write("version: __CPFLOW_VERSION__\n") + end + + it "does not rewrite the pre-existing files" do + inside_dir(playground) do + Cpflow::Cli.start([described_class::NAME]) + + expect(playground.join(".github/existing.yml").read).to eq("version: __CPFLOW_VERSION__\n") + end + end + end +end diff --git a/spec/command/generate_spec.rb b/spec/command/generate_spec.rb index d1d0ac24..fa79d2b1 100644 --- a/spec/command/generate_spec.rb +++ b/spec/command/generate_spec.rb @@ -16,9 +16,47 @@ def inside_dir(path) Dir.chdir original_working_dir end -describe Command::Generate, :enable_validations, :without_config_file do - let(:controlplane_config_file_path) { CONTROLPLANE_CONFIG_DIR_PATH.join("controlplane.yml") } +def controlplane_config_file_path + CONTROLPLANE_CONFIG_DIR_PATH.join("controlplane.yml") +end + +def release_script_path + CONTROLPLANE_CONFIG_DIR_PATH.join("release_script.sh") +end + +def entrypoint_path + CONTROLPLANE_CONFIG_DIR_PATH.join("entrypoint.sh") +end + +def dockerfile_path + CONTROLPLANE_CONFIG_DIR_PATH.join("Dockerfile") +end + +def app_template_path + CONTROLPLANE_CONFIG_DIR_PATH.join("templates/app.yml") +end + +def rails_template_path + CONTROLPLANE_CONFIG_DIR_PATH.join("templates/rails.yml") +end +def postgres_template_path + CONTROLPLANE_CONFIG_DIR_PATH.join("templates/postgres.yml") +end + +def db_template_path + CONTROLPLANE_CONFIG_DIR_PATH.join("templates/db.yml") +end + +def storage_template_path + CONTROLPLANE_CONFIG_DIR_PATH.join("templates/storage.yml") +end + +def generated_ruby_arg + dockerfile_path.read.lines.find { |line| line.start_with?("ARG RUBY_VERSION=") } +end + +describe Command::Generate, :enable_validations, :without_config_file do before do FileUtils.rm_r(GENERATOR_PLAYGROUND_PATH) if Dir.exist?(GENERATOR_PLAYGROUND_PATH) FileUtils.mkdir_p GENERATOR_PLAYGROUND_PATH @@ -36,19 +74,190 @@ def inside_dir(path) Cpflow::Cli.start([described_class::NAME]) expect(controlplane_config_file_path).to exist + expect(dockerfile_path).to exist + expect(entrypoint_path).to exist + expect(release_script_path).to exist + expect(entrypoint_path).to be_executable + expect(release_script_path).to be_executable + + controlplane_content = controlplane_config_file_path.read + dockerfile_content = dockerfile_path.read + app_template_content = app_template_path.read + rails_template_content = rails_template_path.read + + expect(controlplane_content).to include("sample-project-staging") + expect(controlplane_content).to include("sample-project-review") + expect(controlplane_content).to include("sample-project-production") + expect(controlplane_content).to include("setup_app_templates:") + expect(controlplane_content).to include("- postgres") + expect(controlplane_content).to include("release_script: release_script.sh") + expect(generated_ruby_arg).to eq("ARG RUBY_VERSION=3.3.6\n") + expect(dockerfile_content).to include("FROM docker.io/library/node:22-bookworm-slim AS node") + expect(dockerfile_content).to include("COPY --from=node /usr/local/bin/node /usr/local/bin/node") + expect(dockerfile_content).not_to include("RUN apt-get update") + expect(dockerfile_content).to include("bundle config set with 'production'") + expect(dockerfile_content).not_to include("bundle config set with 'staging production'") + expect(dockerfile_content).to include("exec corepack yarn \"$@\"") + expect(dockerfile_content).to include("exec corepack pnpm \"$@\"") + expect(dockerfile_content).to include( + "package_manager=\"$(node -p \"require('./package.json').packageManager || ''\")\"" + ) + expect(dockerfile_content).to include("corepack prepare \"$package_manager\" --activate &&") + expect(dockerfile_content).to include("npm install -g yarn@1.22.22 &&") + expect(dockerfile_content).to include("corepack yarn install --immutable") + expect(dockerfile_content).to include("yarn install --immutable || yarn install --frozen-lockfile") + expect(dockerfile_content).to include("corepack pnpm install --frozen-lockfile") + expect(dockerfile_content).to include("npm ci") + expect(dockerfile_content).not_to include("react_on_rails:generate_packs") + expect(app_template_content).to include("RAILS_LOG_TO_STDOUT") + expect(app_template_content).to include("SECRET_KEY_BASE") + expect(rails_template_content).to include("minScale: 1") + expect(rails_template_content).to include("timeoutSeconds: 60") + expect(entrypoint_path.read).to include("executing '$*'") + expect(postgres_template_path).to exist + expect(release_script_path.read).to include("SECRET_KEY_BASE=\"${SECRET_KEY_BASE:-precompile_placeholder}\"") + end + end + + it "skips startup checks for the local-only generator command" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + expect(Cpflow::Cli).not_to have_received(:check_cpln_version) + expect(Cpflow::Cli).not_to have_received(:check_cpflow_version) end end end - context "when .controlplane directory already exist" do - let(:controlplane_config_dir) { controlplane_config_file_path.parent } + context "when .ruby-version exists" do + before do + GENERATOR_PLAYGROUND_PATH.join(".ruby-version").write("ruby-3.3.6\n") + end + + it "uses the .ruby-version value for the Docker base image" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + expect(generated_ruby_arg).to eq("ARG RUBY_VERSION=3.3.6\n") + end + end + end + + context "when .tool-versions exists" do + before do + GENERATOR_PLAYGROUND_PATH.join(".tool-versions").write("nodejs 22.15.0\nruby 3.2.9\n") + end + + it "uses the ruby version from .tool-versions" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + expect(generated_ruby_arg).to eq("ARG RUBY_VERSION=3.2.9\n") + end + end + end + + context "when only a Gemfile ruby directive exists" do + before do + GENERATOR_PLAYGROUND_PATH.join("Gemfile").write(<<~GEMFILE) + source "https://rubygems.org" + + ruby ">= 3.3" + GEMFILE + end + + it "uses the Gemfile ruby requirement as the Docker base image hint" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + expect(generated_ruby_arg).to eq("ARG RUBY_VERSION=3.3\n") + end + end + end + + context "when production uses sqlite3" do + before do + FileUtils.mkdir_p(GENERATOR_PLAYGROUND_PATH.join("config")) + GENERATOR_PLAYGROUND_PATH.join("config/database.yml").write(<<~YAML) + default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + + production: + <<: *default + database: db/production.sqlite3 + YAML + end + + it "generates sqlite-backed persistent volume templates instead of postgres" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + controlplane_content = controlplane_config_file_path.read + + expect(controlplane_content).to include("- db") + expect(controlplane_content).to include("- storage") + expect(controlplane_content).not_to include("- postgres") + expect(postgres_template_path).not_to exist + expect(db_template_path).to exist + expect(storage_template_path).to exist + expect(app_template_path.read).not_to include("DATABASE_URL") + expect(rails_template_path.read).to include("uri: cpln://volumeset/app-db") + expect(rails_template_path.read).to include("uri: cpln://volumeset/app-storage") + expect(release_script_path.read).to include("mkdir -p db storage") + end + end + end + context "when shakapacker config defines a precompile hook" do before do - Dir.mkdir(controlplane_config_dir) + FileUtils.mkdir_p(GENERATOR_PLAYGROUND_PATH.join("config")) + GENERATOR_PLAYGROUND_PATH.join("config/shakapacker.yml").write(<<~YAML) + default: &default + precompile_hook: "rake react_on_rails:generate_packs" + YAML + end + + it "runs the hook before assets precompile in the generated Dockerfile" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + dockerfile_content = dockerfile_path.read + + expect(dockerfile_content).to include("RUN bundle exec rake react_on_rails:generate_packs") + expect( + dockerfile_content.index("RUN bundle exec rake react_on_rails:generate_packs") + ).to be < dockerfile_content.index("RUN rails assets:precompile") + end end + end + context "when React on Rails auto bundle generation is enabled" do + before do + FileUtils.mkdir_p(GENERATOR_PLAYGROUND_PATH.join("config/initializers")) + GENERATOR_PLAYGROUND_PATH.join("config/initializers/react_on_rails.rb").write(<<~RUBY) + ReactOnRails.configure do |config| + config.auto_load_bundle = true + end + RUBY + end + + it "adds the React on Rails pack generation step before assets precompile" do + inside_dir(GENERATOR_PLAYGROUND_PATH) do + Cpflow::Cli.start([described_class::NAME]) + + expect(dockerfile_path.read).to include("RUN bundle exec rake react_on_rails:generate_packs") + end + end + end + + context "when .controlplane directory already exist" do it "doesn't generates base config files" do inside_dir(GENERATOR_PLAYGROUND_PATH) do + controlplane_config_dir = controlplane_config_file_path.parent + Dir.mkdir(controlplane_config_dir) + expect(controlplane_config_dir).to exist expect do diff --git a/spec/command/github_flow_readiness_spec.rb b/spec/command/github_flow_readiness_spec.rb new file mode 100644 index 00000000..8207f95c --- /dev/null +++ b/spec/command/github_flow_readiness_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "spec_helper" +require "pathname" +require "tmpdir" + +describe Command::GithubFlowReadiness, :enable_validations, :without_config_file do + def inside_dir(path) + original_working_dir = Dir.pwd + Dir.chdir(path) + yield if block_given? + ensure + Dir.chdir(original_working_dir) + end + + let(:playground) { Pathname.new(Dir.mktmpdir("cpflow-github-flow-readiness")) } + + after do + FileUtils.remove_entry(playground.to_s) if playground.exist? + end + + it "passes for a modern repo when exact gem and npm pins are available" do + service = instance_double(GithubFlowReadinessService) + allow(GithubFlowReadinessService).to receive(:new).and_return(service) + allow(service).to receive_messages( + results: [ + GithubFlowReadinessService::Result.new(status: :pass, message: "Ruby 3.3.7 is modern enough for rollout."), + GithubFlowReadinessService::Result.new( + status: :pass, + message: "Checked 1 exact-pinned direct Ruby gem; all appear available on RubyGems." + ), + GithubFlowReadinessService::Result.new( + status: :pass, + message: "Checked 1 exact-pinned direct npm package; all appear available on npm." + ) + ], + summary: "No blocking readiness issues detected. Validate the real production build path before merging.", + blockers?: false + ) + + inside_dir(playground) do + result = run_cpflow_command(described_class::NAME) + + expect(result[:status]).to eq(0) + expect(result[:stdout]).to include("[PASS] Ruby 3.3.7 is modern enough for rollout.") + expect(result[:stdout]).to include("No blocking readiness issues detected.") + end + end + + it "fails when the repo has rollout blockers" do + service = instance_double(GithubFlowReadinessService) + allow(GithubFlowReadinessService).to receive(:new).and_return(service) + allow(service).to receive_messages( + results: [ + GithubFlowReadinessService::Result.new( + status: :fail, + message: "Ruby 2.5.1 is legacy. Upgrade the repo toolchain before adding the GitHub flow." + ), + GithubFlowReadinessService::Result.new( + status: :fail, + message: "Direct npm package versions not available on npm: `react-on-rails-rsc@16.4.0`." + ) + ], + summary: "Blockers found. Fix them before generating the Control Plane GitHub flow.", + blockers?: true + ) + + inside_dir(playground) do + result = run_cpflow_command(described_class::NAME) + + expect(result[:status]).to eq(ExitCode::ERROR_DEFAULT) + expect(result[:stdout]).to include("Ruby 2.5.1 is legacy") + expect(result[:stdout]).to include("react-on-rails-rsc@16.4.0") + expect(result[:stdout]).to include("Blockers found.") + end + end + + it "skips startup checks for the local-only readiness command" do + service = instance_double(GithubFlowReadinessService, results: [], summary: "ok", blockers?: false) + allow(GithubFlowReadinessService).to receive(:new).and_return(service) + + inside_dir(playground) do + result = run_cpflow_command(described_class::NAME) + + expect(result[:status]).to eq(0) + expect(Cpflow::Cli).not_to have_received(:check_cpln_version) + expect(Cpflow::Cli).not_to have_received(:check_cpflow_version) + end + end +end diff --git a/spec/command/latest_image_spec.rb b/spec/command/latest_image_spec.rb index 03e324a3..96f7a244 100644 --- a/spec/command/latest_image_spec.rb +++ b/spec/command/latest_image_spec.rb @@ -3,6 +3,22 @@ require "spec_helper" describe Command::LatestImage do + def wait_for_latest_image(app, expected_suffix, attempts: 8, delay: 5) + expected_output = "#{app}:#{expected_suffix}" + result = nil + + attempts.times do |attempt| + result = run_cpflow_command("latest-image", "-a", app) + return result if result[:status].zero? && result[:stdout].include?(expected_output) + + break unless result[:status].zero? && result[:stdout].include?("#{app}:NO_IMAGE_AVAILABLE") + + sleep(delay) if attempt < attempts - 1 + end + + result + end + context "when no images have been built" do let!(:app) { dummy_test_app } @@ -18,7 +34,7 @@ let!(:app) { dummy_test_app("full", create_if_not_exists: true) } it "displays latest image for app" do - result = run_cpflow_command("latest-image", "-a", app) + result = wait_for_latest_image(app, 2) expect(result[:status]).to eq(0) expect(result[:stdout]).to include("#{app}:2") diff --git a/spec/core/github_flow_readiness_service_spec.rb b/spec/core/github_flow_readiness_service_spec.rb new file mode 100644 index 00000000..b4ea3e4a --- /dev/null +++ b/spec/core/github_flow_readiness_service_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require "spec_helper" +require "pathname" +require "tmpdir" + +describe GithubFlowReadinessService do + let(:playground) { Pathname.new(Dir.mktmpdir("cpflow-github-flow-readiness-service")) } + let(:service) { described_class.new(root_path: playground) } + let(:ruby_version) { "3.3.7" } + + before do + FileUtils.mkdir_p(playground.join("config")) + File.write(playground.join("config/application.rb"), "# app\n") + File.write(playground.join("config.ru"), "run Rails.application\n") + File.write(playground.join("Dockerfile"), "FROM ruby:3.3.7\n") + File.write(playground.join(".ruby-version"), "#{ruby_version}\n") + File.write(playground.join("Gemfile"), gemfile_contents) + File.write(playground.join("Gemfile.lock"), gemfile_lock_contents) + File.write(playground.join("package.json"), package_json_contents) + File.write(playground.join("config/database.yml"), database_yml) if database_yml + end + + after do + FileUtils.remove_entry(playground.to_s) if playground.exist? + end + + def bundler_version + "2.5.22" + end + + def database_yml + nil + end + + def gemfile_contents + <<~GEMFILE + source "https://rubygems.org" + gem "rails", "8.0.2.1" + gem "react_on_rails", "= 16.4.0" + GEMFILE + end + + def gemfile_lock_contents + <<~LOCKFILE + GEM + remote: https://rubygems.org/ + specs: + rails (8.0.2.1) + react_on_rails (16.4.0) + + DEPENDENCIES + rails (= 8.0.2.1) + react_on_rails (= 16.4.0) + + BUNDLED WITH + #{bundler_version} + LOCKFILE + end + + def package_json_contents + JSON.pretty_generate( + { + name: "demo-app", + dependencies: npm_dependencies + } + ) + end + + def npm_dependencies + { + "react-on-rails" => "16.4.0" + } + end + + it "passes for a modern repo when exact pins are available" do + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.4.0"]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + expect(service.blockers?).to be(false) + expect(service.results.map(&:message)).to include("Ruby 3.3.7 is modern enough for rollout.") + expect(service.results.map(&:message)).to include( + "Checked 2 exact-pinned direct Ruby gems; all appear available on RubyGems." + ) + expect(service.results.map(&:message)).to include( + "Checked 1 exact-pinned direct npm package; all appear available on npm." + ) + end + + it "fails when no production Dockerfile is present" do + File.delete(playground.join("Dockerfile")) + + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.4.0"]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + expect(service.blockers?).to be(true) + expect(service.results.map(&:message)).to include( + "No production Dockerfile found at `Dockerfile` or `.controlplane/Dockerfile`. " \ + "Add and validate one before generating the Control Plane GitHub flow." + ) + end + + it "does not execute Ruby from the target Gemfile while checking readiness" do + sentinel_path = playground.join("gemfile-side-effect.txt") + File.write(playground.join("Gemfile"), <<~GEMFILE) + File.write("#{sentinel_path}", "executed") + source "https://rubygems.org" + gem "rails", "8.0.2.1" + gem "react_on_rails", "= 16.4.0" + GEMFILE + + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.4.0"]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + service.results + + expect(sentinel_path).not_to exist + end + + it "treats exact Ruby gem pins without patch segments as available when RubyGems normalizes them" do + File.write(playground.join("Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "react_on_rails", "= 16.6" + gem "shakapacker", "= 10.0" + GEMFILE + File.write(playground.join("Gemfile.lock"), <<~LOCKFILE) + GEM + remote: https://rubygems.org/ + specs: + react_on_rails (16.6.0) + shakapacker (10.0.0) + + DEPENDENCIES + react_on_rails (= 16.6) + shakapacker (= 10.0) + + BUNDLED WITH + #{bundler_version} + LOCKFILE + + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.6.0"]) + allow(service).to receive(:fetch_rubygems_versions).with("shakapacker").and_return(["10.0.0"]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + expect(service.blockers?).to be(false) + expect(service.results.map(&:message)).to include( + "Checked 2 exact-pinned direct Ruby gems; all appear available on RubyGems." + ) + end + + it "reports legacy toolchains and unavailable direct pins as blockers" do + File.write(playground.join(".ruby-version"), "2.5.1\n") + File.write(playground.join("Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "react_on_rails", "= 15.0.0" + GEMFILE + File.write(playground.join("Gemfile.lock"), <<~LOCKFILE) + GEM + remote: https://rubygems.org/ + specs: + react_on_rails (15.0.0) + + DEPENDENCIES + react_on_rails (= 15.0.0) + + BUNDLED WITH + 1.12.3 + LOCKFILE + File.write( + playground.join("package.json"), + JSON.pretty_generate( + { + name: "demo-app", + dependencies: { + "react-on-rails-rsc" => "16.4.0" + } + } + ) + ) + + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return([]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails-rsc").and_return([]) + + expect(service.blockers?).to be(true) + expect(service.results.map(&:message)).to include( + "Ruby 2.5.1 is legacy. Upgrade the repo toolchain before adding the GitHub flow." + ) + expect(service.results.map(&:message)).to include( + "Bundler 1.12.3 is legacy. Upgrade the repo toolchain before adding the GitHub flow." + ) + expect(service.results.map(&:message)).to include( + "Direct Ruby gem versions not available on RubyGems: `react_on_rails@15.0.0`." + ) + expect(service.results.map(&:message)).to include( + "Direct npm package versions not available on npm: `react-on-rails-rsc@16.4.0`." + ) + end + + it "warns about git-sourced gems and SQLite production" do + File.write(playground.join("Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "rails", "8.0.2.1" + gem "private-gem", github: "org/private-gem" + GEMFILE + File.write(playground.join("Gemfile.lock"), <<~LOCKFILE) + GIT + remote: https://github.com/org/private-gem.git + revision: 1234567890abcdef1234567890abcdef12345678 + specs: + private-gem (0.1.0) + + GEM + remote: https://rubygems.org/ + specs: + rails (8.0.2.1) + + DEPENDENCIES + private-gem! + rails (= 8.0.2.1) + + BUNDLED WITH + #{bundler_version} + LOCKFILE + File.write(playground.join("config/database.yml"), <<~YAML) + default: &default + adapter: sqlite3 + + production: + <<: *default + database: db/production.sqlite3 + YAML + + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + expect(service.results.map(&:status)).to include(:warn, :info) + expect(service.results.map(&:message).join("\n")).to include("git/path or non-public gem sources") + expect(service.results.map(&:message).join("\n")).to include("Production database config uses SQLite") + end + + it "warns about direct gems from non-public rubygems sources instead of checking rubygems.org" do + File.write(playground.join("Gemfile"), <<~GEMFILE) + source "https://gems.example.com" + gem "private-gem", "= 1.2.3" + GEMFILE + File.write(playground.join("Gemfile.lock"), <<~LOCKFILE) + GEM + remote: https://gems.example.com/ + specs: + private-gem (1.2.3) + + DEPENDENCIES + private-gem (= 1.2.3) + + BUNDLED WITH + #{bundler_version} + LOCKFILE + + allow(service).to receive(:fetch_rubygems_versions) + allow(service).to receive(:fetch_npm_versions).with("react-on-rails").and_return(["16.4.0"]) + + messages = service.results.map(&:message) + + expect(service).not_to have_received(:fetch_rubygems_versions) + expect(messages.join("\n")).to include("git/path or non-public gem sources") + expect(messages).not_to include( + "Checked 1 exact-pinned direct Ruby gem; all appear available on RubyGems." + ) + end + + it "warns when package.json cannot be parsed" do + File.write(playground.join("package.json"), "{invalid json\n") + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.4.0"]) + + expect(service.results.map(&:message)).to include( + "Could not parse `package.json`; exact-pinned direct npm package readiness could not be fully verified." + ) + end + + it "treats exact prerelease npm versions as exact pins to verify" do + File.write( + playground.join("package.json"), + JSON.pretty_generate( + { + name: "demo-app", + dependencies: { + "@demo/widget" => "1.2.3-beta.1" + } + } + ) + ) + + allow(service).to receive(:fetch_rubygems_versions).with("rails").and_return(["8.0.2.1"]) + allow(service).to receive(:fetch_rubygems_versions).with("react_on_rails").and_return(["16.4.0"]) + allow(service).to receive(:fetch_npm_versions).with("@demo/widget").and_return(["1.2.3-beta.1"]) + + expect(service.results.map(&:message)).to include( + "Checked 1 exact-pinned direct npm package; all appear available on npm." + ) + end +end diff --git a/spec/cpflow_spec.rb b/spec/cpflow_spec.rb index 761117d5..f31eb365 100644 --- a/spec/cpflow_spec.rb +++ b/spec/cpflow_spec.rb @@ -37,4 +37,12 @@ expect(subcommand_result[:stdout]).to include("#{basename} #{subcommand} help [COMMAND]") end end + + it "skips startup checks for top-level help" do + result = run_cpflow_command("--help") + + expect(result[:status]).to eq(0) + expect(Cpflow::Cli).not_to have_received(:check_cpln_version) + expect(Cpflow::Cli).not_to have_received(:check_cpflow_version) + end end diff --git a/spec/support/command_helpers.rb b/spec/support/command_helpers.rb index 1159f48c..eb1a8060 100644 --- a/spec/support/command_helpers.rb +++ b/spec/support/command_helpers.rb @@ -170,11 +170,15 @@ def run_cpflow_command(*args, raise_errors: false) # rubocop:disable Metrics/Met original_stderr = replace_stderr original_stdout = replace_stdout + original_argv = ARGV.dup + ARGV.replace(args.map(&:to_s)) begin Cpflow::Cli.start(args) rescue SystemExit => e result[:status] = e.status + ensure + ARGV.replace(original_argv) end result[:stderr] = restore_stderr(original_stderr)