From 5b81dc238337b97ceb95b69d2e5e3c42243582c9 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 15 Apr 2026 02:13:38 -1000 Subject: [PATCH 01/25] Add Control Plane flow to migration example --- .controlplane/Dockerfile | 67 ++++ .controlplane/controlplane.yml | 45 +++ .controlplane/entrypoint.sh | 8 + .controlplane/readme.md | 66 ++++ .controlplane/release_script.sh | 5 + .controlplane/templates/app.yml | 15 + .controlplane/templates/db.yml | 6 + .controlplane/templates/rails.yml | 32 ++ .controlplane/templates/storage.yml | 6 + .../cpflow-build-docker-image/action.yml | 70 ++++ .../action.yml | 24 ++ .../delete-app.sh | 37 ++ .../cpflow-setup-environment/action.yml | 70 ++++ .../cpflow-cleanup-stale-review-apps.yml | 47 +++ .../workflows/cpflow-delete-review-app.yml | 118 ++++++ .../workflows/cpflow-deploy-review-app.yml | 364 ++++++++++++++++++ .github/workflows/cpflow-deploy-staging.yml | 120 ++++++ .github/workflows/cpflow-help-command.yml | 78 ++++ .../cpflow-promote-staging-to-production.yml | 243 ++++++++++++ .github/workflows/cpflow-review-app-help.yml | 37 ++ Gemfile | 3 - Gemfile.lock | 15 +- README.md | 6 + .../Navbar/ror_components/Navbar.client.jsx | 3 + .../Navbar/ror_components/Navbar.server.jsx | 3 + .../ror_components/PostsIndex.client.jsx | 3 + .../ror_components/PostsIndex.server.jsx | 3 + .../ror_components/PostsShow.client.jsx | 3 + .../ror_components/PostsShow.server.jsx | 3 + app/javascript/src/components/Navbar.jsx | 4 +- app/javascript/src/pages/PostsShow.jsx | 8 +- app/views/layouts/application.html.erb | 5 +- app/views/posts/index.html.erb | 2 +- app/views/posts/show.html.erb | 2 +- config/tailwind.config.js | 18 + config/webpack/serverWebpackConfig.js | 5 - package.json | 2 +- 37 files changed, 1520 insertions(+), 26 deletions(-) create mode 100644 .controlplane/Dockerfile create mode 100644 .controlplane/controlplane.yml create mode 100755 .controlplane/entrypoint.sh create mode 100644 .controlplane/readme.md create mode 100755 .controlplane/release_script.sh create mode 100644 .controlplane/templates/app.yml create mode 100644 .controlplane/templates/db.yml create mode 100644 .controlplane/templates/rails.yml create mode 100644 .controlplane/templates/storage.yml create mode 100644 .github/actions/cpflow-build-docker-image/action.yml create mode 100644 .github/actions/cpflow-delete-control-plane-app/action.yml create mode 100755 .github/actions/cpflow-delete-control-plane-app/delete-app.sh create mode 100644 .github/actions/cpflow-setup-environment/action.yml create mode 100644 .github/workflows/cpflow-cleanup-stale-review-apps.yml create mode 100644 .github/workflows/cpflow-delete-review-app.yml create mode 100644 .github/workflows/cpflow-deploy-review-app.yml create mode 100644 .github/workflows/cpflow-deploy-staging.yml create mode 100644 .github/workflows/cpflow-help-command.yml create mode 100644 .github/workflows/cpflow-promote-staging-to-production.yml create mode 100644 .github/workflows/cpflow-review-app-help.yml create mode 100644 app/javascript/src/Navbar/ror_components/Navbar.client.jsx create mode 100644 app/javascript/src/Navbar/ror_components/Navbar.server.jsx create mode 100644 app/javascript/src/PostsIndex/ror_components/PostsIndex.client.jsx create mode 100644 app/javascript/src/PostsIndex/ror_components/PostsIndex.server.jsx create mode 100644 app/javascript/src/PostsShow/ror_components/PostsShow.client.jsx create mode 100644 app/javascript/src/PostsShow/ror_components/PostsShow.server.jsx create mode 100644 config/tailwind.config.js diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile new file mode 100644 index 0000000..26fce15 --- /dev/null +++ b/.controlplane/Dockerfile @@ -0,0 +1,67 @@ +ARG RUBY_VERSION=3.1.2 + +FROM docker.io/library/node:22-bullseye-slim AS node +FROM ruby:$RUBY_VERSION + +RUN apt-get update + +WORKDIR /app + +# Keep Node.js available both for asset compilation and for SSR runtimes that +# rely on ExecJS in production. +COPY --from=node /usr/local/ /usr/local/ + +# 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 install --jobs=3 --retry=3 + +COPY . ./ + +# Install JavaScript dependencies only when the project actually has them. +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 && \ + (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@latest --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 + +RUN rails assets:precompile + +# add entrypoint +COPY .controlplane/entrypoint.sh ./ +ENTRYPOINT ["/app/entrypoint.sh"] + +CMD ["rails", "s"] diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml new file mode 100644 index 0000000..d590169 --- /dev/null +++ b/.controlplane/controlplane.yml @@ -0,0 +1,45 @@ +# Keys beginning with "cpln_" correspond to your settings in Control Plane. +# +# This demo stores its SQLite database under `/app/db` and local Active Storage +# files under `/app/storage`, so both directories need 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: [] + + 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: react-on-rails-migration-example-staging + +apps: + react-on-rails-migration-example-staging: + <<: *common + + react-on-rails-migration-example-review: + <<: *common + match_if_app_name_starts_with: true + + react-on-rails-migration-example-production: + <<: *production diff --git a/.controlplane/entrypoint.sh b/.controlplane/entrypoint.sh new file mode 100755 index 0000000..e0cb960 --- /dev/null +++ b/.controlplane/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Runs before the main command + +echo " -- Preparing database" +rails db:prepare + +echo " -- Finishing entrypoint.sh, executing '$@'" +exec "$@" diff --git a/.controlplane/readme.md b/.controlplane/readme.md new file mode 100644 index 0000000..4f4fc82 --- /dev/null +++ b/.controlplane/readme.md @@ -0,0 +1,66 @@ +# Control Plane Deployment Notes + +This repo now includes `cpflow` scaffolding for: + +- opt-in PR review apps +- automatic staging deploys from `main` +- manual promotion from staging to production + +## Why This Shape + +This app runs on SQLite in production and stores uploaded files on the local +disk. + +The Control Plane setup mirrors that: + +- `.controlplane/templates/db.yml` creates a persistent volume for `/app/db` +- `.controlplane/templates/storage.yml` creates a persistent volume for `/app/storage` +- `.controlplane/templates/rails.yml` mounts both volumes into the `rails` workload +- `.controlplane/release_script.sh` runs `bin/rails db:prepare` before deploys switch images + +The generated `.controlplane/Dockerfile` now installs Node.js alongside Ruby, +auto-installs JavaScript dependencies for npm/Yarn/pnpm projects, and leaves a +callable package-manager shim in place so `assets:precompile` can invoke +`yarn` or `pnpm` again in later build steps. + +## Required Runtime Secrets + +Before the app will boot on Control Plane, populate `SECRET_KEY_BASE` in the +generated secret dictionaries: + +- `react-on-rails-migration-example-staging-secrets` +- `react-on-rails-migration-example-review-secrets` +- `react-on-rails-migration-example-production-secrets` + +`cpflow setup-app` creates those dictionaries automatically. You only need to +add a `SECRET_KEY_BASE` entry to each one before the first deploy. + +## Local cpflow Flow + +Typical setup: + +```sh +export APP_NAME=react-on-rails-migration-example-staging + +cpflow setup-app -a "$APP_NAME" +cpflow build-image -a "$APP_NAME" +cpflow deploy-image -a "$APP_NAME" --run-release-phase +cpflow open -a "$APP_NAME" +``` + +## GitHub Actions Variables And Secrets + +Set these in GitHub before enabling the generated `cpflow-*` workflows: + +- `CPLN_TOKEN_STAGING` +- `CPLN_TOKEN_PRODUCTION` +- `CPLN_ORG_STAGING` +- `CPLN_ORG_PRODUCTION` +- `STAGING_APP_NAME=react-on-rails-migration-example-staging` +- `PRODUCTION_APP_NAME=react-on-rails-migration-example-production` +- `REVIEW_APP_PREFIX=react-on-rails-migration-example-review` + +Optional: + +- `STAGING_APP_BRANCH=main` +- `PRIMARY_WORKLOAD=rails` diff --git a/.controlplane/release_script.sh b/.controlplane/release_script.sh new file mode 100755 index 0000000..0c5c5a8 --- /dev/null +++ b/.controlplane/release_script.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +mkdir -p db storage +./bin/rails db:prepare diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml new file mode 100644 index 0000000..bf87f46 --- /dev/null +++ b/.controlplane/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/.controlplane/templates/db.yml b/.controlplane/templates/db.yml new file mode 100644 index 0000000..921675b --- /dev/null +++ b/.controlplane/templates/db.yml @@ -0,0 +1,6 @@ +kind: volumeset +name: app-db +spec: + fileSystemType: ext4 + initialCapacity: 5 + performanceClass: general-purpose-ssd diff --git a/.controlplane/templates/rails.yml b/.controlplane/templates/rails.yml new file mode 100644 index 0000000..b1b3197 --- /dev/null +++ b/.controlplane/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/.controlplane/templates/storage.yml b/.controlplane/templates/storage.yml new file mode 100644 index 0000000..8956013 --- /dev/null +++ b/.controlplane/templates/storage.yml @@ -0,0 +1,6 @@ +kind: volumeset +name: app-storage +spec: + fileSystemType: ext4 + initialCapacity: 10 + performanceClass: general-purpose-ssd diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml new file mode 100644 index 0000000..e17628f --- /dev/null +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -0,0 +1,70 @@ +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 arguments passed through to docker build + 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 + +outputs: + image_tag: + description: Fully qualified image tag + value: ${{ steps.build.outputs.image_tag }} + +runs: + using: composite + steps: + - name: Build Docker image + id: build + shell: bash + run: | + set -euo pipefail + + PR_INFO="" + docker_build_args=() + + if [[ -n "${{ inputs.pr_number }}" ]]; then + PR_INFO=" for PR #${{ inputs.pr_number }}" + fi + + if [[ -n "${{ inputs.docker_build_extra_args }}" ]]; then + while IFS= read -r arg; do + arg="${arg%$'\r'}" + [[ -n "${arg}" ]] || continue + docker_build_args+=("${arg}") + done <<< "${{ inputs.docker_build_extra_args }}" + fi + + if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan -H github.com >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + eval "$(ssh-agent -s)" + trap 'ssh-agent -k >/dev/null' EXIT + ssh-add - <<< "${{ inputs.docker_build_ssh_key }}" + docker_build_args+=("--ssh default") + fi + + echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." + cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}" "${docker_build_args[@]}" + + image_tag="${{ inputs.org }}/${{ inputs.app_name }}:${{ inputs.commit }}" + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + echo "✅ Docker image build successful${PR_INFO} (commit ${{ inputs.commit }})" diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml new file mode 100644 index 0000000..63981dd --- /dev/null +++ b/.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/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh new file mode 100755 index 0000000..a43e881 --- /dev/null +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -0,0 +1,37 @@ +#!/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" +exists_output="" +if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then + if [[ -z "$exists_output" ]]; then + echo "⚠️ Application does not exist: $APP_NAME" + exit 0 + fi + + echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2 + printf '%s\n' "$exists_output" >&2 + exit 1 +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/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml new file mode 100644 index 0000000..57b5f3f --- /dev/null +++ b/.github/actions/cpflow-setup-environment/action.yml @@ -0,0 +1,70 @@ +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 + required: false + default: "3.4.6" + cpln_cli_version: + description: "@controlplane/cli version" + required: false + default: "3.3.1" + cpflow_version: + description: cpflow gem version + required: false + default: "4.2.0" + +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 + run: | + set -euo pipefail + + TOKEN="${{ inputs.token }}" + ORG="${{ inputs.org }}" + + if [[ -z "$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 --token "$TOKEN" --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" --token "$TOKEN" + cpln image docker-login --org "$ORG" diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml new file mode 100644 index 0000000..d8dc5a9 --- /dev/null +++ b/.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/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml new file mode 100644 index 0000000..3118a62 --- /dev/null +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -0,0 +1,118 @@ +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 + +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 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"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: Number("${{ steps.create-comment.outputs.comment-id }}"), + body + }); diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml new file mode 100644 index 0000000..9ec36a3 --- /dev/null +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -0,0 +1,364 @@ +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: [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: + if: | + github.event_name == 'pull_request' || + 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 + + if cpflow config -a "${APP_NAME}" | grep -q "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 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: Number("${{ steps.create-comment.outputs.comment-id }}"), + 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 }} + + - 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 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: Number("${{ steps.create-comment.outputs.comment-id }}"), + 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') + shell: bash + run: | + set -euo pipefail + cpflow deploy-image -a "${APP_NAME}" ${{ steps.release-phase.outputs.flag }} --org "${CPLN_ORG}" --verbose + + - 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"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: success ? successBody : failureBody + }); diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml new file mode 100644 index 0000000..29acbed --- /dev/null +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -0,0 +1,120 @@ +name: Deploy Staging to Control Plane + +run-name: Deploy Control Plane staging app + +on: + push: + branches: ["**"] + 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 }} + + 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 + + if cpflow config -a "${APP_NAME}" | grep -q "release_script:"; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy staging image + shell: bash + run: | + set -euo pipefail + cpflow deploy-image -a "${APP_NAME}" ${{ steps.release-phase.outputs.flag }} --org "${CPLN_ORG}" --verbose diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml new file mode 100644 index 0000000..d0112a9 --- /dev/null +++ b/.github/workflows/cpflow-help-command.yml @@ -0,0 +1,78 @@ +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') || + 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`", + "", + "## 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`)", + "", + "## 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/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml new file mode 100644 index 0000000..73dc069 --- /dev/null +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -0,0 +1,243 @@ +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: + HEALTH_CHECK_RETRIES: 12 + HEALTH_CHECK_INTERVAL: 10 + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + +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: Resolve production app workloads + id: workloads + shell: bash + run: | + set -euo pipefail + + workloads="$( + ruby -e 'require "yaml"; app = ARGV.fetch(0); data = YAML.load_file(".controlplane/controlplane.yml", aliases: true); app_config = data.fetch("apps").fetch(app); workloads = Array(app_config["app_workloads"]); workloads = ["rails"] if workloads.empty?; puts workloads.join(",")' "${{ vars.PRODUCTION_APP_NAME }}" + )" + + echo "names=${workloads}" >> "$GITHUB_OUTPUT" + + - name: Setup production environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + org: ${{ vars.CPLN_ORG_PRODUCTION }} + + - name: Detect release phase support + id: release-phase + shell: bash + run: | + set -euo pipefail + + if cpflow config -a "${{ vars.PRODUCTION_APP_NAME }}" | grep -q "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 }} + shell: bash + run: | + set -euo pipefail + + staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${{ vars.STAGING_APP_NAME }}" --org "${{ vars.CPLN_ORG_STAGING }}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.CPLN_ORG_PRODUCTION }}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + missing_vars="$(comm -23 <(echo "${staging_vars}") <(echo "${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 + 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 "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.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' <<< "${{ steps.workloads.outputs.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" + { + echo "rollback_state<> "$GITHUB_OUTPUT" + + - name: Copy image from staging + shell: bash + run: | + set -euo pipefail + cpflow copy-image-from-upstream -a "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.CPLN_ORG_PRODUCTION }}" -t "${{ secrets.CPLN_TOKEN_STAGING }}" + + - name: Deploy image to production + shell: bash + run: | + set -euo pipefail + cpflow deploy-image -a "${{ vars.PRODUCTION_APP_NAME }}" ${{ steps.release-phase.outputs.flag }} --org "${{ vars.CPLN_ORG_PRODUCTION }}" --verbose + + - name: Wait for deployment health + id: health-check + shell: bash + run: | + set -euo pipefail + + workload_name="${PRIMARY_WORKLOAD:-rails}" + + for attempt in $(seq 1 "${HEALTH_CHECK_RETRIES}"); do + echo "Health check attempt ${attempt}/${HEALTH_CHECK_RETRIES}" + + endpoint="$(cpln workload get "${workload_name}" --gvc "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.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}" + + if [[ "${http_status}" == "200" || "${http_status}" == "301" || "${http_status}" == "302" ]]; then + echo "healthy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + 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 != '' + env: + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + shell: bash + run: | + set -euo pipefail + + 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)"') + + cpln workload update "${workload_name}" \ + --gvc "${{ vars.PRODUCTION_APP_NAME }}" \ + --org "${{ vars.CPLN_ORG_PRODUCTION }}" \ + "${rollback_args[@]}" + done < <(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.images | @json)"') + + - name: Create GitHub release + if: success() && steps.health-check.outputs.healthy == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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')" + release_tag="production-${release_date}-${timestamp}" + + 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() + shell: bash + run: | + { + echo "## Promotion Summary" + echo + if [[ "${{ steps.health-check.outputs.healthy }}" == "true" ]]; then + echo "✅ Status: deployment successful" + else + echo "❌ Status: deployment failed" + fi + echo + echo "Previous image: \`${{ steps.capture-current.outputs.current_image }}\`" + echo "Previous version: ${{ steps.capture-current.outputs.current_version }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml new file mode 100644 index 0000000..e3958cc --- /dev/null +++ b/.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/Gemfile b/Gemfile index 3c459ae..0d90ae8 100644 --- a/Gemfile +++ b/Gemfile @@ -18,9 +18,6 @@ gem "puma", "~> 5.0" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" -# Embedded V8 javascript engine in ruby, it's pretty fast -gem "mini_racer", "~> 0.8.0" - # The library which provides react helpers and more in rails gem "react_on_rails" diff --git a/Gemfile.lock b/Gemfile.lock index ce9368b..40955d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,7 +100,6 @@ GEM railties (>= 4) jsbundling-rails (1.1.2) railties (>= 6.0.0) - libv8-node (18.16.0.0-arm64-darwin) loofah (2.21.3) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -113,8 +112,6 @@ GEM matrix (0.4.2) method_source (1.0.0) mini_mime (1.1.5) - mini_racer (0.8.0) - libv8-node (~> 18.16.0.0) minitest (5.19.0) msgpack (1.7.2) net-imap (0.3.7) @@ -127,7 +124,9 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.9) - nokogiri (1.15.4-arm64-darwin) + nokogiri (1.19.2-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) package_json (0.2.0) pry (0.14.2) @@ -204,7 +203,10 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sqlite3 (1.6.4-arm64-darwin) + sqlite3 (1.7.3-aarch64-linux) + sqlite3 (1.7.3-arm64-darwin) + tailwindcss-rails (2.0.30-aarch64-linux) + railties (>= 6.0.0) tailwindcss-rails (2.0.30-arm64-darwin) railties (>= 6.0.0) terser (1.1.17) @@ -231,7 +233,9 @@ GEM zeitwerk (2.6.11) PLATFORMS + aarch64-linux arm64-darwin-21 + arm64-darwin-23 arm64-darwin-25 DEPENDENCIES @@ -241,7 +245,6 @@ DEPENDENCIES jbuilder js-routes jsbundling-rails (~> 1.1) - mini_racer (~> 0.8.0) pry-remote puma (~> 5.0) rails (~> 7.0.7, >= 7.0.7.2) diff --git a/README.md b/README.md index 2600505..a3f59e1 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,9 @@ This repository is a migrated version of [ganchdev/react-rails-example](https:// ## Notes - This app is intentionally minimal and focused on migration outcomes. - Generated React on Rails files are kept in-repo so users can inspect exact changes after migration. + +## Control Plane Flow + +This repo now includes a shared `cpflow` setup for review apps, automatic +staging deploys from `main`, and production promotion. See +`.controlplane/readme.md` for the required Control Plane and GitHub setup. diff --git a/app/javascript/src/Navbar/ror_components/Navbar.client.jsx b/app/javascript/src/Navbar/ror_components/Navbar.client.jsx new file mode 100644 index 0000000..c7eb39e --- /dev/null +++ b/app/javascript/src/Navbar/ror_components/Navbar.client.jsx @@ -0,0 +1,3 @@ +import Navbar from '../../components/Navbar'; + +export default Navbar; diff --git a/app/javascript/src/Navbar/ror_components/Navbar.server.jsx b/app/javascript/src/Navbar/ror_components/Navbar.server.jsx new file mode 100644 index 0000000..f91e1a2 --- /dev/null +++ b/app/javascript/src/Navbar/ror_components/Navbar.server.jsx @@ -0,0 +1,3 @@ +import Navbar from './Navbar.client'; + +export default Navbar; diff --git a/app/javascript/src/PostsIndex/ror_components/PostsIndex.client.jsx b/app/javascript/src/PostsIndex/ror_components/PostsIndex.client.jsx new file mode 100644 index 0000000..3e9ce9a --- /dev/null +++ b/app/javascript/src/PostsIndex/ror_components/PostsIndex.client.jsx @@ -0,0 +1,3 @@ +import PostsIndex from '../../pages/PostsIndex'; + +export default PostsIndex; diff --git a/app/javascript/src/PostsIndex/ror_components/PostsIndex.server.jsx b/app/javascript/src/PostsIndex/ror_components/PostsIndex.server.jsx new file mode 100644 index 0000000..729577b --- /dev/null +++ b/app/javascript/src/PostsIndex/ror_components/PostsIndex.server.jsx @@ -0,0 +1,3 @@ +import PostsIndex from './PostsIndex.client'; + +export default PostsIndex; diff --git a/app/javascript/src/PostsShow/ror_components/PostsShow.client.jsx b/app/javascript/src/PostsShow/ror_components/PostsShow.client.jsx new file mode 100644 index 0000000..164bb24 --- /dev/null +++ b/app/javascript/src/PostsShow/ror_components/PostsShow.client.jsx @@ -0,0 +1,3 @@ +import PostsShow from '../../pages/PostsShow'; + +export default PostsShow; diff --git a/app/javascript/src/PostsShow/ror_components/PostsShow.server.jsx b/app/javascript/src/PostsShow/ror_components/PostsShow.server.jsx new file mode 100644 index 0000000..f342d72 --- /dev/null +++ b/app/javascript/src/PostsShow/ror_components/PostsShow.server.jsx @@ -0,0 +1,3 @@ +import PostsShow from './PostsShow.client'; + +export default PostsShow; diff --git a/app/javascript/src/components/Navbar.jsx b/app/javascript/src/components/Navbar.jsx index 8615b56..83ad484 100644 --- a/app/javascript/src/components/Navbar.jsx +++ b/app/javascript/src/components/Navbar.jsx @@ -42,9 +42,9 @@ function Navbar({ menus, homeUrl }) { ); } -Card.propTypes = { +Navbar.propTypes = { menus: PropTypes.string, homeUrl: PropTypes.string, -} +}; export default Navbar; diff --git a/app/javascript/src/pages/PostsShow.jsx b/app/javascript/src/pages/PostsShow.jsx index 02a4c8b..2d082a0 100644 --- a/app/javascript/src/pages/PostsShow.jsx +++ b/app/javascript/src/pages/PostsShow.jsx @@ -1,4 +1,4 @@ -import React, from "react"; +import React from "react"; import PropTypes from "prop-types"; /** @@ -8,7 +8,7 @@ import PropTypes from "prop-types"; function PostsShow({ post }) { const parsedPost = JSON.parse(post); - return( + return (

{parsedPost.title}

{parsedPost.content}

@@ -18,6 +18,6 @@ function PostsShow({ post }) { PostsShow.propTypes = { post: PropTypes.string -} +}; -export default PostsShow; \ No newline at end of file +export default PostsShow; diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 988f871..ea27b5c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,15 +9,14 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> - <%= javascript_pack_tag "application" %> <%= react_component( "Navbar", - { - menus: JSON.generate([{name: "Posts", href: root_path}]), + props: { + menus: JSON.generate([{ name: "Posts", href: root_path }]), homeUrl: root_path }, prerender: true) diff --git a/app/views/posts/index.html.erb b/app/views/posts/index.html.erb index 64c3c87..6132b7e 100644 --- a/app/views/posts/index.html.erb +++ b/app/views/posts/index.html.erb @@ -1,3 +1,3 @@ <%= - react_component("PostsIndex", { posts: @posts }, prerender: true) + react_component("PostsIndex", props: { posts: @posts }, prerender: true) %> diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb index 06b4410..d47efcf 100644 --- a/app/views/posts/show.html.erb +++ b/app/views/posts/show.html.erb @@ -1,3 +1,3 @@ <%= - react_component("PostsShow", {post: @post}, prerender: true) + react_component("PostsShow", props: { post: @post }, prerender: true) %> diff --git a/config/tailwind.config.js b/config/tailwind.config.js new file mode 100644 index 0000000..2d459e9 --- /dev/null +++ b/config/tailwind.config.js @@ -0,0 +1,18 @@ +module.exports = { + content: [ + "./app/helpers/**/*.rb", + "./app/javascript/**/*.{js,jsx,ts,tsx}", + "./app/views/**/*.{erb,haml,html,slim}", + "./config/initializers/**/*.rb" + ], + theme: { + extend: {} + }, + plugins: [ + require("@tailwindcss/aspect-ratio"), + require("@tailwindcss/container-queries"), + require("@tailwindcss/forms"), + require("@tailwindcss/typography"), + require("daisyui") + ] +}; diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 77aa114..1f3b49e 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -140,11 +140,6 @@ const configureServer = () => { // The default of cheap-module-source-map is slow and provides poor info. serverWebpackConfig.devtool = 'eval'; - // If using the default 'web', then libraries like Emotion and loadable-components - // break with SSR. The fix is to use a node renderer and change the target. - // If using the React on Rails Pro node server renderer, uncomment the next line - // serverWebpackConfig.target = 'node' - return serverWebpackConfig; }; diff --git a/package.json b/package.json index c2345fb..4bd5165 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "webpack-subresource-integrity": "^5.1.0" }, "scripts": { - "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets", + "build": "esbuild app/javascript/application.js --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets", "build:css": "tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -m -o ./app/assets/builds/tailwind.css" }, "devDependencies": { From 9238039c801cf79214a5f881d35c7c5612c70fcd Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 15 Apr 2026 04:50:37 -1000 Subject: [PATCH 02/25] Sync Control Plane flow hardening --- .controlplane/Dockerfile | 3 +-- .controlplane/entrypoint.sh | 2 +- .../cpflow-build-docker-image/action.yml | 19 ++++++++++++++-- .../delete-app.sh | 18 ++++++++++----- .../workflows/cpflow-delete-review-app.yml | 12 +++++++++- .../workflows/cpflow-deploy-review-app.yml | 22 +++++++++++++++++-- .github/workflows/cpflow-deploy-staging.yml | 3 +++ .github/workflows/cpflow-help-command.yml | 6 ++++- .../cpflow-promote-staging-to-production.yml | 2 +- 9 files changed, 71 insertions(+), 16 deletions(-) diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index 26fce15..e921e4c 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -3,7 +3,6 @@ ARG RUBY_VERSION=3.1.2 FROM docker.io/library/node:22-bullseye-slim AS node FROM ruby:$RUBY_VERSION -RUN apt-get update WORKDIR /app @@ -22,7 +21,7 @@ RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \ 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 . ./ diff --git a/.controlplane/entrypoint.sh b/.controlplane/entrypoint.sh index e0cb960..df261da 100755 --- a/.controlplane/entrypoint.sh +++ b/.controlplane/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/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml index e17628f..56b1100 100644 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -20,6 +20,9 @@ inputs: 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 outputs: image_tag: @@ -53,13 +56,25 @@ runs: if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then mkdir -p ~/.ssh chmod 700 ~/.ssh - ssh-keyscan -H github.com >> ~/.ssh/known_hosts + + if [[ -n "${{ inputs.docker_build_ssh_known_hosts }}" ]]; then + cat <<'EOF' > ~/.ssh/known_hosts +${{ inputs.docker_build_ssh_known_hosts }} +EOF + else + cat <<'EOF' > ~/.ssh/known_hosts +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= +EOF + fi + chmod 600 ~/.ssh/known_hosts eval "$(ssh-agent -s)" trap 'ssh-agent -k >/dev/null' EXIT ssh-add - <<< "${{ inputs.docker_build_ssh_key }}" - docker_build_args+=("--ssh default") + docker_build_args+=("--ssh" "default") fi echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." diff --git a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh index a43e881..6b70722 100755 --- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -17,14 +17,20 @@ fi echo "🔍 Checking if application exists: $APP_NAME" exists_output="" if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then - if [[ -z "$exists_output" ]]; then - echo "⚠️ Application does not exist: $APP_NAME" - exit 0 + 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 "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2 - printf '%s\n' "$exists_output" >&2 - exit 1 + echo "⚠️ Application does not exist: $APP_NAME" + exit 0 fi if [[ -n "$exists_output" ]]; then diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml index 3118a62..7a29629 100644 --- a/.github/workflows/cpflow-delete-review-app.yml +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -17,6 +17,10 @@ permissions: 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 }} @@ -95,6 +99,7 @@ jobs: uses: actions/github-script@v7 with: script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); const success = "${{ job.status }}" === "success"; const body = success ? [ @@ -110,9 +115,14 @@ jobs: `[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: Number("${{ steps.create-comment.outputs.comment-id }}"), + comment_id: commentId, body }); diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml index 9ec36a3..e662b80 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -254,6 +254,12 @@ jobs: 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}`, "", @@ -265,7 +271,7 @@ jobs: await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: Number("${{ steps.create-comment.outputs.comment-id }}"), + comment_id: commentId, body }); @@ -279,12 +285,19 @@ jobs: 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...", "", @@ -296,7 +309,7 @@ jobs: await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: Number("${{ steps.create-comment.outputs.comment-id }}"), + comment_id: commentId, body }); @@ -356,6 +369,11 @@ jobs: `[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, diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index 29acbed..00fbe80 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -4,6 +4,8 @@ run-name: Deploy Control Plane staging app on: push: + # GitHub does not allow repository vars in branch filters, so this workflow listens to + # every branch and exits early unless the ref matches STAGING_APP_BRANCH/main/master. branches: ["**"] workflow_dispatch: @@ -86,6 +88,7 @@ jobs: 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] diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml index d0112a9..1430886 100644 --- a/.github/workflows/cpflow-help-command.yml +++ b/.github/workflows/cpflow-help-command.yml @@ -19,7 +19,8 @@ jobs: if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && - github.event.comment.body == '/help') || + github.event.comment.body == '/help' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -46,6 +47,7 @@ jobs: "", "- `CPLN_TOKEN_STAGING`", "- `CPLN_TOKEN_PRODUCTION`", + "- `DOCKER_BUILD_SSH_KEY` (optional, when Docker builds fetch private dependencies over SSH)", "", "## Repository variables", "", @@ -56,6 +58,8 @@ jobs: "- `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", "", diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index 73dc069..5e1f93a 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -187,7 +187,7 @@ jobs: exit 1 - name: Roll back on failure - if: failure() && steps.capture-current.outputs.rollback_state != '' + if: failure() && steps.capture-current.outputs.rollback_state != '{}' env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} shell: bash From ea290be79a0ebc633a134f8f56a7221c9bf24fe0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 15 Apr 2026 05:01:07 -1000 Subject: [PATCH 03/25] Preserve frontend precompile hooks in Dockerfile --- .controlplane/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index e921e4c..d69a3bf 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -57,6 +57,8 @@ ENV RAILS_ENV=production # compiling assets requires any value for ENV of SECRET_KEY_BASE ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK +RUN bin/shakapacker-precompile-hook + RUN rails assets:precompile # add entrypoint From 1e769a01ea3c30d6b719c0620f1ed5f819c7bb57 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 15 Apr 2026 05:05:16 -1000 Subject: [PATCH 04/25] Tighten Docker build action arguments --- .github/actions/cpflow-build-docker-image/action.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml index 56b1100..e7fbf45 100644 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -15,7 +15,7 @@ inputs: description: Pull request number for status messaging required: false docker_build_extra_args: - description: Optional newline-delimited extra arguments passed through to docker build + 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 @@ -49,6 +49,13 @@ runs: 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 <<< "${{ inputs.docker_build_extra_args }}" fi @@ -74,7 +81,7 @@ EOF eval "$(ssh-agent -s)" trap 'ssh-agent -k >/dev/null' EXIT ssh-add - <<< "${{ inputs.docker_build_ssh_key }}" - docker_build_args+=("--ssh" "default") + docker_build_args+=("--ssh=default") fi echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." From e8bc403a4db233c884f8635c57b093cd3c610c5d Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 1 Jun 2026 15:38:26 -1000 Subject: [PATCH 05/25] docs: clarify review app security --- .controlplane/readme.md | 10 ++++++++++ .controlplane/templates/app.yml | 3 +++ 2 files changed, 13 insertions(+) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 4f4fc82..0afa9a5 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -35,6 +35,11 @@ generated secret dictionaries: `cpflow setup-app` creates those dictionaries automatically. You only need to add a `SECRET_KEY_BASE` entry to each one before the first deploy. +Review apps run pull request code. Values mounted through `cpln://secret/...` +can be read by that code after the workload starts, so keep the review secret +dictionary limited to generated, review-only values. Do not reuse production or +long-lived staging secret dictionaries for review apps. + ## Local cpflow Flow Typical setup: @@ -64,3 +69,8 @@ Optional: - `STAGING_APP_BRANCH=main` - `PRIMARY_WORKLOAD=rails` + +Use a staging/review `CPLN_TOKEN_STAGING` that cannot access production Control +Plane resources. In public repositories, review-app deploys skip fork PR heads +because Docker builds use repository secrets. If a forked change needs a review +app, first move the reviewed change to a trusted branch in this repository. diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml index bf87f46..8af89c1 100644 --- a/.controlplane/templates/app.yml +++ b/.controlplane/templates/app.yml @@ -8,6 +8,9 @@ spec: value: "true" - name: RAILS_SERVE_STATIC_FILES value: "true" + # Review apps run pull request code. Values mounted through cpln://secret + # become readable by that code after the workload starts, so review-app + # secret dictionaries must contain review-safe values. - name: SECRET_KEY_BASE value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE staticPlacement: From 2ceb6b94105412fdf9e5856bfbf2b5abdcd2beff Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:01:04 -1000 Subject: [PATCH 06/25] Update cpflow deployment flow to v5.3.0 --- .agents/bin/README.md | 2 +- .agents/bin/build | 3 +- .controlplane/Dockerfile | 52 +- .controlplane/controlplane.yml | 12 +- .controlplane/entrypoint.sh | 46 +- .controlplane/readme.md | 101 +-- .controlplane/release_script.sh | 22 +- .controlplane/templates/app.yml | 11 +- .controlplane/templates/rails.yml | 11 +- .../cpflow-build-docker-image/action.yml | 92 --- .../action.yml | 24 - .../delete-app.sh | 43 - .../cpflow-setup-environment/action.yml | 70 -- .github/cpflow-help.md | 192 +++++ .../cpflow-cleanup-stale-review-apps.yml | 40 +- .../workflows/cpflow-delete-review-app.yml | 120 +-- .../workflows/cpflow-deploy-review-app.yml | 371 +-------- .github/workflows/cpflow-deploy-staging.yml | 121 +-- .github/workflows/cpflow-help-command.yml | 62 +- .../cpflow-promote-staging-to-production.yml | 758 +++++++++++++++--- .github/workflows/cpflow-review-app-help.yml | 34 +- Gemfile | 2 +- bin/pin-cpflow-github-ref | 88 ++ bin/test-cpflow-github-flow | 156 ++++ 24 files changed, 1318 insertions(+), 1115 deletions(-) delete mode 100644 .github/actions/cpflow-build-docker-image/action.yml delete mode 100644 .github/actions/cpflow-delete-control-plane-app/action.yml delete mode 100755 .github/actions/cpflow-delete-control-plane-app/delete-app.sh delete mode 100644 .github/actions/cpflow-setup-environment/action.yml create mode 100644 .github/cpflow-help.md create mode 100755 bin/pin-cpflow-github-ref create mode 100755 bin/test-cpflow-github-flow diff --git a/.agents/bin/README.md b/.agents/bin/README.md index 0885878..1bc277e 100644 --- a/.agents/bin/README.md +++ b/.agents/bin/README.md @@ -11,7 +11,7 @@ means that capability is n/a here. | `validate` | Pre-push gate | JavaScript and Tailwind CSS builds, production Shakapacker compilation, then `bin/rails test` | | `test` | Run tests | `bin/rails test` | | `lint` | Lint / format | n/a | -| `build` | Build / type-check | `bin/yarn build`, `bin/yarn build:css`, then `RAILS_ENV=production bin/shakapacker` | +| `build` | Build / type-check | JavaScript and Tailwind builds, React on Rails pack generation, then production Shakapacker compilation | | `docs` | Validate documentation | n/a | | `ci-detect` | Detect CI impact | n/a | diff --git a/.agents/bin/build b/.agents/bin/build index 463aa91..4d89345 100755 --- a/.agents/bin/build +++ b/.agents/bin/build @@ -3,5 +3,6 @@ set -euo pipefail cd "$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" bin/yarn build -bin/yarn build:css +bin/rails tailwindcss:build +bin/shakapacker-precompile-hook exec env RAILS_ENV=production bin/shakapacker "$@" diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index d69a3bf..d830a17 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -1,14 +1,32 @@ -ARG RUBY_VERSION=3.1.2 +ARG RUBY_VERSION=3.4.6 -FROM docker.io/library/node:22-bullseye-slim AS node -FROM ruby:$RUBY_VERSION +# 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 +# Yarn Classic / pnpm fallbacks for projects that haven't adopted corepack via +# `packageManager` in package.json. Override at build time (`--build-arg=YARN_CLASSIC_VERSION=...`) +# rather than editing this file so regenerating the Dockerfile keeps your pin. +ARG YARN_CLASSIC_VERSION=1.22.22 +ARG PNPM_FALLBACK_VERSION=9.12.3 WORKDIR /app # Keep Node.js available both for asset compilation and for SSR runtimes that -# rely on ExecJS in production. -COPY --from=node /usr/local/ /usr/local/ +# 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/lib/node_modules /usr/local/lib/node_modules +COPY --from=node /usr/local/include/node /usr/local/include/node + +RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ + ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack && \ + chmod +x /usr/local/lib/node_modules/npm/bin/npm-cli.js \ + /usr/local/lib/node_modules/npm/bin/npx-cli.js \ + /usr/local/lib/node_modules/corepack/dist/corepack.js && \ + node --version && npm --version && corepack --version # Expose Corepack-managed shims so later build steps can call yarn/pnpm # directly during asset precompilation hooks. @@ -20,13 +38,20 @@ RUN printf '%s\n' '#!/bin/sh' 'exec corepack yarn "$@"' > /usr/bin/yarn && \ # install ruby gems COPY Gemfile* ./ -RUN bundle config set without 'development test' && \ +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential && \ + bundle config set without 'development test' && \ bundle config set with 'production' && \ - bundle install --jobs=3 --retry=3 + bundle install --jobs=3 --retry=3 && \ + apt-get purge -y --auto-remove build-essential && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives COPY . ./ # Install JavaScript dependencies only when the project actually has them. +# Pin `packageManager` in package.json to take the corepack path and avoid the +# YARN_CLASSIC_VERSION / PNPM_FALLBACK_VERSION fallbacks below; the fallbacks exist for +# projects that haven't adopted corepack and should be reviewed periodically as they go stale. RUN if [ -f package.json ]; then \ package_manager="$(node -p "require('./package.json').packageManager || ''")"; \ if [ -f yarn.lock ]; then \ @@ -34,7 +59,7 @@ RUN if [ -f package.json ]; then \ corepack prepare "$package_manager" --activate && \ (corepack yarn install --immutable || corepack yarn install --frozen-lockfile); \ else \ - npm install -g yarn && \ + npm install -g "yarn@${YARN_CLASSIC_VERSION}" && \ (yarn install --immutable || yarn install --frozen-lockfile); \ fi; \ elif [ -f pnpm-lock.yaml ]; then \ @@ -42,7 +67,7 @@ RUN if [ -f package.json ]; then \ corepack prepare "$package_manager" --activate && \ corepack pnpm install --frozen-lockfile; \ else \ - corepack prepare pnpm@latest --activate && \ + corepack prepare "pnpm@${PNPM_FALLBACK_VERSION}" --activate && \ corepack pnpm install --frozen-lockfile; \ fi; \ elif [ -f package-lock.json ]; then \ @@ -54,15 +79,16 @@ RUN if [ -f package.json ]; then \ ENV RAILS_ENV=production -# compiling assets requires any value for ENV of SECRET_KEY_BASE -ENV SECRET_KEY_BASE=NOT_USED_NON_BLANK +# Compiling assets requires any non-blank value, but the placeholder must not +# remain in the final image environment. +RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK bin/shakapacker-precompile-hook -RUN bin/shakapacker-precompile-hook -RUN rails assets:precompile +RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK rails assets:precompile # add entrypoint COPY .controlplane/entrypoint.sh ./ +RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] CMD ["rails", "s"] diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index d590169..53dc4ff 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -1,7 +1,7 @@ # Keys beginning with "cpln_" correspond to your settings in Control Plane. # -# This demo stores its SQLite database under `/app/db` and local Active Storage -# files under `/app/storage`, so both directories need persistent volumes. +# 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 @@ -21,6 +21,7 @@ aliases: - rails additional_workloads: [] + maintenance_workload: maintenance release_script: release_script.sh stale_app_image_deployed_days: 5 @@ -40,6 +41,13 @@ apps: react-on-rails-migration-example-review: <<: *common match_if_app_name_starts_with: true + # Optional: if a review app needs an existing shared org-level secret, + # declare its policy here and reference it in templates with + # {{SHARED_SECRET_}}. + # shared_secret_grants: + # - name: database + # secret_name: react-on-rails-migration-example-review-database-secrets + # policy_name: react-on-rails-migration-example-review-database-secrets-policy react-on-rails-migration-example-production: <<: *production diff --git a/.controlplane/entrypoint.sh b/.controlplane/entrypoint.sh index df261da..b041530 100755 --- a/.controlplane/entrypoint.sh +++ b/.controlplane/entrypoint.sh @@ -1,8 +1,48 @@ #!/bin/sh +set -e # Runs before the main command -echo " -- Preparing database" -rails db:prepare +is_rails_server_command() { + if [ "${1:-}" = "env" ]; then + shift + while [ "$#" -gt 0 ]; do + case "${1}" in + *=*) shift ;; + --) shift; break ;; + -*) return 1 ;; + *) break ;; + esac + done + fi -echo " -- Finishing entrypoint.sh, executing '$*'@'" + if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then + shift 2 + fi + + # Matches generated, optionally env-prefixed, flag-free Thruster invocations. + # Hand-edited commands with env flags or Thruster flags before rails skip + # generated DB prep. + if [ "${1:-}" = "thrust" ] || [ "${1:-}" = "bin/thrust" ] || [ "${1:-}" = "./bin/thrust" ]; then + shift + fi + + # Thruster may be wrapped with its own `bundle exec`, and the Rails command + # it proxies may also be wrapped with `bundle exec`. + if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then + shift 2 + fi + + { [ "${1:-}" = "rails" ] || [ "${1:-}" = "bin/rails" ] || [ "${1:-}" = "./bin/rails" ]; } && + { [ "${2:-}" = "server" ] || [ "${2:-}" = "s" ]; } +} + +# Match generated Rails server commands; workers and renderers skip DB prep. +# Generated Dockerfiles use WORKDIR /app; adjust this path if your hand-edited +# image runs the entrypoint from a different working directory. +if is_rails_server_command "$@"; then + echo " -- Preparing database" + ./bin/rails db:prepare +fi + +echo " -- Finishing entrypoint.sh, executing command" exec "$@" diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 0afa9a5..76d6fad 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -1,76 +1,51 @@ # Control Plane Deployment Notes -This repo now includes `cpflow` scaffolding for: +This repository uses `cpflow` for opt-in pull-request review apps, automatic +staging deploys from `main`, and manual promotion from staging to production. +The generated GitHub Actions use `cpflow` v5.3.0 and pin the immutable release +commit `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`; see +[`.github/cpflow-help.md`](../.github/cpflow-help.md) for the complete commands, +settings, and upgrade procedure. After regenerating wrappers for a future +release, repin them with `bin/pin-cpflow-github-ref `. -- opt-in PR review apps -- automatic staging deploys from `main` -- manual promotion from staging to production +## Runtime Shape -## Why This Shape +The app uses SQLite and local Active Storage in production. The Control Plane +templates therefore mount persistent volumes at `/app/db` and `/app/storage`, +and the release script runs `bin/rails db:prepare` before a new image is made +live. The Rails workload remains `standard` with one warm replica while +Capacity AI right-sizes its allocation. -This app runs on SQLite in production and stores uploaded files on the local -disk. +## One-Time Bootstrap -The Control Plane setup mirrors that: - -- `.controlplane/templates/db.yml` creates a persistent volume for `/app/db` -- `.controlplane/templates/storage.yml` creates a persistent volume for `/app/storage` -- `.controlplane/templates/rails.yml` mounts both volumes into the `rails` workload -- `.controlplane/release_script.sh` runs `bin/rails db:prepare` before deploys switch images - -The generated `.controlplane/Dockerfile` now installs Node.js alongside Ruby, -auto-installs JavaScript dependencies for npm/Yarn/pnpm projects, and leaves a -callable package-manager shim in place so `assets:precompile` can invoke -`yarn` or `pnpm` again in later build steps. - -## Required Runtime Secrets - -Before the app will boot on Control Plane, populate `SECRET_KEY_BASE` in the -generated secret dictionaries: - -- `react-on-rails-migration-example-staging-secrets` -- `react-on-rails-migration-example-review-secrets` -- `react-on-rails-migration-example-production-secrets` - -`cpflow setup-app` creates those dictionaries automatically. You only need to -add a `SECRET_KEY_BASE` entry to each one before the first deploy. - -Review apps run pull request code. Values mounted through `cpln://secret/...` -can be read by that code after the workload starts, so keep the review secret -dictionary limited to generated, review-only values. Do not reuse production or -long-lived staging secret dictionaries for review apps. - -## Local cpflow Flow - -Typical setup: +Bootstrap the persistent staging and production apps before their first deploy: ```sh -export APP_NAME=react-on-rails-migration-example-staging - -cpflow setup-app -a "$APP_NAME" -cpflow build-image -a "$APP_NAME" -cpflow deploy-image -a "$APP_NAME" --run-release-phase -cpflow open -a "$APP_NAME" +cpflow setup-app \ + -a react-on-rails-migration-example-staging \ + --org "$CPLN_ORG_STAGING" \ + --skip-post-creation-hook + +cpflow setup-app \ + -a react-on-rails-migration-example-production \ + --org "$CPLN_ORG_PRODUCTION" \ + --skip-post-creation-hook ``` -## GitHub Actions Variables And Secrets - -Set these in GitHub before enabling the generated `cpflow-*` workflows: - -- `CPLN_TOKEN_STAGING` -- `CPLN_TOKEN_PRODUCTION` -- `CPLN_ORG_STAGING` -- `CPLN_ORG_PRODUCTION` -- `STAGING_APP_NAME=react-on-rails-migration-example-staging` -- `PRODUCTION_APP_NAME=react-on-rails-migration-example-production` -- `REVIEW_APP_PREFIX=react-on-rails-migration-example-review` +Add `SECRET_KEY_BASE` to each generated app secret dictionary. Use disposable, +review-safe values for review apps because pull-request code can read mounted +secrets. For later template changes, run `cpflow apply-template` and ensure the +app identity can `reveal` the app secret policy. -Optional: +## GitHub Configuration -- `STAGING_APP_BRANCH=main` -- `PRIMARY_WORKLOAD=rails` +The normal review-app path needs only the repository secret +`CPLN_TOKEN_STAGING`; the review prefix and staging organization are inferred +from `.controlplane/controlplane.yml`. Set `STAGING_APP_NAME` to +`react-on-rails-migration-example-staging` for automatic staging deploys. -Use a staging/review `CPLN_TOKEN_STAGING` that cannot access production Control -Plane resources. In public repositories, review-app deploys skip fork PR heads -because Docker builds use repository secrets. If a forked change needs a review -app, first move the reviewed change to a trusted branch in this repository. +Create a protected `production` GitHub Environment with required reviewers and +self-review disabled. Store `CPLN_TOKEN_PRODUCTION` only as an Environment +secret, and set `CPLN_ORG_PRODUCTION` and `PRODUCTION_APP_NAME` there as +Environment variables. Do not create a repository or organization secret named +`CPLN_TOKEN_PRODUCTION`. diff --git a/.controlplane/release_script.sh b/.controlplane/release_script.sh index 0c5c5a8..3f94bf6 100755 --- a/.controlplane/release_script.sh +++ b/.controlplane/release_script.sh @@ -1,5 +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 -./bin/rails db:prepare + +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/.controlplane/templates/app.yml b/.controlplane/templates/app.yml index 8af89c1..f8dd6d3 100644 --- a/.controlplane/templates/app.yml +++ b/.controlplane/templates/app.yml @@ -1,5 +1,5 @@ kind: gvc -name: {{APP_NAME}} +name: "{{APP_NAME}}" spec: env: - name: RAILS_ENV @@ -8,11 +8,10 @@ spec: value: "true" - name: RAILS_SERVE_STATIC_FILES value: "true" - # Review apps run pull request code. Values mounted through cpln://secret - # become readable by that code after the workload starts, so review-app - # secret dictionaries must contain review-safe values. + # Review apps execute pull request code, so only mount review-safe values + # through this generated secret dictionary. - name: SECRET_KEY_BASE - value: cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE + value: "cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE" staticPlacement: locationLinks: - - {{APP_LOCATION_LINK}} + - "{{APP_LOCATION_LINK}}" diff --git a/.controlplane/templates/rails.yml b/.controlplane/templates/rails.yml index b1b3197..38151ce 100644 --- a/.controlplane/templates/rails.yml +++ b/.controlplane/templates/rails.yml @@ -6,10 +6,14 @@ spec: - name: rails cpu: 300m inheritEnv: true - image: {{APP_IMAGE_LINK}} + image: "{{APP_IMAGE_LINK}}" memory: 512Mi ports: - number: 3000 + # Keep this as `http` even when fronting Rails with Thruster — the Control Plane + # load balancer still serves HTTP/2 to end users. Setting `http2` here causes + # `502 Bad Gateway` with "protocol error". + # See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details. protocol: http volumes: - path: /app/db @@ -22,7 +26,8 @@ spec: autoscaling: minScale: 1 maxScale: 1 - capacityAI: false + metric: disabled + capacityAI: true timeoutSeconds: 60 firewallConfig: external: @@ -30,3 +35,5 @@ spec: - 0.0.0.0/0 outboundAllowCIDR: - 0.0.0.0/0 + # cpflow apply-template replaces {{APP_IDENTITY_LINK}} with the app workload identity link. + identityLink: "{{APP_IDENTITY_LINK}}" diff --git a/.github/actions/cpflow-build-docker-image/action.yml b/.github/actions/cpflow-build-docker-image/action.yml deleted file mode 100644 index e7fbf45..0000000 --- a/.github/actions/cpflow-build-docker-image/action.yml +++ /dev/null @@ -1,92 +0,0 @@ -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 - -outputs: - image_tag: - description: Fully qualified image tag - value: ${{ steps.build.outputs.image_tag }} - -runs: - using: composite - steps: - - name: Build Docker image - id: build - shell: bash - run: | - set -euo pipefail - - PR_INFO="" - docker_build_args=() - - if [[ -n "${{ inputs.pr_number }}" ]]; then - PR_INFO=" for PR #${{ inputs.pr_number }}" - fi - - if [[ -n "${{ inputs.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 <<< "${{ inputs.docker_build_extra_args }}" - fi - - if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then - mkdir -p ~/.ssh - chmod 700 ~/.ssh - - if [[ -n "${{ inputs.docker_build_ssh_known_hosts }}" ]]; then - cat <<'EOF' > ~/.ssh/known_hosts -${{ inputs.docker_build_ssh_known_hosts }} -EOF - else - cat <<'EOF' > ~/.ssh/known_hosts -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= -EOF - fi - - chmod 600 ~/.ssh/known_hosts - - eval "$(ssh-agent -s)" - trap 'ssh-agent -k >/dev/null' EXIT - ssh-add - <<< "${{ inputs.docker_build_ssh_key }}" - docker_build_args+=("--ssh=default") - fi - - echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." - cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}" "${docker_build_args[@]}" - - image_tag="${{ inputs.org }}/${{ inputs.app_name }}:${{ inputs.commit }}" - echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" - echo "✅ Docker image build successful${PR_INFO} (commit ${{ inputs.commit }})" diff --git a/.github/actions/cpflow-delete-control-plane-app/action.yml b/.github/actions/cpflow-delete-control-plane-app/action.yml deleted file mode 100644 index 63981dd..0000000 --- a/.github/actions/cpflow-delete-control-plane-app/action.yml +++ /dev/null @@ -1,24 +0,0 @@ -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/.github/actions/cpflow-delete-control-plane-app/delete-app.sh b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh deleted file mode 100755 index 6b70722..0000000 --- a/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/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" -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/.github/actions/cpflow-setup-environment/action.yml b/.github/actions/cpflow-setup-environment/action.yml deleted file mode 100644 index 57b5f3f..0000000 --- a/.github/actions/cpflow-setup-environment/action.yml +++ /dev/null @@ -1,70 +0,0 @@ -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 - required: false - default: "3.4.6" - cpln_cli_version: - description: "@controlplane/cli version" - required: false - default: "3.3.1" - cpflow_version: - description: cpflow gem version - required: false - default: "4.2.0" - -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 - run: | - set -euo pipefail - - TOKEN="${{ inputs.token }}" - ORG="${{ inputs.org }}" - - if [[ -z "$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 --token "$TOKEN" --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" --token "$TOKEN" - cpln image docker-login --org "$ORG" diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md new file mode 100644 index 0000000..50511ec --- /dev/null +++ b/.github/cpflow-help.md @@ -0,0 +1,192 @@ +# Review App Help + +You asked for review app help. +These commands are generated by [cpflow](https://github.com/shakacode/control-plane-flow). + +## Review App Commands + +Comment with exactly one command, with no surrounding text or trailing spaces. +A single trailing newline from GitHub's comment editor is accepted. + +| Command | What it does | +| --- | --- | +| `+review-app-deploy` | Builds the PR image, creates the review app if needed, deploys, and comments with the review URL. | +| `+review-app-delete` | Deletes the review app. This also runs automatically when the PR closes. | +| `+review-app-help` | Posts this help message on the PR. | + +For complete setup, version-pinning, and troubleshooting guidance, see the upstream +[CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v5.3.0/docs/ci-automation.md). + +
+GitHub Actions setup options + +## GitHub Actions Secrets + +For the normal generated review-app path, GitHub Actions needs one secret: + +| Name | Where | Notes | +| --- | --- | --- | +| `CPLN_TOKEN_STAGING` | GitHub Actions secret | Service-account token scoped to the staging Control Plane org on controlplane.com. | + +For public repositories, use a staging/review token that cannot access +production Control Plane resources. Generated review-app deploys skip fork PR +heads because Docker builds use repository secrets. If a forked change needs a +review app, first move the reviewed change to a trusted branch in this +repository. + +## GitHub Actions Variables + +No GitHub Actions variables are required for the standard review-app path when +`.controlplane/controlplane.yml` has exactly one review app entry with +`match_if_app_name_starts_with: true`. cpflow infers the review-app prefix and +staging org from that config. + +Review apps run pull request code. Any value mounted through +`cpln://secret/...` can be read by that code after the workload starts, so keep +review-app secret dictionaries limited to disposable databases, review-only +renderer credentials, and license values that are acceptable for review-app +exposure. + +For public demos, starter staging apps, and long-lived review apps, keep the app +workload `type: standard` with one warm replica, set its autoscaling metric to +`disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and +memory allocation at that fixed replica count. Shared Postgres and other +stateful workloads are the usual exceptions and should stay manually sized; +Capacity AI is for supported stateless app/service workloads. If true idle +scale-to-zero is explicitly required, create a separate `serverless` workload +before the first deploy or plan a delete/recreate migration because Control +Plane will not change an existing `standard` workload to `serverless` in place. + +Optional overrides exist for forks, clones, and unusual apps: + +| Name | Notes | +| --- | --- | +| `CPLN_ORG_STAGING` | Control Plane org on controlplane.com for staging and review apps. Overrides the org inferred from `controlplane.yml`. | +| `REVIEW_APP_PREFIX` | Override the review-app prefix inferred from `controlplane.yml`. | +| `PRIMARY_WORKLOAD` | Public workload used for review URLs and health checks; defaults to `rails`. | + +
+ +## Staging And Production + +Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`. +Before the first staging deploy, bootstrap the persistent staging app once: + +```sh +cpflow setup-app -a "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" --skip-post-creation-hook +``` + +`setup-app` reads `.controlplane/controlplane.yml`'s `setup_app_templates` and +creates the app identity, app secret dictionary, app secret policy, policy +binding, and template resources. Use `--skip-post-creation-hook` so first-time +bootstrap does not try to run database setup before an image exists. For later +template updates on an existing persistent app, use `cpflow apply-template` +with the same template list and make sure the app identity has `reveal` +permission on the app secret policy. + +Review apps are temporary and are created by the `+review-app-deploy` workflow, +but staging and production are persistent apps and should be bootstrapped +explicitly. + +Production promotion is part of the generated flow, but keep it protected: + +| Name | Where | Notes | +| --- | --- | --- | +| `CPLN_TOKEN_PRODUCTION` | `production` GitHub Environment secret | Do not store this as a repository or organization secret. | +| `CPLN_ORG_PRODUCTION` | Prefer `production` Environment variable | Production Control Plane org. | +| `PRODUCTION_APP_NAME` | Prefer `production` Environment variable | Production app name from `controlplane.yml`. | + +Configure the `production` GitHub Environment with required reviewers and +prevent self-review. Production promotion intentionally runs as a normal +caller-repo workflow job with `environment: production`, then checks out the +pinned `control-plane-flow` release for shared actions. Do not move production +promotion behind a cross-repo reusable workflow: GitHub does not expose this +repo's environment secrets to that called workflow. + +Keep `CPLN_TOKEN_PRODUCTION` absent from repository and organization secrets. A +normal environment-gated job cannot tell which secret scope supplied a nonempty +value, so a broader secret with the same name can mask a missing environment +secret. + +If the promotion workflow fails with +`CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the 'production' GitHub Environment.`, +the token is missing from the environment scope or the workflow job is no longer +declaring `environment: production`. Create or verify the environment secret +and confirm there is no same-named repository or organization secret: +You need permission to manage repository environments and secrets to run these +commands. + +```sh +gh secret set CPLN_TOKEN_PRODUCTION --repo OWNER/REPO --env production +# Paste the token value when prompted. +gh secret list --repo OWNER/REPO --env production +gh secret list --repo OWNER/REPO +gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true +``` + +Before the first promotion, bootstrap the production app the same way in the +production org, using production-only secrets and values. + +
+Version locking and advanced options + +## Version Locking + +Generated wrappers pin Control Plane Flow with a release tag, for example +`v5.3.0`. Reusable review-app, staging, cleanup, and +helper workflows pin the tag in their `uses:` ref. Production promotion pins +the same tag in the `Checkout control-plane-flow actions` step so the +caller-owned job can keep `environment: production` and receive production +environment secrets directly. + +Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same +checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the +release tag your wrappers are pinned to: a `CPFLOW_VERSION=5.3.x` runtime +override goes with a wrapper pinned to `uses: ...@v5.3.x` (substitute the +release you pinned above). + +After updating the `cpflow` gem in this repo, update the generated wrappers in +the same PR: + +```sh +cpflow update-github-actions +bin/test-cpflow-github-flow +``` + +If `cpflow` is bundled by the app, use: + +```sh +bundle exec cpflow update-github-actions +bin/test-cpflow-github-flow bundle exec cpflow +``` + +Do not leave downstream apps pinned to a moving branch such as `main`. For a +short-lived test of an unreleased upstream PR, pin to a full 40-character commit +SHA and leave `CPFLOW_VERSION` unset: + +```sh +bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha> +bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow +``` + +## Advanced GitHub Actions Variables + +Most apps do not need these: + +| Name | Notes | +| --- | --- | +| `DOCKER_BUILD_EXTRA_ARGS` | Newline-delimited extra Docker build tokens. | +| `DOCKER_BUILD_SSH_KEY` | Read-only, revocable deploy key for Docker builds that fetch private dependencies. Do not use a personal SSH key. | +| `DOCKER_BUILD_SSH_KNOWN_HOSTS` | SSH known_hosts entries when SSH build hosts are not GitHub.com. | +| `REVIEW_APP_DEPLOYING_ICON_URL` | Cosmetic custom image URL for the animated deploying icon. Set to `none` to use the text fallback icon. | +| `STAGING_APP_BRANCH` | Custom staging branch. The branch must also appear in `cpflow-deploy-staging.yml`'s push filter. | +| `CPLN_CLI_VERSION` | Pin a specific `@controlplane/cli` version; normally leave unset. | + +The PR-open help workflow posts a short command reference whenever the generated +wrapper exists. That is intentional for configured demo repos. Forks or clones +that copy the workflow before configuring Control Plane can remove +`.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the +wrapper-level `if:` guard shown in that file, for example +`vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''`. + +
diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml index d8dc5a9..6f1fd42 100644 --- a/.github/workflows/cpflow-cleanup-stale-review-apps.yml +++ b/.github/workflows/cpflow-cleanup-stale-review-apps.yml @@ -8,40 +8,10 @@ on: 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 + # Cleanup targets the current inferred review-app prefix. If you changed + # naming conventions, manually delete review apps under the old prefix. + uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + secrets: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml index 7a29629..70c0e1f 100644 --- a/.github/workflows/cpflow-delete-review-app.yml +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -1,5 +1,7 @@ name: Delete Review App +run-name: "Delete Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" + on: pull_request_target: types: [closed] @@ -11,118 +13,34 @@ on: description: Pull request number targeted for deletion required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write 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 }} - jobs: delete-review-app: + # pull_request_target is intentional: fork PR-close events need access to + # staging secrets to delete review apps and update PR comments. The upstream + # reusable workflow checks out trusted base-branch action code, not fork code. + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && - github.event.comment.body == '/delete-review-app' && + contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body) && 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 - }); + # This `if:` mirrors the upstream job guard to avoid a billable workflow_call + # when the event does not match. Keep both conditions in sync. + uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + secrets: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml index e662b80..20bb103 100644 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -4,7 +4,7 @@ run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || githu on: pull_request: - types: [synchronize, reopened] + types: [opened, synchronize, reopened] issue_comment: types: [created] workflow_dispatch: @@ -13,370 +13,33 @@ on: description: Pull request number to deploy required: true type: number + reconcile_intent_run_id: + description: Authenticated internal handoff; manual values are rejected + required: false + type: string permissions: + actions: write 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: + # The reusable job exposes `image_built`; downstream jobs can read + # `needs.deploy.outputs.image_built`. A value of `false` means this check did not validate the Docker image. deploy: + # author_association is a cheap caller-side cost filter. The reusable workflow + # still checks the commenter's current repository permission before privileged work. if: | - github.event_name == 'pull_request' || + (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('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) && 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 - - if cpflow config -a "${APP_NAME}" | grep -q "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') - shell: bash - run: | - set -euo pipefail - cpflow deploy-image -a "${APP_NAME}" ${{ steps.release-phase.outputs.flag }} --org "${CPLN_ORG}" --verbose - - - 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 - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + secrets: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index 00fbe80..cbeccb0 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -4,120 +4,21 @@ run-name: Deploy Control Plane staging app on: push: - # GitHub does not allow repository vars in branch filters, so this workflow listens to - # every branch and exits early unless the ref matches STAGING_APP_BRANCH/main/master. + # GitHub does not allow repository vars in branch filters. Default to the common + # deploy branches unless `cpflow generate-github-actions --staging-branch BRANCH` + # was used. If STAGING_APP_BRANCH is later changed in repository variables, keep + # this list in sync so pushes to that branch actually trigger the workflow. branches: ["**"] 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 - - if cpflow config -a "${APP_NAME}" | grep -q "release_script:"; then - echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" - else - echo "flag=" >> "$GITHUB_OUTPUT" - fi - - - name: Deploy staging image - shell: bash - run: | - set -euo pipefail - cpflow deploy-image -a "${APP_NAME}" ${{ steps.release-phase.outputs.flag }} --org "${CPLN_ORG}" --verbose + deploy-staging: + uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + with: + staging_app_branch_default: "**" + secrets: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml index 1430886..59358f0 100644 --- a/.github/workflows/cpflow-help-command.yml +++ b/.github/workflows/cpflow-help-command.yml @@ -11,6 +11,7 @@ on: type: number permissions: + contents: read issues: write pull-requests: write @@ -19,64 +20,7 @@ jobs: if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request && - github.event.comment.body == '/help' && + contains(fromJson('["+review-app-help","+review-app-help\n","+review-app-help\r\n"]'), github.event.comment.body) && 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 - }); + uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index 5e1f93a..7c70781 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -9,115 +9,360 @@ on: type: string permissions: - contents: write + contents: read env: - HEALTH_CHECK_RETRIES: 12 - HEALTH_CHECK_INTERVAL: 10 - PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + # Override these by editing this file or by setting the matching repository variable. + # Worst-case wall time per attempt is HEALTH_CHECK_INTERVAL plus the curl --max-time below + # (10s), so the defaults give a ~10 minute window (24 × (15 + 10) = 600s) — enough for + # most Rails cold boots (asset precompile + db:migrate + workload readiness). + HEALTH_CHECK_RETRIES: ${{ vars.HEALTH_CHECK_RETRIES || '24' }} + HEALTH_CHECK_INTERVAL: ${{ vars.HEALTH_CHECK_INTERVAL || '15' }} + # Space-separated list of HTTP statuses considered healthy. The default accepts 301/302 + # because `curl` is invoked without `-L`, so a root `/` that redirects to a login page + # (common for Rails apps that auth-gate `/`) would otherwise be reported as unhealthy + # despite the workload itself being up. + # + # Strongly recommended: expose a dedicated `/health` endpoint that returns `200` and set + # HEALTH_CHECK_ACCEPTED_STATUSES to `"200"` in repository variables. The 301/302 default + # trades correctness for ergonomics — a maintenance-mode redirect or an auth-gate redirect + # to a login page can pass this check even when the underlying app is broken. Override + # via the HEALTH_CHECK_ACCEPTED_STATUSES repo variable to tighten this for apps that + # expose a dedicated health endpoint (e.g. "200" for a plain /health, or "200 401 403" + # for apps that auth-gate / without redirecting). + HEALTH_CHECK_ACCEPTED_STATUSES: ${{ vars.HEALTH_CHECK_ACCEPTED_STATUSES || '200 301 302' }} + COPY_IMAGE_RETRIES: ${{ vars.COPY_IMAGE_RETRIES || '3' }} + COPY_IMAGE_RETRY_INTERVAL: ${{ vars.COPY_IMAGE_RETRY_INTERVAL || '20' }} + ROLLBACK_READINESS_RETRIES: ${{ vars.ROLLBACK_READINESS_RETRIES || '24' }} + ROLLBACK_READINESS_INTERVAL: ${{ vars.ROLLBACK_READINESS_INTERVAL || '15' }} + +concurrency: + # Single global group: only one production promotion may run at a time across the + # whole repo. Independent of staging deploys and review-app workflows (different + # GVCs / different concurrency keys), so those can still run in parallel. + group: cpflow-promote-staging-to-production + # Don't cancel an in-flight promotion: a half-finished `cpflow deploy-image` plus a + # rollback can leave production in a worse state than letting the first run finish. + cancel-in-progress: false jobs: promote-to-production: if: github.event.inputs.confirm_promotion == 'promote' runs-on: ubuntu-latest + # This normal caller-repo job declares the protected production Environment + # directly, so GitHub exposes environment secrets in this job after the + # environment gate. Do not move production promotion back behind a + # cross-repo reusable workflow; environment secrets are not available there. + environment: production + timeout-minutes: 45 + outputs: + staging_app_name: ${{ steps.release-context.outputs.staging_app_name }} + production_app_name: ${{ steps.release-context.outputs.production_app_name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - - name: Validate required secrets and variables + - name: Checkout control-plane-flow actions + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + repository: shakacode/control-plane-flow + ref: b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + path: .cpflow + persist-credentials: false + + - name: Validate production token shell: bash + env: + # GitHub does not expose which secret scope supplied this value. + # Keep CPLN_TOKEN_PRODUCTION absent from repo/org secrets so the + # protected production Environment is the only configured source. + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + PRODUCTION_ENVIRONMENT: production 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 + if [[ -z "${CPLN_TOKEN_PRODUCTION}" ]]; then + echo "::error::CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the '${PRODUCTION_ENVIRONMENT}' GitHub Environment." exit 1 fi - - name: Resolve production app workloads - id: workloads + - name: Validate required secrets and variables + uses: ./.cpflow/.github/actions/cpflow-validate-config + # Pass secrets via env so the composite action checks indirect shell + # variables instead of interpolating secret values into a run script. + env: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + with: + required: | + secret:CPLN_TOKEN_STAGING + secret:CPLN_TOKEN_PRODUCTION + variable:CPLN_ORG_STAGING + variable:CPLN_ORG_PRODUCTION + variable:STAGING_APP_NAME + variable:PRODUCTION_APP_NAME + + - name: Normalize Control Plane org names + id: cpln-orgs + env: + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} shell: bash run: | set -euo pipefail - workloads="$( - ruby -e 'require "yaml"; app = ARGV.fetch(0); data = YAML.load_file(".controlplane/controlplane.yml", aliases: true); app_config = data.fetch("apps").fetch(app); workloads = Array(app_config["app_workloads"]); workloads = ["rails"] if workloads.empty?; puts workloads.join(",")' "${{ vars.PRODUCTION_APP_NAME }}" - )" + sanitize_control_plane_name() { + local label="$1" + local value="$2" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then + echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2 + exit 1 + fi - echo "names=${workloads}" >> "$GITHUB_OUTPUT" + printf '%s' "${value}" + } + + validate_control_plane_org() { + local label="$1" + local value="$2" + + if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + local display_value + display_value="$(printf '%q' "${value}")" + echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen." >&2 + exit 1 + fi + } + + staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")" + production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")" + + validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}" + validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}" + + { + echo "staging=${staging_org}" + echo "production=${production_org}" + } >> "$GITHUB_OUTPUT" + + - name: Capture release context + id: release-context + env: + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + { + echo "staging_app_name=${STAGING_APP_NAME}" + echo "production_app_name=${PRODUCTION_APP_NAME}" + } >> "$GITHUB_OUTPUT" - name: Setup production environment - uses: ./.github/actions/cpflow-setup-environment + uses: ./.cpflow/.github/actions/cpflow-setup-environment with: token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} - - - name: Detect release phase support - id: release-phase + org: ${{ steps.cpln-orgs.outputs.production }} + working_directory: .cpflow + cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} + cpflow_version: ${{ vars.CPFLOW_VERSION }} + # The setup action validates CPFLOW_VERSION against this full workflow ref. + control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 + + # 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 }} + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} shell: bash run: | set -euo pipefail - if cpflow config -a "${{ vars.PRODUCTION_APP_NAME }}" | grep -q "release_script:"; then - echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" - else - echo "flag=" >> "$GITHUB_OUTPUT" - fi + ruby - "${PRODUCTION_APP_NAME}" "${PRIMARY_WORKLOAD}" "${GITHUB_OUTPUT}" <<'RUBY' + require "yaml" + + app = ARGV.fetch(0) + requested_primary = ARGV.fetch(1, "").to_s.strip + output_path = ARGV.fetch(2) + data = YAML.safe_load(File.read(".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"]).map(&:to_s).reject(&:empty?) + workloads = ["rails"] if workloads.empty? + + primary = + if requested_primary.empty? + if workloads.length == 1 + workloads.first + elsif workloads.include?("rails") + "rails" + else + puts "::error::PRIMARY_WORKLOAD is not configured and app '#{app}' has multiple workloads: #{workloads.join(', ')}." + warn " Set the PRIMARY_WORKLOAD repository variable to one of these workloads." + exit 1 + end + elsif workloads.include?(requested_primary) + requested_primary + else + puts "::error::PRIMARY_WORKLOAD '#{requested_primary}' is not one of: #{workloads.join(', ')}." + exit 1 + end + + File.open(output_path, "a") do |output| + output.puts "names=#{workloads.join(',')}" + output.puts "primary=#{primary}" + end + RUBY + + - name: Detect release phase support + id: release-phase + uses: ./.cpflow/.github/actions/cpflow-detect-release-phase + with: + app_name: ${{ vars.PRODUCTION_APP_NAME }} - 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: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} shell: bash run: | set -euo pipefail - staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${{ vars.STAGING_APP_NAME }}" --org "${{ vars.CPLN_ORG_STAGING }}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" - production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.CPLN_ORG_PRODUCTION }}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" - missing_vars="$(comm -23 <(echo "${staging_vars}") <(echo "${production_vars}"))" + list_gvc_env_names() { + local token="$1" + local org="$2" + local app="$3" + + CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json | + jq -r '.spec.env // [] | .[] | .name // empty' | + sort -u + } + + list_workload_env_names() { + local token="$1" + local org="$2" + local app="$3" + local workload="$4" + + CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json | + jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' | + sort -u + } + + check_required_vars() { + local staging_scope="$1" + local production_scope="$2" + local missing_message="$3" + local staging_vars="$4" + local production_vars="$5" + local missing_vars + local production_only_vars + + if [[ -z "${staging_vars}" ]]; then + echo "Staging ${staging_scope} exposes no environment variables; skipping parity check." + return + fi - if [[ -n "${missing_vars}" ]]; then - echo "::error::Production is missing environment variables that exist in staging" - echo "${missing_vars}" - exit 1 - fi + # Treat staging as the promotion source of truth: fail when a variable + # present in staging is missing in production. Production-only variables + # are allowed, but surface them so teams can spot drift. + missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + + if [[ -n "${production_only_vars}" ]]; then + echo "::warning::Production ${production_scope} has environment variables that are not present in staging:" + echo "${production_only_vars}" + fi + + if [[ -n "${missing_vars}" ]]; then + echo "::error::${missing_message}" + echo "${missing_vars}" + env_check_failed=1 + fi + } + + # check_required_vars intentionally mutates env_check_failed in this + # shell; keep calls outside subshells so failures aggregate before the + # final exit. + env_check_failed=0 + + staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")" + production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")" + check_required_vars \ + "GVC '${STAGING_APP_NAME}'" \ + "GVC '${PRODUCTION_APP_NAME}'" \ + "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \ + "${staging_vars}" \ + "${production_vars}" + + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")" + production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")" + check_required_vars \ + "workload '${workload_name}'" \ + "workload '${workload_name}'" \ + "Production workload '${workload_name}' is missing environment variables that exist in staging" \ + "${staging_workload_vars}" \ + "${production_workload_vars}" + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + exit "${env_check_failed}" - name: Capture current production image id: capture-current + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} + PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash run: | set -euo pipefail - selected_workload="${PRIMARY_WORKLOAD:-}" + selected_workload="${PRIMARY_WORKLOAD}" selected_image="" selected_version="" - first_image="" - first_version="" rollback_state='{}' + # Validate all workloads have images, then promote the primary workload's + # image as the canonical image for this GVC. while IFS= read -r workload_name; do [[ -n "${workload_name}" ]] || continue - workload_json="$(cpln workload get "${workload_name}" --gvc "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.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_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 // empty')" + workload_containers="$(echo "${workload_json}" | jq -c '.spec.containers | map({name, 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 + if [[ "${workload_name}" == "${selected_workload}" ]]; then selected_image="${workload_image}" selected_version="${workload_version}" fi @@ -127,117 +372,412 @@ jobs: --arg workload "${workload_name}" \ --arg image "${workload_image}" \ --arg version "${workload_version}" \ - --argjson images "${workload_images}" \ - '. + {($workload): {image: $image, version: $version, images: $images}}' \ + --argjson containers "${workload_containers}" \ + '. + {($workload): {image: $image, version: $version, containers: $containers}}' \ <<< "${rollback_state}" )" - done < <(tr ',' '\n' <<< "${{ steps.workloads.outputs.names }}") + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - current_image="${selected_image:-${first_image}}" - current_version="${selected_version:-${first_version}}" + if [[ -z "${selected_image}" || -z "${selected_version}" ]]; then + echo "::error::Could not capture current image/version for primary workload '${selected_workload}'." >&2 + exit 1 + fi - echo "current_image=${current_image}" >> "$GITHUB_OUTPUT" - echo "current_version=${current_version}" >> "$GITHUB_OUTPUT" + echo "current_image=${selected_image}" >> "$GITHUB_OUTPUT" + echo "current_version=${selected_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<> "$GITHUB_OUTPUT" - - name: Copy image from staging + - name: Capture deployed staging image + id: staging-image + env: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} + PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} shell: bash run: | set -euo pipefail - cpflow copy-image-from-upstream -a "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.CPLN_ORG_PRODUCTION }}" -t "${{ secrets.CPLN_TOKEN_STAGING }}" - - name: Deploy image to production - shell: bash - run: | - set -euo pipefail - cpflow deploy-image -a "${{ vars.PRODUCTION_APP_NAME }}" ${{ steps.release-phase.outputs.flag }} --org "${{ vars.CPLN_ORG_PRODUCTION }}" --verbose + selected_workload="${PRIMARY_WORKLOAD}" + selected_image="" - - name: Wait for deployment health - id: health-check + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + workload_json="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln workload get "${workload_name}" --gvc "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json)" + workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image // empty')" + + if [[ -z "${workload_image}" ]]; then + echo "::error::Could not find an image on staging workload '${workload_name}'." >&2 + exit 1 + fi + + if [[ "${workload_name}" == "${selected_workload}" ]]; then + selected_image="${workload_image}" + fi + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + staging_image_ref="${selected_image}" + if [[ -z "${staging_image_ref}" ]]; then + echo "::error::Could not determine the deployed staging image for primary workload '${selected_workload}'." >&2 + exit 1 + fi + + if [[ "${staging_image_ref}" == /org/*/image/* ]]; then + staging_image="${staging_image_ref##*/image/}" + elif [[ "${staging_image_ref}" == *.registry.cpln.io/* ]]; then + staging_image="${staging_image_ref#*.registry.cpln.io/}" + else + staging_image="${staging_image_ref}" + fi + + echo "image=${staging_image}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + + - name: Copy image from staging + id: copy-image + env: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} + STAGING_IMAGE: ${{ steps.staging-image.outputs.image }} shell: bash run: | set -euo pipefail - workload_name="${PRIMARY_WORKLOAD:-rails}" + if ! [[ "${COPY_IMAGE_RETRIES}" =~ ^[0-9]+$ ]]; then + echo "::error::COPY_IMAGE_RETRIES must be a non-negative integer." + exit 1 + fi + + if ! [[ "${COPY_IMAGE_RETRY_INTERVAL}" =~ ^[0-9]+$ ]]; then + echo "::error::COPY_IMAGE_RETRY_INTERVAL must be a non-negative integer." + exit 1 + fi - for attempt in $(seq 1 "${HEALTH_CHECK_RETRIES}"); do - echo "Health check attempt ${attempt}/${HEALTH_CHECK_RETRIES}" + copy_image_retries=$((10#${COPY_IMAGE_RETRIES})) + copy_image_attempts=$((copy_image_retries + 1)) + copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL})) - endpoint="$(cpln workload get "${workload_name}" --gvc "${{ vars.PRODUCTION_APP_NAME }}" --org "${{ vars.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}" + staging_image="${STAGING_IMAGE}" + if [[ -z "${staging_image}" ]]; then + echo "::error::STAGING_IMAGE is not set or is empty." + exit 1 + fi - if [[ "${http_status}" == "200" || "${http_status}" == "301" || "${http_status}" == "302" ]]; then - echo "healthy=true" >> "$GITHUB_OUTPUT" - exit 0 - fi + if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then + echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion." + exit 1 + fi + + staging_tag="" + if [[ "${staging_image}" == *@* ]]; then + staging_tag="${staging_image##*@}" + elif [[ "${staging_image}" == *:* ]]; then + staging_tag="${staging_image##*:}" + fi + staging_commit="" + if [[ "${staging_tag}" == *_* ]]; then + staging_commit="${staging_tag##*_}" + else + echo "::warning::Staging image '${staging_image}' did not include a '_' suffix; production image tag will omit the commit suffix." + fi + + # The workflow-level concurrency group serializes this sequence so two + # production promotions cannot derive and publish the same next tag. + # See the top-level concurrency group: cpflow-promote-staging-to-production. + latest_number="$( + cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json | + jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \ + '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?[0-9]+)") catch empty) | .number | tonumber] | max // 0' + )" + if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then + echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'." + exit 1 + fi + + production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))" + if [[ -n "${staging_commit}" ]]; then + production_image="${production_image}_${staging_commit}" + fi + + staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io" + production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io" + source_image_ref="${staging_registry}/${STAGING_IMAGE}" + production_image_ref="${production_registry}/${production_image}" + + docker_config_dir="$(mktemp -d)" + cleanup_copy_credentials() { + rm -rf "${docker_config_dir}" + } + trap cleanup_copy_credentials EXIT + + export DOCKER_CONFIG="${docker_config_dir}" + + if ! printf '%s' "${CPLN_TOKEN_STAGING}" | + docker login "${staging_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to staging registry '${staging_registry}'." + exit 1 + fi + + if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" | + docker login "${production_registry}" -u '' --password-stdin >/dev/null; then + echo "::error::Failed to authenticate to production registry '${production_registry}'." + exit 1 + fi + + if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then + echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it." + exit 1 + fi + + copy_status=1 + for attempt in $(seq 1 "${copy_image_attempts}"); do + if docker buildx imagetools inspect "${source_image_ref}" >/dev/null && + docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then + copy_status=0 + break + else + copy_status=$? fi - if [[ "${attempt}" -lt "${HEALTH_CHECK_RETRIES}" ]]; then - sleep "${HEALTH_CHECK_INTERVAL}" + if [[ "${attempt}" -lt "${copy_image_attempts}" ]]; then + echo "::warning::Image copy attempt ${attempt}/${copy_image_attempts} failed with exit ${copy_status}; retrying in ${copy_image_retry_interval}s." + sleep "${copy_image_retry_interval}" + else + echo "::warning::Image copy attempt ${attempt}/${copy_image_attempts} failed with exit ${copy_status}; no attempts remain." fi done - echo "healthy=false" >> "$GITHUB_OUTPUT" - exit 1 + if [[ "${copy_status}" -ne 0 ]]; then + echo "::error::Could not copy staging image '${STAGING_IMAGE}' from '${CPLN_ORG_STAGING}' to '${CPLN_ORG_PRODUCTION}' after ${copy_image_attempts} attempt(s)." + exit "${copy_status}" + fi + + echo "image=${production_image}" >> "$GITHUB_OUTPUT" + + - name: Deploy image to production + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.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 + # `cpflow deploy-image` deploys the latest image for the app. The + # workflow-level concurrency group keeps production promotion copy and + # deploy steps coupled across workflow runs. + deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" + + - name: Wait for deployment health + id: health-check + uses: ./.cpflow/.github/actions/cpflow-wait-for-health + with: + workload_name: ${{ steps.workloads.outputs.primary }} + app_name: ${{ vars.PRODUCTION_APP_NAME }} + org: ${{ steps.cpln-orgs.outputs.production }} + max_retries: ${{ env.HEALTH_CHECK_RETRIES }} + interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }} + accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} - name: Roll back on failure - if: failure() && steps.capture-current.outputs.rollback_state != '{}' + if: failure() && steps.capture-current.outcome == 'success' env: ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | - set -euo pipefail + # 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. + # Keep -e disabled here so rollback can aggregate failures across workloads. + set -uo pipefail + + rollback_failures=0 + if ! rollback_entries="$(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.containers | @json)"')"; then + echo "::error::Could not parse rollback state; manual recovery may be required." >&2 + exit 1 + fi - while IFS=$'\t' read -r workload_name previous_images; do + while IFS=$'\t' read -r workload_name previous_containers; do rollback_args=() + if ! current_names="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -c '.spec.containers | map(.name)')"; then + echo "::warning::Could not retrieve current containers for workload '${workload_name}'; skipping rollback for this workload." >&2 + rollback_failures=$((rollback_failures + 1)) + continue + fi + if ! previous_names="$(echo "${previous_containers}" | jq -c 'map(.name)')"; then + echo "::warning::Could not parse captured containers for workload '${workload_name}'; skipping rollback for this workload." >&2 + rollback_failures=$((rollback_failures + 1)) + continue + fi + + if [[ "$(echo "${current_names}" | jq -c 'sort')" != "$(echo "${previous_names}" | jq -c 'sort')" ]]; then + echo "::error::Container set changed for workload '${workload_name}'; refusing rollback." >&2 + rollback_failures=$((rollback_failures + 1)) + continue + fi - 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 ! rollback_container_entries="$(jq -r '.[] | "\(.name)\t\(.image)"' <<< "${previous_containers}")"; then + echo "::warning::Could not build rollback image list for workload '${workload_name}'; skipping rollback for this workload." >&2 + rollback_failures=$((rollback_failures + 1)) + continue + fi - cpln workload update "${workload_name}" \ - --gvc "${{ vars.PRODUCTION_APP_NAME }}" \ - --org "${{ vars.CPLN_ORG_PRODUCTION }}" \ - "${rollback_args[@]}" - done < <(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.images | @json)"') + while IFS=$'\t' read -r container_name image; do + rollback_args+=(--set "spec.containers.${container_name}.image=${image}") + done <<< "${rollback_container_entries}" - - name: Create GitHub release - if: success() && steps.health-check.outputs.healthy == 'true' + 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 <<< "${rollback_entries}" + + 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.outcome == 'success' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} shell: bash run: | set -euo pipefail - release_date="$(date '+%Y-%m-%d')" - timestamp="$(date '+%H%M')" - release_tag="production-${release_date}-${timestamp}" + mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') - 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}." + # Poll workloads in parallel so the worst-case wall time during a + # production incident is `retries × interval` rather than scaling + # linearly with the number of workloads. Each per-workload retry + # loop runs in a backgrounded subshell that writes its final state + # to a status file; the parent waits for all of them before + # aggregating warnings, keeping output ordered and deterministic. + status_dir="$(mktemp -d)" + trap 'rm -rf "${status_dir}"' EXIT + + pids=() + for workload_name in "${workloads[@]}"; do + [[ -n "${workload_name}" ]] || continue + status_name="${workload_name//\//_}" + + echo "Polling rollback readiness for workload '${workload_name}'..." + ( + set -euo pipefail + ready=false + for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do + workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" + deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')" + latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')" + if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then + ready=true + break + fi + + if [[ "${attempt}" -lt "${ROLLBACK_READINESS_RETRIES}" ]]; then + sleep "${ROLLBACK_READINESS_INTERVAL}" + fi + done + + if [[ "${ready}" == "true" ]]; then + printf 'ready\n' > "${status_dir}/${status_name}" + else + printf 'not_ready\n' > "${status_dir}/${status_name}" + fi + ) & + pids+=("$!") + done + + # `|| true` so a single workload that fails to poll (e.g. transient + # cpln API error) doesn't abort the parent before the others finish. + # Missing or non-`ready` status files are surfaced in the aggregation + # loop below, so the failure is still visible to operators. + for pid in "${pids[@]}"; do + wait "${pid}" || true + done + + for workload_name in "${workloads[@]}"; do + [[ -n "${workload_name}" ]] || continue + status_name="${workload_name//\//_}" + status_file="${status_dir}/${status_name}" + if [[ ! -f "${status_file}" ]] || [[ "$(<"${status_file}")" != "ready" ]]; then + echo "::warning::Workload '${workload_name}' did not report ready after rollback." + fi + done - 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 }} + COPIED_IMAGE: ${{ steps.copy-image.outputs.image }} shell: bash run: | { echo "## Promotion Summary" echo - if [[ "${{ steps.health-check.outputs.healthy }}" == "true" ]]; then + if [[ "${HEALTHY}" == "true" ]]; then echo "✅ Status: deployment successful" + deployed_image="${COPIED_IMAGE}" else echo "❌ Status: deployment failed" + deployed_image="${PREVIOUS_IMAGE}" fi echo - echo "Previous image: \`${{ steps.capture-current.outputs.current_image }}\`" - echo "Previous version: ${{ steps.capture-current.outputs.current_version }}" + echo "Previous image: \`${PREVIOUS_IMAGE}\`" + echo "Previous version: ${PREVIOUS_VERSION}" + echo "Deployed image: \`${deployed_image}\`" } >> "$GITHUB_STEP_SUMMARY" + + create-github-release: + needs: promote-to-production + if: needs.promote-to-production.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Create GitHub release + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RUN_ID: ${{ github.run_id }} + STAGING_APP_NAME: ${{ needs.promote-to-production.outputs.staging_app_name }} + PRODUCTION_APP_NAME: ${{ needs.promote-to-production.outputs.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}." diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml index e3958cc..b061ca3 100644 --- a/.github/workflows/cpflow-review-app-help.yml +++ b/.github/workflows/cpflow-review-app-help.yml @@ -1,6 +1,10 @@ name: Show Review App Commands on PR Open on: + # pull_request_target is intentional: it has write permission to comment on PRs from + # forks, where `pull_request` would be read-only. This is safe because no PR code is + # checked out — the job only calls the upstream reusable workflow with a hardcoded + # message. pull_request_target: types: [opened] @@ -10,28 +14,8 @@ permissions: 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 - }); + # This is intentionally unconditional: committing this wrapper opts the repo in + # to PR-open help. Remove it, or uncomment and adapt this guard, if forks or + # clones should stay quiet until Control Plane is configured: + # if: vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != '' + uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 diff --git a/Gemfile b/Gemfile index d46b1ee..a095290 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby ">= 3.3.0" +ruby "~> 3.4.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.3" diff --git a/bin/pin-cpflow-github-ref b/bin/pin-cpflow-github-ref new file mode 100755 index 0000000..38e75fe --- /dev/null +++ b/bin/pin-cpflow-github-ref @@ -0,0 +1,88 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "pathname" + +USAGE = <<~USAGE + Usage: bin/pin-cpflow-github-ref [--allow-moving-ref] + + Use a release tag for normal operation, e.g. v5.0.0. + Use a full 40-character commit SHA for temporary unreleased upstream testing. + This updates generated reusable-workflow `uses:` refs plus the production + workflow's pinned control-plane-flow checkout and setup validation ref. + Regenerate from the cpflow gem when templates changed. + Use --allow-moving-ref only for short-lived local branch/ref experiments. +USAGE + +ALLOWED_OPTIONS = ["--allow-moving-ref"].freeze +FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i +RELEASE_TAG = /\Av\d+\.\d+\.\d+(?:[-.][0-9A-Za-z][0-9A-Za-z.-]*)?\z/ +PRODUCTION_WORKFLOW_REF = "shakacode/control-plane-flow/.github/workflows/" \ + "cpflow-promote-staging-to-production.yml" +CPFLOW_CHECKOUT_REF_PATTERN = %r{ + (^\s*-\s+name:\s+Checkout\ control-plane-flow\ actions\s*\n + (?:(?!^\s*-\s+name:).)*? + ^\s+repository:\s+shakacode/control-plane-flow\s*\n + (?:(?!^\s*-\s+name:).)*? + ^\s+ref:\s+) + [^\s]+ +}mx + +options, positional = ARGV.partition { |arg| arg.start_with?("--") } +unknown_options = options - ALLOWED_OPTIONS + +unless unknown_options.empty? + warn "Unknown option(s): #{unknown_options.join(', ')}" + warn USAGE + exit 1 +end + +unless positional.length == 1 + warn USAGE + exit 1 +end + +ref = positional.first +allow_moving_ref = options.include?("--allow-moving-ref") + +unless ref.match?(%r{\A[0-9A-Za-z._/-]+\z}) + warn "Ref contains unsupported characters: #{ref.inspect}" + exit 1 +end + +unless ref.match?(FULL_COMMIT_SHA) || ref.match?(RELEASE_TAG) || allow_moving_ref + warn "Ref must be a full 40-character commit SHA or a v-prefixed release tag: #{ref.inspect}" + warn "Use --allow-moving-ref only for a short-lived branch/ref experiment." + exit 1 +end + +root = Pathname.new(__dir__).join("..").expand_path +workflow_paths = Dir[root.join(".github/workflows/cpflow-*.yml")] + +if workflow_paths.empty? + warn "No cpflow workflow wrappers found." + exit 1 +end + +changed = [] +workflow_paths.each do |path| + text = File.read(path) + production_setup_ref_pattern = + /(control_plane_flow_ref:\s+#{Regexp.escape(PRODUCTION_WORKFLOW_REF)}@)[^\s]+/ + updated = text + .gsub(%r{(uses:\s+shakacode/control-plane-flow/\.github/workflows/[^@\s]+@)[^\s]+}, "\\1#{ref}") + .gsub(production_setup_ref_pattern, "\\1#{ref}") + .gsub(CPFLOW_CHECKOUT_REF_PATTERN, "\\1#{ref}") + + next if updated == text + + File.write(path, updated) + changed << Pathname.new(path).relative_path_from(root).to_s +end + +puts "Pinned cpflow GitHub ref to #{ref}" +if changed.empty? + puts "No files changed." +else + changed.each { |path| puts "updated #{path}" } +end diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow new file mode 100755 index 0000000..70c8c07 --- /dev/null +++ b/bin/test-cpflow-github-flow @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +cpflow_cmd=(cpflow) +if [[ $# -gt 0 ]]; then + cpflow_cmd=("$@") +fi + +echo "==> cpflow github-flow-readiness" +"${cpflow_cmd[@]}" github-flow-readiness + +echo "==> parse generated GitHub Actions YAML" +ruby <<'RUBY' +require "yaml" + +Dir[".github/actions/**/action.yml", ".github/workflows/*.yml"].sort.each do |path| + YAML.load_file(path, aliases: true) + puts "parsed #{path}" +end +RUBY + +echo "==> check composite action input descriptions" +ruby <<'RUBY' +require "yaml" + +bad = [] +Dir[".github/actions/**/action.yml"].sort.each do |path| + doc = YAML.load_file(path, aliases: true) + doc.fetch("inputs", {}).each do |name, spec| + bad << "#{path}:#{name}" if spec["description"].to_s.include?("${{") + end +end + +abort bad.join("\n") unless bad.empty? +puts "no action metadata descriptions contain GitHub expressions" +RUBY + +echo "==> check cpflow reusable workflow refs" +ruby <<'RUBY' +require "yaml" + +CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z} +PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-promote-staging-to-production\.yml@([^\s]+)\z} +EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" +EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" +EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" + +refs = Hash.new { |hash, key| hash[key] = [] } + +Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| + doc = YAML.load_file(path, aliases: true) + doc.fetch("jobs", {}).each do |job_name, job| + next unless job.is_a?(Hash) + + with = job["with"].is_a?(Hash) ? job["with"] : {} + input_ref = with["control_plane_flow_ref"] + uses_match = job["uses"].to_s.match(CONTROL_PLANE_FLOW_WORKFLOW) + + if job["secrets"] == "inherit" + abort "#{path}:#{job_name} uses secrets: inherit; pass only the named secrets the upstream workflow needs" + end + + if input_ref + abort "#{path}:#{job_name} passes obsolete control_plane_flow_ref. Remove it; the upstream reusable workflow checks out its own source from GitHub's job.workflow_* context." + end + + unless uses_match + next + end + + uses_ref = uses_match[1] + refs[uses_ref] << "#{path}:#{job_name}" + + if job["uses"].to_s.match?(PROMOTE_WORKFLOW) + abort "#{path}:#{job_name} must not call the cross-repo production reusable workflow; use a normal caller-repo job with environment: production" + end + + end +end + +promote_path = ".github/workflows/cpflow-promote-staging-to-production.yml" +promote_doc = YAML.load_file(promote_path, aliases: true) +promote_job = promote_doc.fetch("jobs", {})["promote-to-production"] + +unless promote_job + abort "#{promote_path}:promote-to-production job is missing" +end + +if promote_job.key?("uses") + abort "#{promote_path}:promote-to-production must run as a normal caller-repo job, not a reusable workflow, so GitHub can expose production environment secrets" +end + +unless promote_job["environment"].to_s == "production" + abort "#{promote_path}:promote-to-production must declare environment: production" +end + +checkout_step = Array(promote_job["steps"]).find { |step| step["name"] == "Checkout control-plane-flow actions" } + +unless checkout_step + abort "#{promote_path}:promote-to-production must include a Checkout control-plane-flow actions step" +end + +unless checkout_step["uses"] == EXPECTED_CPFLOW_CHECKOUT_ACTION + abort "#{promote_path}:promote-to-production must use #{EXPECTED_CPFLOW_CHECKOUT_ACTION} for the Checkout control-plane-flow actions step" +end + +checkout_with = checkout_step.fetch("with", {}) +checkout_repository = checkout_with["repository"] +checkout_ref = checkout_with["ref"] + +unless checkout_repository == EXPECTED_CPFLOW_CHECKOUT_REPOSITORY + abort "#{promote_path}:promote-to-production must check out #{EXPECTED_CPFLOW_CHECKOUT_REPOSITORY}" +end + +if checkout_ref.to_s.strip.empty? + abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step" +end + +refs[checkout_ref] << "#{promote_path}:promote-to-production" + +setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" } + +unless setup_step + abort "#{promote_path}:promote-to-production must include a Setup production environment step" +end + +setup_ref = setup_step.fetch("with", {})["control_plane_flow_ref"] +setup_match = setup_ref.to_s.match(PROMOTE_WORKFLOW) + +unless setup_match + abort "#{promote_path}:promote-to-production must pass a pinned production control_plane_flow_ref to setup, " \ + "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}" +end + +refs[setup_match[1]] << "#{promote_path}:promote-to-production setup" + +if refs.empty? + puts "no upstream cpflow reusable workflow refs found" +elsif refs.length > 1 + refs.each do |ref, paths| + puts "#{ref}: #{paths.uniq.sort.join(', ')}" + end + abort "cpflow workflow wrappers use multiple upstream refs: #{refs.keys.sort.join(', ')}" +else + puts "cpflow refs: #{refs.keys.sort.join(', ')}" +end +RUBY + +echo "==> actionlint" +actionlint \ + -ignore "SC2129" \ + -ignore 'property "workflow_(ref|sha|repository|file_path)" is not defined in object type' \ + .github/workflows/cpflow-*.yml From 55270b1ba0c59224a52b6465f4390ec193871ad2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:10:24 -1000 Subject: [PATCH 07/25] Harden generated deployment defaults --- .controlplane/readme.md | 10 ++++++---- .dockerignore | 19 +++++++++++++++++++ .github/workflows/cpflow-deploy-staging.yml | 4 ++-- 3 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 .dockerignore diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 76d6fad..fba1c06 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -39,10 +39,12 @@ app identity can `reveal` the app secret policy. ## GitHub Configuration -The normal review-app path needs only the repository secret -`CPLN_TOKEN_STAGING`; the review prefix and staging organization are inferred -from `.controlplane/controlplane.yml`. Set `STAGING_APP_NAME` to -`react-on-rails-migration-example-staging` for automatic staging deploys. +Store `CPLN_TOKEN_STAGING` as a repository secret. Set the repository variables +`CPLN_ORG_STAGING` to the staging Control Plane organization and +`STAGING_APP_NAME` to `react-on-rails-migration-example-staging`; both review +apps and automatic staging deploys use that staging organization. The review +app prefix is inferred from `.controlplane/controlplane.yml` unless +`REVIEW_APP_PREFIX` overrides it. Create a protected `production` GitHub Environment with required reviewers and self-review disabled. Store `CPLN_TOKEN_PRODUCTION` only as an Environment diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b86c026 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.agents +.bundle +.env +.env.* +config/master.key +config/credentials/*.key +db/*.sqlite3 +log/* +storage/* +tmp/* +node_modules +vendor/bundle +app/assets/builds/* +public/assets +public/packs +public/packs-test +coverage diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml index cbeccb0..2456303 100644 --- a/.github/workflows/cpflow-deploy-staging.yml +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -8,7 +8,7 @@ on: # deploy branches unless `cpflow generate-github-actions --staging-branch BRANCH` # was used. If STAGING_APP_BRANCH is later changed in repository variables, keep # this list in sync so pushes to that branch actually trigger the workflow. - branches: ["**"] + branches: ["main"] workflow_dispatch: permissions: @@ -18,7 +18,7 @@ jobs: deploy-staging: uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 with: - staging_app_branch_default: "**" + staging_app_branch_default: "main" secrets: CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} From 56c892282008703e57d8fd921e86e795394e4aaa Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:29:36 -1000 Subject: [PATCH 08/25] Fix production promotion and rendering --- .../cpflow-promote-staging-to-production.yml | 24 ++++++++++++-- app/views/layouts/application.html.erb | 2 +- config/webpack/serverWebpackConfig.js | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index 7c70781..ba0f78b 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -58,6 +58,7 @@ jobs: outputs: staging_app_name: ${{ steps.release-context.outputs.staging_app_name }} production_app_name: ${{ steps.release-context.outputs.production_app_name }} + staging_commit: ${{ steps.copy-image.outputs.staging_commit }} steps: - name: Checkout repository @@ -491,7 +492,12 @@ jobs: if [[ "${staging_tag}" == *_* ]]; then staging_commit="${staging_tag##*_}" else - echo "::warning::Staging image '${staging_image}' did not include a '_' suffix; production image tag will omit the commit suffix." + echo "::error::Staging image '${staging_image}' does not include the commit suffix required for a traceable production release." + exit 1 + fi + if ! [[ "${staging_commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Staging image '${staging_image}' has an invalid commit suffix; expected a full lowercase Git commit SHA." + exit 1 fi # The workflow-level concurrency group serializes this sequence so two @@ -566,8 +572,10 @@ jobs: fi echo "image=${production_image}" >> "$GITHUB_OUTPUT" + echo "staging_commit=${staging_commit}" >> "$GITHUB_OUTPUT" - name: Deploy image to production + id: deploy-production env: PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} @@ -599,7 +607,11 @@ jobs: accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} - name: Roll back on failure - if: failure() && steps.capture-current.outcome == 'success' + if: >- + failure() && + (steps.deploy-production.outcome == 'success' || steps.deploy-production.outcome == '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 }} @@ -661,7 +673,11 @@ jobs: fi - name: Wait for rollback readiness - if: failure() && steps.capture-current.outcome == 'success' + if: >- + failure() && + (steps.deploy-production.outcome == 'success' || steps.deploy-production.outcome == '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 }} @@ -770,6 +786,7 @@ jobs: GITHUB_RUN_ID: ${{ github.run_id }} STAGING_APP_NAME: ${{ needs.promote-to-production.outputs.staging_app_name }} PRODUCTION_APP_NAME: ${{ needs.promote-to-production.outputs.production_app_name }} + STAGING_COMMIT: ${{ needs.promote-to-production.outputs.staging_commit }} shell: bash run: | set -euo pipefail @@ -779,5 +796,6 @@ jobs: release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}" gh release create "${release_tag}" \ + --target "${STAGING_COMMIT}" \ --title "Production Release ${release_date} ${timestamp}" \ --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}." diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ea27b5c..a7960e9 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,7 +6,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 1f3b49e..53f9b34 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -53,6 +53,37 @@ const configureServer = () => { minimize: false, }; serverWebpackConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); + serverWebpackConfig.plugins.unshift( + new bundler.BannerPlugin({ + banner: ` +if (typeof globalThis.TextEncoder === 'undefined') { + globalThis.TextEncoder = class TextEncoder { + encode(input = '') { + const encoded = unescape(encodeURIComponent(String(input))); + return Uint8Array.from(encoded, (character) => character.charCodeAt(0)); + } + }; +} + +if (typeof globalThis.MessageChannel === 'undefined') { + globalThis.MessageChannel = class MessageChannel { + constructor() { + const port1 = { onmessage: null }; + const port2 = { + postMessage: (data) => { + Promise.resolve().then(() => port1.onmessage?.({ data })); + }, + }; + this.port1 = port1; + this.port2 = port2; + } + }; +} +`, + entryOnly: true, + raw: true, + }), + ); // Custom output for the server-bundle // Using Shakapacker 9.0+ privateOutputPath for automatic sync with shakapacker.yml From 3cf019b3af6f2719c3e3c9542299c5c9b0013b36 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:37:50 -1000 Subject: [PATCH 09/25] Document review app secret bootstrap --- .controlplane/readme.md | 18 ++++++++++++++---- Gemfile | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index fba1c06..9fb1e5d 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -18,6 +18,17 @@ Capacity AI right-sizes its allocation. ## One-Time Bootstrap +Create the shared review-app secret before enabling review deployments. Review +apps execute pull-request code, so use a disposable value that grants no access +to staging, production, or third-party services: + +```sh +cpln secret create-dictionary \ + --name react-on-rails-migration-example-review-secrets \ + --org "$CPLN_ORG_STAGING" \ + --entry "SECRET_KEY_BASE=$(bin/rails secret)" +``` + Bootstrap the persistent staging and production apps before their first deploy: ```sh @@ -32,10 +43,9 @@ cpflow setup-app \ --skip-post-creation-hook ``` -Add `SECRET_KEY_BASE` to each generated app secret dictionary. Use disposable, -review-safe values for review apps because pull-request code can read mounted -secrets. For later template changes, run `cpflow apply-template` and ensure the -app identity can `reveal` the app secret policy. +Add a distinct `SECRET_KEY_BASE` to each generated staging and production app +secret dictionary. For later template changes, run `cpflow apply-template` and +ensure the app identity can `reveal` the app secret policy. ## GitHub Configuration diff --git a/Gemfile b/Gemfile index a095290..d46b1ee 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby "~> 3.4.0" +ruby ">= 3.3.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.1.3" From 87c86aa9d79f76f0b98b1054f32fac9ee7ee5286 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:44:15 -1000 Subject: [PATCH 10/25] Keep Navbar SSR deterministic --- app/javascript/src/components/Navbar.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/javascript/src/components/Navbar.jsx b/app/javascript/src/components/Navbar.jsx index 83ad484..19241b9 100644 --- a/app/javascript/src/components/Navbar.jsx +++ b/app/javascript/src/components/Navbar.jsx @@ -6,9 +6,10 @@ import Card from "./Card"; /** * @param {String} menus * @param {String} homeUrl + * @param {Number} avatarSeed * @return {JSX.Element} */ -function Navbar({ menus, homeUrl }) { +function Navbar({ menus, homeUrl, avatarSeed = 0 }) { return (
@@ -23,7 +24,7 @@ function Navbar({ menus, homeUrl }) {
    @@ -45,6 +46,7 @@ function Navbar({ menus, homeUrl }) { Navbar.propTypes = { menus: PropTypes.string, homeUrl: PropTypes.string, + avatarSeed: PropTypes.number, }; export default Navbar; From 6c3f88436cc0b028ecdd3613b653ac9004bf1a30 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 16 Sep 2026 23:51:33 -1000 Subject: [PATCH 11/25] Keep SQLite data separate from migrations --- .controlplane/readme.md | 10 ++++++---- .controlplane/templates/app.yml | 2 ++ .controlplane/templates/rails.yml | 4 +++- config/database.yml | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 9fb1e5d..7b8f59d 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -11,10 +11,12 @@ release, repin them with `bin/pin-cpflow-github-ref `. ## Runtime Shape The app uses SQLite and local Active Storage in production. The Control Plane -templates therefore mount persistent volumes at `/app/db` and `/app/storage`, -and the release script runs `bin/rails db:prepare` before a new image is made -live. The Rails workload remains `standard` with one warm replica while -Capacity AI right-sizes its allocation. +templates therefore mount persistent volumes at `/app/data` and `/app/storage`, +set `DATABASE_PATH=/app/data/production.sqlite3`, and leave the image's +`/app/db` migrations visible to the release script. The release script runs +`bin/rails db:prepare` before a new image is made live. The Rails workload +remains `standard` with one warm replica while Capacity AI right-sizes its +allocation. ## One-Time Bootstrap diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml index f8dd6d3..c087e05 100644 --- a/.controlplane/templates/app.yml +++ b/.controlplane/templates/app.yml @@ -8,6 +8,8 @@ spec: value: "true" - name: RAILS_SERVE_STATIC_FILES value: "true" + - name: DATABASE_PATH + value: /app/data/production.sqlite3 # Review apps execute pull request code, so only mount review-safe values # through this generated secret dictionary. - name: SECRET_KEY_BASE diff --git a/.controlplane/templates/rails.yml b/.controlplane/templates/rails.yml index 38151ce..c0d571d 100644 --- a/.controlplane/templates/rails.yml +++ b/.controlplane/templates/rails.yml @@ -16,7 +16,9 @@ spec: # See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details. protocol: http volumes: - - path: /app/db + # Keep migrations and schema from the image visible under /app/db; + # only the SQLite data file belongs on persistent storage. + - path: /app/data recoveryPolicy: retain uri: cpln://volumeset/app-db - path: /app/storage diff --git a/config/database.yml b/config/database.yml index fcba57f..a10ab4a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -22,4 +22,4 @@ test: production: <<: *default - database: db/production.sqlite3 + database: <%= ENV.fetch("DATABASE_PATH", "db/production.sqlite3") %> From f4bd7004c7ad0008debba969dbfee040577e6b54 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 19 Sep 2026 11:19:28 -1000 Subject: [PATCH 12/25] Address deployment flow review feedback --- .controlplane/controlplane.yml | 4 ++-- .github/cpflow-help.md | 2 ++ .../workflows/cpflow-promote-staging-to-production.yml | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index 53dc4ff..d04f305 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -1,7 +1,8 @@ # 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. +# keeps SQLite data under `/app/data` and Active Storage under `/app/storage` +# on persistent volumes without hiding migrations under `/app/db`. allow_org_override_by_env: true allow_app_override_by_env: true @@ -21,7 +22,6 @@ aliases: - rails additional_workloads: [] - maintenance_workload: maintenance release_script: release_script.sh stale_app_image_deployed_days: 5 diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index 50511ec..f7efb30 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -95,6 +95,8 @@ Production promotion is part of the generated flow, but keep it protected: | `CPLN_TOKEN_PRODUCTION` | `production` GitHub Environment secret | Do not store this as a repository or organization secret. | | `CPLN_ORG_PRODUCTION` | Prefer `production` Environment variable | Production Control Plane org. | | `PRODUCTION_APP_NAME` | Prefer `production` Environment variable | Production app name from `controlplane.yml`. | +| `CPLN_ORG_STAGING` | Repository or `production` Environment variable | Required staging source organization for promotion. | +| `STAGING_APP_NAME` | Repository or `production` Environment variable | Required staging source app for promotion. | Configure the `production` GitHub Environment with required reviewers and prevent self-review. Production promotion intentionally runs as a normal diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml index ba0f78b..e2ef25f 100644 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -686,6 +686,16 @@ jobs: run: | set -euo pipefail + if ! [[ "${ROLLBACK_READINESS_RETRIES}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::ROLLBACK_READINESS_RETRIES must be a positive integer." + exit 1 + fi + + if ! [[ "${ROLLBACK_READINESS_INTERVAL}" =~ ^[0-9]+$ ]]; then + echo "::error::ROLLBACK_READINESS_INTERVAL must be a non-negative integer." + exit 1 + fi + mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') # Poll workloads in parallel so the worst-case wall time during a From de5796536787413edbe52eb885b5d94864e18c99 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 22:30:15 -1000 Subject: [PATCH 13/25] Cover migrated routes with SSR regression tests --- test/integration/rendering_test.rb | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 test/integration/rendering_test.rb diff --git a/test/integration/rendering_test.rb b/test/integration/rendering_test.rb new file mode 100644 index 0000000..410d1c0 --- /dev/null +++ b/test/integration/rendering_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class RenderingTest < ActionDispatch::IntegrationTest + test "posts index renders its navigation and cards on the server" do + get root_path + + assert_response :success + assert_select "a", text: "Home" + assert_select "h2", text: "Tail beef" + assert_select 'script[data-component-name="Navbar"]' + assert_select 'script[data-component-name="PostsIndex"]' + end + + test "post details render on the server" do + get post_path(0) + + assert_response :success + assert_select "h1", text: "Tail beef" + assert_select 'script[data-component-name="PostsShow"]' + end + + test "hello world renders with the MiniRacer runtime" do + get hello_world_path + + assert_response :success + assert_select "h3", text: "Hello, Stranger!" + assert_select 'input#name[value="Stranger"]' + assert_select 'script[data-component-name="HelloWorld"]' + end +end From 0368ee100417db6d9f828832d1043cf26112d0c3 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 22:38:11 -1000 Subject: [PATCH 14/25] Keep preview deployment focused and load hydration bundles --- .controlplane/controlplane.yml | 11 +- .controlplane/readme.md | 18 +- .github/cpflow-help.md | 52 +- .../cpflow-promote-staging-to-production.yml | 811 ------------------ Gemfile | 2 + Gemfile.lock | 3 +- README.md | 2 +- app/views/layouts/application.html.erb | 1 + bin/pin-cpflow-github-ref | 18 +- bin/test-cpflow-github-flow | 63 -- test/integration/rendering_test.rb | 4 + 11 files changed, 19 insertions(+), 966 deletions(-) delete mode 100644 .github/workflows/cpflow-promote-staging-to-production.yml diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index d04f305..6da7ade 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -9,7 +9,7 @@ allow_app_override_by_env: true aliases: common: &common - cpln_org: my-org-staging + cpln_org: shakacode-open-source-examples-staging default_location: aws-us-east-2 setup_app_templates: - app @@ -27,13 +27,6 @@ aliases: 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: react-on-rails-migration-example-staging - apps: react-on-rails-migration-example-staging: <<: *common @@ -49,5 +42,3 @@ apps: # secret_name: react-on-rails-migration-example-review-database-secrets # policy_name: react-on-rails-migration-example-review-database-secrets-policy - react-on-rails-migration-example-production: - <<: *production diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 7b8f59d..9a24bf9 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -1,7 +1,7 @@ # Control Plane Deployment Notes This repository uses `cpflow` for opt-in pull-request review apps, automatic -staging deploys from `main`, and manual promotion from staging to production. +staging deploys from `main`. Production promotion is not configured. The generated GitHub Actions use `cpflow` v5.3.0 and pin the immutable release commit `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`; see [`.github/cpflow-help.md`](../.github/cpflow-help.md) for the complete commands, @@ -31,35 +31,25 @@ cpln secret create-dictionary \ --entry "SECRET_KEY_BASE=$(bin/rails secret)" ``` -Bootstrap the persistent staging and production apps before their first deploy: +Bootstrap persistent staging before its first deploy: ```sh cpflow setup-app \ -a react-on-rails-migration-example-staging \ --org "$CPLN_ORG_STAGING" \ --skip-post-creation-hook - -cpflow setup-app \ - -a react-on-rails-migration-example-production \ - --org "$CPLN_ORG_PRODUCTION" \ - --skip-post-creation-hook ``` -Add a distinct `SECRET_KEY_BASE` to each generated staging and production app +Add a distinct `SECRET_KEY_BASE` to the generated staging app secret dictionary. For later template changes, run `cpflow apply-template` and ensure the app identity can `reveal` the app secret policy. ## GitHub Configuration Store `CPLN_TOKEN_STAGING` as a repository secret. Set the repository variables -`CPLN_ORG_STAGING` to the staging Control Plane organization and +`CPLN_ORG_STAGING` to `shakacode-open-source-examples-staging` and `STAGING_APP_NAME` to `react-on-rails-migration-example-staging`; both review apps and automatic staging deploys use that staging organization. The review app prefix is inferred from `.controlplane/controlplane.yml` unless `REVIEW_APP_PREFIX` overrides it. -Create a protected `production` GitHub Environment with required reviewers and -self-review disabled. Store `CPLN_TOKEN_PRODUCTION` only as an Environment -secret, and set `CPLN_ORG_PRODUCTION` and `PRODUCTION_APP_NAME` there as -Environment variables. Do not create a repository or organization secret named -`CPLN_TOKEN_PRODUCTION`. diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index f7efb30..b27042c 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -67,7 +67,7 @@ Optional overrides exist for forks, clones, and unusual apps: -## Staging And Production +## Automatic staging Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`. Before the first staging deploy, bootstrap the persistent staging app once: @@ -84,50 +84,7 @@ template updates on an existing persistent app, use `cpflow apply-template` with the same template list and make sure the app identity has `reveal` permission on the app secret policy. -Review apps are temporary and are created by the `+review-app-deploy` workflow, -but staging and production are persistent apps and should be bootstrapped -explicitly. - -Production promotion is part of the generated flow, but keep it protected: - -| Name | Where | Notes | -| --- | --- | --- | -| `CPLN_TOKEN_PRODUCTION` | `production` GitHub Environment secret | Do not store this as a repository or organization secret. | -| `CPLN_ORG_PRODUCTION` | Prefer `production` Environment variable | Production Control Plane org. | -| `PRODUCTION_APP_NAME` | Prefer `production` Environment variable | Production app name from `controlplane.yml`. | -| `CPLN_ORG_STAGING` | Repository or `production` Environment variable | Required staging source organization for promotion. | -| `STAGING_APP_NAME` | Repository or `production` Environment variable | Required staging source app for promotion. | - -Configure the `production` GitHub Environment with required reviewers and -prevent self-review. Production promotion intentionally runs as a normal -caller-repo workflow job with `environment: production`, then checks out the -pinned `control-plane-flow` release for shared actions. Do not move production -promotion behind a cross-repo reusable workflow: GitHub does not expose this -repo's environment secrets to that called workflow. - -Keep `CPLN_TOKEN_PRODUCTION` absent from repository and organization secrets. A -normal environment-gated job cannot tell which secret scope supplied a nonempty -value, so a broader secret with the same name can mask a missing environment -secret. - -If the promotion workflow fails with -`CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the 'production' GitHub Environment.`, -the token is missing from the environment scope or the workflow job is no longer -declaring `environment: production`. Create or verify the environment secret -and confirm there is no same-named repository or organization secret: -You need permission to manage repository environments and secrets to run these -commands. - -```sh -gh secret set CPLN_TOKEN_PRODUCTION --repo OWNER/REPO --env production -# Paste the token value when prompted. -gh secret list --repo OWNER/REPO --env production -gh secret list --repo OWNER/REPO -gh secret list --org OWNER | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || true -``` - -Before the first promotion, bootstrap the production app the same way in the -production org, using production-only secrets and values. +Review apps are temporary; bootstrap persistent staging explicitly.
    Version locking and advanced options @@ -136,10 +93,7 @@ production org, using production-only secrets and values. Generated wrappers pin Control Plane Flow with a release tag, for example `v5.3.0`. Reusable review-app, staging, cleanup, and -helper workflows pin the tag in their `uses:` ref. Production promotion pins -the same tag in the `Checkout control-plane-flow actions` step so the -caller-owned job can keep `environment: production` and receive production -environment secrets directly. +helper workflows pin the tag in their `uses:` ref. Production promotion is not configured. Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml deleted file mode 100644 index e2ef25f..0000000 --- a/.github/workflows/cpflow-promote-staging-to-production.yml +++ /dev/null @@ -1,811 +0,0 @@ -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: read - -env: - # Override these by editing this file or by setting the matching repository variable. - # Worst-case wall time per attempt is HEALTH_CHECK_INTERVAL plus the curl --max-time below - # (10s), so the defaults give a ~10 minute window (24 × (15 + 10) = 600s) — enough for - # most Rails cold boots (asset precompile + db:migrate + workload readiness). - HEALTH_CHECK_RETRIES: ${{ vars.HEALTH_CHECK_RETRIES || '24' }} - HEALTH_CHECK_INTERVAL: ${{ vars.HEALTH_CHECK_INTERVAL || '15' }} - # Space-separated list of HTTP statuses considered healthy. The default accepts 301/302 - # because `curl` is invoked without `-L`, so a root `/` that redirects to a login page - # (common for Rails apps that auth-gate `/`) would otherwise be reported as unhealthy - # despite the workload itself being up. - # - # Strongly recommended: expose a dedicated `/health` endpoint that returns `200` and set - # HEALTH_CHECK_ACCEPTED_STATUSES to `"200"` in repository variables. The 301/302 default - # trades correctness for ergonomics — a maintenance-mode redirect or an auth-gate redirect - # to a login page can pass this check even when the underlying app is broken. Override - # via the HEALTH_CHECK_ACCEPTED_STATUSES repo variable to tighten this for apps that - # expose a dedicated health endpoint (e.g. "200" for a plain /health, or "200 401 403" - # for apps that auth-gate / without redirecting). - HEALTH_CHECK_ACCEPTED_STATUSES: ${{ vars.HEALTH_CHECK_ACCEPTED_STATUSES || '200 301 302' }} - COPY_IMAGE_RETRIES: ${{ vars.COPY_IMAGE_RETRIES || '3' }} - COPY_IMAGE_RETRY_INTERVAL: ${{ vars.COPY_IMAGE_RETRY_INTERVAL || '20' }} - ROLLBACK_READINESS_RETRIES: ${{ vars.ROLLBACK_READINESS_RETRIES || '24' }} - ROLLBACK_READINESS_INTERVAL: ${{ vars.ROLLBACK_READINESS_INTERVAL || '15' }} - -concurrency: - # Single global group: only one production promotion may run at a time across the - # whole repo. Independent of staging deploys and review-app workflows (different - # GVCs / different concurrency keys), so those can still run in parallel. - group: cpflow-promote-staging-to-production - # Don't cancel an in-flight promotion: a half-finished `cpflow deploy-image` plus a - # rollback can leave production in a worse state than letting the first run finish. - cancel-in-progress: false - -jobs: - promote-to-production: - if: github.event.inputs.confirm_promotion == 'promote' - runs-on: ubuntu-latest - # This normal caller-repo job declares the protected production Environment - # directly, so GitHub exposes environment secrets in this job after the - # environment gate. Do not move production promotion back behind a - # cross-repo reusable workflow; environment secrets are not available there. - environment: production - timeout-minutes: 45 - outputs: - staging_app_name: ${{ steps.release-context.outputs.staging_app_name }} - production_app_name: ${{ steps.release-context.outputs.production_app_name }} - staging_commit: ${{ steps.copy-image.outputs.staging_commit }} - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - - name: Checkout control-plane-flow actions - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - repository: shakacode/control-plane-flow - ref: b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - path: .cpflow - persist-credentials: false - - - name: Validate production token - shell: bash - env: - # GitHub does not expose which secret scope supplied this value. - # Keep CPLN_TOKEN_PRODUCTION absent from repo/org secrets so the - # protected production Environment is the only configured source. - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - PRODUCTION_ENVIRONMENT: production - run: | - set -euo pipefail - - if [[ -z "${CPLN_TOKEN_PRODUCTION}" ]]; then - echo "::error::CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the '${PRODUCTION_ENVIRONMENT}' GitHub Environment." - exit 1 - fi - - - name: Validate required secrets and variables - uses: ./.cpflow/.github/actions/cpflow-validate-config - # Pass secrets via env so the composite action checks indirect shell - # variables instead of interpolating secret values into a run script. - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - with: - required: | - secret:CPLN_TOKEN_STAGING - secret:CPLN_TOKEN_PRODUCTION - variable:CPLN_ORG_STAGING - variable:CPLN_ORG_PRODUCTION - variable:STAGING_APP_NAME - variable:PRODUCTION_APP_NAME - - - name: Normalize Control Plane org names - id: cpln-orgs - env: - CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} - CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} - shell: bash - run: | - set -euo pipefail - - sanitize_control_plane_name() { - local label="$1" - local value="$2" - - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - if [[ "${value}" == *$'\r'* || "${value}" == *$'\n'* ]]; then - echo "::error::${label} contains embedded line endings; remove them from the repository variable instead of relying on normalization." >&2 - exit 1 - fi - - printf '%s' "${value}" - } - - validate_control_plane_org() { - local label="$1" - local value="$2" - - if ! [[ "${value}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then - local display_value - display_value="$(printf '%q' "${value}")" - echo "::error::${label} (${display_value}) must be a valid Control Plane org name; use lowercase alphanumeric characters and hyphens only, with no leading or trailing hyphen." >&2 - exit 1 - fi - } - - staging_org="$(sanitize_control_plane_name "CPLN_ORG_STAGING" "${CPLN_ORG_STAGING}")" - production_org="$(sanitize_control_plane_name "CPLN_ORG_PRODUCTION" "${CPLN_ORG_PRODUCTION}")" - - validate_control_plane_org "CPLN_ORG_STAGING" "${staging_org}" - validate_control_plane_org "CPLN_ORG_PRODUCTION" "${production_org}" - - { - echo "staging=${staging_org}" - echo "production=${production_org}" - } >> "$GITHUB_OUTPUT" - - - name: Capture release context - id: release-context - env: - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - shell: bash - run: | - set -euo pipefail - - { - echo "staging_app_name=${STAGING_APP_NAME}" - echo "production_app_name=${PRODUCTION_APP_NAME}" - } >> "$GITHUB_OUTPUT" - - - name: Setup production environment - uses: ./.cpflow/.github/actions/cpflow-setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ steps.cpln-orgs.outputs.production }} - working_directory: .cpflow - cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }} - cpflow_version: ${{ vars.CPFLOW_VERSION }} - # The setup action validates CPFLOW_VERSION against this full workflow ref. - control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - - # 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 }} - PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} - shell: bash - run: | - set -euo pipefail - - ruby - "${PRODUCTION_APP_NAME}" "${PRIMARY_WORKLOAD}" "${GITHUB_OUTPUT}" <<'RUBY' - require "yaml" - - app = ARGV.fetch(0) - requested_primary = ARGV.fetch(1, "").to_s.strip - output_path = ARGV.fetch(2) - data = YAML.safe_load(File.read(".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"]).map(&:to_s).reject(&:empty?) - workloads = ["rails"] if workloads.empty? - - primary = - if requested_primary.empty? - if workloads.length == 1 - workloads.first - elsif workloads.include?("rails") - "rails" - else - puts "::error::PRIMARY_WORKLOAD is not configured and app '#{app}' has multiple workloads: #{workloads.join(', ')}." - warn " Set the PRIMARY_WORKLOAD repository variable to one of these workloads." - exit 1 - end - elsif workloads.include?(requested_primary) - requested_primary - else - puts "::error::PRIMARY_WORKLOAD '#{requested_primary}' is not one of: #{workloads.join(', ')}." - exit 1 - end - - File.open(output_path, "a") do |output| - output.puts "names=#{workloads.join(',')}" - output.puts "primary=#{primary}" - end - RUBY - - - name: Detect release phase support - id: release-phase - uses: ./.cpflow/.github/actions/cpflow-detect-release-phase - with: - app_name: ${{ vars.PRODUCTION_APP_NAME }} - - - 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: ${{ steps.cpln-orgs.outputs.staging }} - CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} - WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} - shell: bash - run: | - set -euo pipefail - - list_gvc_env_names() { - local token="$1" - local org="$2" - local app="$3" - - CPLN_TOKEN="${token}" cpln gvc get "${app}" --org "${org}" -o json | - jq -r '.spec.env // [] | .[] | .name // empty' | - sort -u - } - - list_workload_env_names() { - local token="$1" - local org="$2" - local app="$3" - local workload="$4" - - CPLN_TOKEN="${token}" cpln workload get "${workload}" --gvc "${app}" --org "${org}" -o json | - jq -r '.spec.containers // [] | .[] | (.env // [])[]? | .name // empty' | - sort -u - } - - check_required_vars() { - local staging_scope="$1" - local production_scope="$2" - local missing_message="$3" - local staging_vars="$4" - local production_vars="$5" - local missing_vars - local production_only_vars - - if [[ -z "${staging_vars}" ]]; then - echo "Staging ${staging_scope} exposes no environment variables; skipping parity check." - return - fi - - # Treat staging as the promotion source of truth: fail when a variable - # present in staging is missing in production. Production-only variables - # are allowed, but surface them so teams can spot drift. - missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - production_only_vars="$(comm -13 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" - - if [[ -n "${production_only_vars}" ]]; then - echo "::warning::Production ${production_scope} has environment variables that are not present in staging:" - echo "${production_only_vars}" - fi - - if [[ -n "${missing_vars}" ]]; then - echo "::error::${missing_message}" - echo "${missing_vars}" - env_check_failed=1 - fi - } - - # check_required_vars intentionally mutates env_check_failed in this - # shell; keep calls outside subshells so failures aggregate before the - # final exit. - env_check_failed=0 - - staging_vars="$(list_gvc_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}")" - production_vars="$(list_gvc_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}")" - check_required_vars \ - "GVC '${STAGING_APP_NAME}'" \ - "GVC '${PRODUCTION_APP_NAME}'" \ - "Production GVC '${PRODUCTION_APP_NAME}' is missing environment variables that exist in staging" \ - "${staging_vars}" \ - "${production_vars}" - - while IFS= read -r workload_name; do - [[ -n "${workload_name}" ]] || continue - - staging_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_STAGING}" "${CPLN_ORG_STAGING}" "${STAGING_APP_NAME}" "${workload_name}")" - production_workload_vars="$(list_workload_env_names "${CPLN_TOKEN_PRODUCTION}" "${CPLN_ORG_PRODUCTION}" "${PRODUCTION_APP_NAME}" "${workload_name}")" - check_required_vars \ - "workload '${workload_name}'" \ - "workload '${workload_name}'" \ - "Production workload '${workload_name}' is missing environment variables that exist in staging" \ - "${staging_workload_vars}" \ - "${production_workload_vars}" - done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - - exit "${env_check_failed}" - - - name: Capture current production image - id: capture-current - env: - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} - WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} - PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} - shell: bash - run: | - set -euo pipefail - - selected_workload="${PRIMARY_WORKLOAD}" - selected_image="" - selected_version="" - rollback_state='{}' - - # Validate all workloads have images, then promote the primary workload's - # image as the canonical image for this GVC. - 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 // empty')" - workload_containers="$(echo "${workload_json}" | jq -c '.spec.containers | map({name, image})')" - workload_version="$(echo "${workload_json}" | jq -r '.version')" - - if [[ "${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 containers "${workload_containers}" \ - '. + {($workload): {image: $image, version: $version, containers: $containers}}' \ - <<< "${rollback_state}" - )" - done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - - if [[ -z "${selected_image}" || -z "${selected_version}" ]]; then - echo "::error::Could not capture current image/version for primary workload '${selected_workload}'." >&2 - exit 1 - fi - - echo "current_image=${selected_image}" >> "$GITHUB_OUTPUT" - echo "current_version=${selected_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: Capture deployed staging image - id: staging-image - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} - WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} - PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }} - shell: bash - run: | - set -euo pipefail - - selected_workload="${PRIMARY_WORKLOAD}" - selected_image="" - - while IFS= read -r workload_name; do - [[ -n "${workload_name}" ]] || continue - - workload_json="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln workload get "${workload_name}" --gvc "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json)" - workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image // empty')" - - if [[ -z "${workload_image}" ]]; then - echo "::error::Could not find an image on staging workload '${workload_name}'." >&2 - exit 1 - fi - - if [[ "${workload_name}" == "${selected_workload}" ]]; then - selected_image="${workload_image}" - fi - done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") - - staging_image_ref="${selected_image}" - if [[ -z "${staging_image_ref}" ]]; then - echo "::error::Could not determine the deployed staging image for primary workload '${selected_workload}'." >&2 - exit 1 - fi - - if [[ "${staging_image_ref}" == /org/*/image/* ]]; then - staging_image="${staging_image_ref##*/image/}" - elif [[ "${staging_image_ref}" == *.registry.cpln.io/* ]]; then - staging_image="${staging_image_ref#*.registry.cpln.io/}" - else - staging_image="${staging_image_ref}" - fi - - echo "image=${staging_image}" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - - - name: Copy image from staging - id: copy-image - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_STAGING: ${{ steps.cpln-orgs.outputs.staging }} - CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }} - STAGING_IMAGE: ${{ steps.staging-image.outputs.image }} - shell: bash - run: | - set -euo pipefail - - if ! [[ "${COPY_IMAGE_RETRIES}" =~ ^[0-9]+$ ]]; then - echo "::error::COPY_IMAGE_RETRIES must be a non-negative integer." - exit 1 - fi - - if ! [[ "${COPY_IMAGE_RETRY_INTERVAL}" =~ ^[0-9]+$ ]]; then - echo "::error::COPY_IMAGE_RETRY_INTERVAL must be a non-negative integer." - exit 1 - fi - - copy_image_retries=$((10#${COPY_IMAGE_RETRIES})) - copy_image_attempts=$((copy_image_retries + 1)) - copy_image_retry_interval=$((10#${COPY_IMAGE_RETRY_INTERVAL})) - - staging_image="${STAGING_IMAGE}" - if [[ -z "${staging_image}" ]]; then - echo "::error::STAGING_IMAGE is not set or is empty." - exit 1 - fi - - if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then - echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion." - exit 1 - fi - - staging_tag="" - if [[ "${staging_image}" == *@* ]]; then - staging_tag="${staging_image##*@}" - elif [[ "${staging_image}" == *:* ]]; then - staging_tag="${staging_image##*:}" - fi - staging_commit="" - if [[ "${staging_tag}" == *_* ]]; then - staging_commit="${staging_tag##*_}" - else - echo "::error::Staging image '${staging_image}' does not include the commit suffix required for a traceable production release." - exit 1 - fi - if ! [[ "${staging_commit}" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::Staging image '${staging_image}' has an invalid commit suffix; expected a full lowercase Git commit SHA." - exit 1 - fi - - # The workflow-level concurrency group serializes this sequence so two - # production promotions cannot derive and publish the same next tag. - # See the top-level concurrency group: cpflow-promote-staging-to-production. - latest_number="$( - cpln image query --org "${CPLN_ORG_PRODUCTION}" --prop "name~${PRODUCTION_APP_NAME}:" --max 0 -o json | - jq -r --arg prefix "${PRODUCTION_APP_NAME}:" \ - '[.items[].name | select(startswith($prefix)) | (try capture("^[^:]+:(?[0-9]+)") catch empty) | .number | tonumber] | max // 0' - )" - if ! [[ "${latest_number}" =~ ^[0-9]+$ ]]; then - echo "::error::Could not determine the next production image number for app '${PRODUCTION_APP_NAME}' in org '${CPLN_ORG_PRODUCTION}'." - exit 1 - fi - - production_image="${PRODUCTION_APP_NAME}:$((latest_number + 1))" - if [[ -n "${staging_commit}" ]]; then - production_image="${production_image}_${staging_commit}" - fi - - staging_registry="${CPLN_ORG_STAGING}.registry.cpln.io" - production_registry="${CPLN_ORG_PRODUCTION}.registry.cpln.io" - source_image_ref="${staging_registry}/${STAGING_IMAGE}" - production_image_ref="${production_registry}/${production_image}" - - docker_config_dir="$(mktemp -d)" - cleanup_copy_credentials() { - rm -rf "${docker_config_dir}" - } - trap cleanup_copy_credentials EXIT - - export DOCKER_CONFIG="${docker_config_dir}" - - if ! printf '%s' "${CPLN_TOKEN_STAGING}" | - docker login "${staging_registry}" -u '' --password-stdin >/dev/null; then - echo "::error::Failed to authenticate to staging registry '${staging_registry}'." - exit 1 - fi - - if ! printf '%s' "${CPLN_TOKEN_PRODUCTION}" | - docker login "${production_registry}" -u '' --password-stdin >/dev/null; then - echo "::error::Failed to authenticate to production registry '${production_registry}'." - exit 1 - fi - - if docker buildx imagetools inspect "${production_image_ref}" >/dev/null 2>&1; then - echo "::error::Production image '${production_image}' already exists in org '${CPLN_ORG_PRODUCTION}'; aborting to avoid overwriting it." - exit 1 - fi - - copy_status=1 - for attempt in $(seq 1 "${copy_image_attempts}"); do - if docker buildx imagetools inspect "${source_image_ref}" >/dev/null && - docker buildx imagetools create --prefer-index=false --tag "${production_image_ref}" "${source_image_ref}"; then - copy_status=0 - break - else - copy_status=$? - fi - - if [[ "${attempt}" -lt "${copy_image_attempts}" ]]; then - echo "::warning::Image copy attempt ${attempt}/${copy_image_attempts} failed with exit ${copy_status}; retrying in ${copy_image_retry_interval}s." - sleep "${copy_image_retry_interval}" - else - echo "::warning::Image copy attempt ${attempt}/${copy_image_attempts} failed with exit ${copy_status}; no attempts remain." - fi - done - - if [[ "${copy_status}" -ne 0 ]]; then - echo "::error::Could not copy staging image '${STAGING_IMAGE}' from '${CPLN_ORG_STAGING}' to '${CPLN_ORG_PRODUCTION}' after ${copy_image_attempts} attempt(s)." - exit "${copy_status}" - fi - - echo "image=${production_image}" >> "$GITHUB_OUTPUT" - echo "staging_commit=${staging_commit}" >> "$GITHUB_OUTPUT" - - - name: Deploy image to production - id: deploy-production - env: - PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} - CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.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 - # `cpflow deploy-image` deploys the latest image for the app. The - # workflow-level concurrency group keeps production promotion copy and - # deploy steps coupled across workflow runs. - deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) - - cpflow deploy-image "${deploy_args[@]}" - - - name: Wait for deployment health - id: health-check - uses: ./.cpflow/.github/actions/cpflow-wait-for-health - with: - workload_name: ${{ steps.workloads.outputs.primary }} - app_name: ${{ vars.PRODUCTION_APP_NAME }} - org: ${{ steps.cpln-orgs.outputs.production }} - max_retries: ${{ env.HEALTH_CHECK_RETRIES }} - interval_seconds: ${{ env.HEALTH_CHECK_INTERVAL }} - accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }} - - - name: Roll back on failure - if: >- - failure() && - (steps.deploy-production.outcome == 'success' || steps.deploy-production.outcome == '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: ${{ steps.cpln-orgs.outputs.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. - # Keep -e disabled here so rollback can aggregate failures across workloads. - set -uo pipefail - - rollback_failures=0 - if ! rollback_entries="$(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.containers | @json)"')"; then - echo "::error::Could not parse rollback state; manual recovery may be required." >&2 - exit 1 - fi - - while IFS=$'\t' read -r workload_name previous_containers; do - rollback_args=() - if ! current_names="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -c '.spec.containers | map(.name)')"; then - echo "::warning::Could not retrieve current containers for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - if ! previous_names="$(echo "${previous_containers}" | jq -c 'map(.name)')"; then - echo "::warning::Could not parse captured containers for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - if [[ "$(echo "${current_names}" | jq -c 'sort')" != "$(echo "${previous_names}" | jq -c 'sort')" ]]; then - echo "::error::Container set changed for workload '${workload_name}'; refusing rollback." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - if ! rollback_container_entries="$(jq -r '.[] | "\(.name)\t\(.image)"' <<< "${previous_containers}")"; then - echo "::warning::Could not build rollback image list for workload '${workload_name}'; skipping rollback for this workload." >&2 - rollback_failures=$((rollback_failures + 1)) - continue - fi - - while IFS=$'\t' read -r container_name image; do - rollback_args+=(--set "spec.containers.${container_name}.image=${image}") - done <<< "${rollback_container_entries}" - - 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 <<< "${rollback_entries}" - - 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.deploy-production.outcome == 'success' || steps.deploy-production.outcome == '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: ${{ steps.cpln-orgs.outputs.production }} - shell: bash - run: | - set -euo pipefail - - if ! [[ "${ROLLBACK_READINESS_RETRIES}" =~ ^[1-9][0-9]*$ ]]; then - echo "::error::ROLLBACK_READINESS_RETRIES must be a positive integer." - exit 1 - fi - - if ! [[ "${ROLLBACK_READINESS_INTERVAL}" =~ ^[0-9]+$ ]]; then - echo "::error::ROLLBACK_READINESS_INTERVAL must be a non-negative integer." - exit 1 - fi - - mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') - - # Poll workloads in parallel so the worst-case wall time during a - # production incident is `retries × interval` rather than scaling - # linearly with the number of workloads. Each per-workload retry - # loop runs in a backgrounded subshell that writes its final state - # to a status file; the parent waits for all of them before - # aggregating warnings, keeping output ordered and deterministic. - status_dir="$(mktemp -d)" - trap 'rm -rf "${status_dir}"' EXIT - - pids=() - for workload_name in "${workloads[@]}"; do - [[ -n "${workload_name}" ]] || continue - status_name="${workload_name//\//_}" - - echo "Polling rollback readiness for workload '${workload_name}'..." - ( - set -euo pipefail - ready=false - for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do - workload_status="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" - deployment_ready="$(echo "${workload_status}" | jq -r '.status.ready // false')" - latest_ready="$(echo "${workload_status}" | jq -r '.status.readyLatest // false')" - if [[ "${deployment_ready}" == "true" && "${latest_ready}" == "true" ]]; then - ready=true - break - fi - - if [[ "${attempt}" -lt "${ROLLBACK_READINESS_RETRIES}" ]]; then - sleep "${ROLLBACK_READINESS_INTERVAL}" - fi - done - - if [[ "${ready}" == "true" ]]; then - printf 'ready\n' > "${status_dir}/${status_name}" - else - printf 'not_ready\n' > "${status_dir}/${status_name}" - fi - ) & - pids+=("$!") - done - - # `|| true` so a single workload that fails to poll (e.g. transient - # cpln API error) doesn't abort the parent before the others finish. - # Missing or non-`ready` status files are surfaced in the aggregation - # loop below, so the failure is still visible to operators. - for pid in "${pids[@]}"; do - wait "${pid}" || true - done - - for workload_name in "${workloads[@]}"; do - [[ -n "${workload_name}" ]] || continue - status_name="${workload_name//\//_}" - status_file="${status_dir}/${status_name}" - if [[ ! -f "${status_file}" ]] || [[ "$(<"${status_file}")" != "ready" ]]; then - echo "::warning::Workload '${workload_name}' did not report ready after rollback." - fi - done - - - 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 }} - COPIED_IMAGE: ${{ steps.copy-image.outputs.image }} - shell: bash - run: | - { - echo "## Promotion Summary" - echo - if [[ "${HEALTHY}" == "true" ]]; then - echo "✅ Status: deployment successful" - deployed_image="${COPIED_IMAGE}" - else - echo "❌ Status: deployment failed" - deployed_image="${PREVIOUS_IMAGE}" - fi - echo - echo "Previous image: \`${PREVIOUS_IMAGE}\`" - echo "Previous version: ${PREVIOUS_VERSION}" - echo "Deployed image: \`${deployed_image}\`" - } >> "$GITHUB_STEP_SUMMARY" - - create-github-release: - needs: promote-to-production - if: needs.promote-to-production.result == 'success' - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Create GitHub release - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_RUN_ID: ${{ github.run_id }} - STAGING_APP_NAME: ${{ needs.promote-to-production.outputs.staging_app_name }} - PRODUCTION_APP_NAME: ${{ needs.promote-to-production.outputs.production_app_name }} - STAGING_COMMIT: ${{ needs.promote-to-production.outputs.staging_commit }} - 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}" \ - --target "${STAGING_COMMIT}" \ - --title "Production Release ${release_date} ${timestamp}" \ - --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}." diff --git a/Gemfile b/Gemfile index ed96902..d7d67a1 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,8 @@ gem "mutex_m" # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] gem "sprockets-rails" +# Sprockets 4.2 still passes JSON options removed in JSON 3. +gem "json", "< 3" # Use sqlite3 as the database for Active Record gem "sqlite3", "~> 2.9" diff --git a/Gemfile.lock b/Gemfile.lock index 8f434aa..47b538c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,7 +122,7 @@ GEM sorbet-runtime jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (3.0.2) + json (2.21.2) libv8-node (24.12.0.1-arm64-darwin) libv8-node (24.12.0.1-x86_64-linux) logger (1.7.0) @@ -309,6 +309,7 @@ DEPENDENCIES jbuilder js-routes jsbundling-rails (~> 1.3) + json (< 3) mini_racer (~> 0.22.1) minitest (< 7) mutex_m diff --git a/README.md b/README.md index f9b6c4e..5bbbf12 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,5 @@ This repository is a migrated version of [ganchdev/react-rails-example](https:// ## Control Plane Flow This repo now includes a shared `cpflow` setup for review apps, automatic -staging deploys from `main`, and production promotion. See +staging deploys from `main`. See `.controlplane/readme.md` for the required Control Plane and GitHub setup. diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a7960e9..cfef32b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -25,5 +25,6 @@
    <%= yield %>
    + <%= javascript_pack_tag %> diff --git a/bin/pin-cpflow-github-ref b/bin/pin-cpflow-github-ref index 38e75fe..d9fae2f 100755 --- a/bin/pin-cpflow-github-ref +++ b/bin/pin-cpflow-github-ref @@ -8,8 +8,7 @@ USAGE = <<~USAGE Use a release tag for normal operation, e.g. v5.0.0. Use a full 40-character commit SHA for temporary unreleased upstream testing. - This updates generated reusable-workflow `uses:` refs plus the production - workflow's pinned control-plane-flow checkout and setup validation ref. + This updates generated reusable-workflow `uses:` refs. Regenerate from the cpflow gem when templates changed. Use --allow-moving-ref only for short-lived local branch/ref experiments. USAGE @@ -17,17 +16,6 @@ USAGE ALLOWED_OPTIONS = ["--allow-moving-ref"].freeze FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i RELEASE_TAG = /\Av\d+\.\d+\.\d+(?:[-.][0-9A-Za-z][0-9A-Za-z.-]*)?\z/ -PRODUCTION_WORKFLOW_REF = "shakacode/control-plane-flow/.github/workflows/" \ - "cpflow-promote-staging-to-production.yml" -CPFLOW_CHECKOUT_REF_PATTERN = %r{ - (^\s*-\s+name:\s+Checkout\ control-plane-flow\ actions\s*\n - (?:(?!^\s*-\s+name:).)*? - ^\s+repository:\s+shakacode/control-plane-flow\s*\n - (?:(?!^\s*-\s+name:).)*? - ^\s+ref:\s+) - [^\s]+ -}mx - options, positional = ARGV.partition { |arg| arg.start_with?("--") } unknown_options = options - ALLOWED_OPTIONS @@ -67,12 +55,8 @@ end changed = [] workflow_paths.each do |path| text = File.read(path) - production_setup_ref_pattern = - /(control_plane_flow_ref:\s+#{Regexp.escape(PRODUCTION_WORKFLOW_REF)}@)[^\s]+/ updated = text .gsub(%r{(uses:\s+shakacode/control-plane-flow/\.github/workflows/[^@\s]+@)[^\s]+}, "\\1#{ref}") - .gsub(production_setup_ref_pattern, "\\1#{ref}") - .gsub(CPFLOW_CHECKOUT_REF_PATTERN, "\\1#{ref}") next if updated == text diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index 70c8c07..f49c419 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -43,10 +43,6 @@ ruby <<'RUBY' require "yaml" CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z} -PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-promote-staging-to-production\.yml@([^\s]+)\z} -EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z" -EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" -EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow" refs = Hash.new { |hash, key| hash[key] = [] } @@ -74,69 +70,10 @@ Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| uses_ref = uses_match[1] refs[uses_ref] << "#{path}:#{job_name}" - if job["uses"].to_s.match?(PROMOTE_WORKFLOW) - abort "#{path}:#{job_name} must not call the cross-repo production reusable workflow; use a normal caller-repo job with environment: production" - end end end -promote_path = ".github/workflows/cpflow-promote-staging-to-production.yml" -promote_doc = YAML.load_file(promote_path, aliases: true) -promote_job = promote_doc.fetch("jobs", {})["promote-to-production"] - -unless promote_job - abort "#{promote_path}:promote-to-production job is missing" -end - -if promote_job.key?("uses") - abort "#{promote_path}:promote-to-production must run as a normal caller-repo job, not a reusable workflow, so GitHub can expose production environment secrets" -end - -unless promote_job["environment"].to_s == "production" - abort "#{promote_path}:promote-to-production must declare environment: production" -end - -checkout_step = Array(promote_job["steps"]).find { |step| step["name"] == "Checkout control-plane-flow actions" } - -unless checkout_step - abort "#{promote_path}:promote-to-production must include a Checkout control-plane-flow actions step" -end - -unless checkout_step["uses"] == EXPECTED_CPFLOW_CHECKOUT_ACTION - abort "#{promote_path}:promote-to-production must use #{EXPECTED_CPFLOW_CHECKOUT_ACTION} for the Checkout control-plane-flow actions step" -end - -checkout_with = checkout_step.fetch("with", {}) -checkout_repository = checkout_with["repository"] -checkout_ref = checkout_with["ref"] - -unless checkout_repository == EXPECTED_CPFLOW_CHECKOUT_REPOSITORY - abort "#{promote_path}:promote-to-production must check out #{EXPECTED_CPFLOW_CHECKOUT_REPOSITORY}" -end - -if checkout_ref.to_s.strip.empty? - abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step" -end - -refs[checkout_ref] << "#{promote_path}:promote-to-production" - -setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" } - -unless setup_step - abort "#{promote_path}:promote-to-production must include a Setup production environment step" -end - -setup_ref = setup_step.fetch("with", {})["control_plane_flow_ref"] -setup_match = setup_ref.to_s.match(PROMOTE_WORKFLOW) - -unless setup_match - abort "#{promote_path}:promote-to-production must pass a pinned production control_plane_flow_ref to setup, " \ - "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}" -end - -refs[setup_match[1]] << "#{promote_path}:promote-to-production setup" - if refs.empty? puts "no upstream cpflow reusable workflow refs found" elsif refs.length > 1 diff --git a/test/integration/rendering_test.rb b/test/integration/rendering_test.rb index 410d1c0..7ce15d5 100644 --- a/test/integration/rendering_test.rb +++ b/test/integration/rendering_test.rb @@ -9,6 +9,8 @@ class RenderingTest < ActionDispatch::IntegrationTest assert_select "h2", text: "Tail beef" assert_select 'script[data-component-name="Navbar"]' assert_select 'script[data-component-name="PostsIndex"]' + assert_select 'script[src*="generated/Navbar"]' + assert_select 'script[src*="generated/PostsIndex"]' end test "post details render on the server" do @@ -17,6 +19,8 @@ class RenderingTest < ActionDispatch::IntegrationTest assert_response :success assert_select "h1", text: "Tail beef" assert_select 'script[data-component-name="PostsShow"]' + assert_select 'script[src*="generated/Navbar"]' + assert_select 'script[src*="generated/PostsShow"]' end test "hello world renders with the MiniRacer runtime" do From 80503ee4bf2e3c53bb025feeeeeef3dabef059c2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 22:38:31 -1000 Subject: [PATCH 15/25] Trim trailing blank lines in deployment configuration --- .controlplane/controlplane.yml | 1 - .controlplane/readme.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index 6da7ade..7cdefc9 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -41,4 +41,3 @@ apps: # - name: database # secret_name: react-on-rails-migration-example-review-database-secrets # policy_name: react-on-rails-migration-example-review-database-secrets-policy - diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 9a24bf9..ab64da5 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -52,4 +52,3 @@ Store `CPLN_TOKEN_STAGING` as a repository secret. Set the repository variables apps and automatic staging deploys use that staging organization. The review app prefix is inferred from `.controlplane/controlplane.yml` unless `REVIEW_APP_PREFIX` overrides it. - From 8a9a5dc3ca5c2577f2a98cda39d1fb62718cb28b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 22:46:28 -1000 Subject: [PATCH 16/25] Meet preview volume limits and record the deployed revision --- .controlplane/Dockerfile | 2 ++ .controlplane/templates/db.yml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index d830a17..b7750b3 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -89,6 +89,8 @@ RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK rails assets:precompile # add entrypoint COPY .controlplane/entrypoint.sh ./ RUN chmod +x /app/entrypoint.sh +ARG GIT_COMMIT=unknown +ENV GIT_COMMIT=$GIT_COMMIT ENTRYPOINT ["/app/entrypoint.sh"] CMD ["rails", "s"] diff --git a/.controlplane/templates/db.yml b/.controlplane/templates/db.yml index 921675b..59bddf0 100644 --- a/.controlplane/templates/db.yml +++ b/.controlplane/templates/db.yml @@ -2,5 +2,5 @@ kind: volumeset name: app-db spec: fileSystemType: ext4 - initialCapacity: 5 + initialCapacity: 10 performanceClass: general-purpose-ssd From 570364ec1870b8502869e45720d302e59b81d75b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 22:57:14 -1000 Subject: [PATCH 17/25] Use a stateful SQLite workload and tighten deployment guidance --- .controlplane/readme.md | 5 +++-- .controlplane/templates/rails.yml | 3 +-- .github/cpflow-help.md | 2 +- bin/test-cpflow-github-flow | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.controlplane/readme.md b/.controlplane/readme.md index ab64da5..d256525 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -15,8 +15,9 @@ templates therefore mount persistent volumes at `/app/data` and `/app/storage`, set `DATABASE_PATH=/app/data/production.sqlite3`, and leave the image's `/app/db` migrations visible to the release script. The release script runs `bin/rails db:prepare` before a new image is made live. The Rails workload -remains `standard` with one warm replica while Capacity AI right-sizes its -allocation. +uses one `stateful` replica because SQLite and Active Storage use ext4 volumes. +Each volume starts at the provider's 10 GiB minimum. CPU and memory are fixed; +Capacity AI is not enabled for this stateful workload. ## One-Time Bootstrap diff --git a/.controlplane/templates/rails.yml b/.controlplane/templates/rails.yml index c0d571d..09943cd 100644 --- a/.controlplane/templates/rails.yml +++ b/.controlplane/templates/rails.yml @@ -1,7 +1,7 @@ kind: workload name: rails spec: - type: standard + type: stateful containers: - name: rails cpu: 300m @@ -29,7 +29,6 @@ spec: minScale: 1 maxScale: 1 metric: disabled - capacityAI: true timeoutSeconds: 60 firewallConfig: external: diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index b27042c..ed582a3 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -27,6 +27,7 @@ For the normal generated review-app path, GitHub Actions needs one secret: | Name | Where | Notes | | --- | --- | --- | | `CPLN_TOKEN_STAGING` | GitHub Actions secret | Service-account token scoped to the staging Control Plane org on controlplane.com. | +| `DOCKER_BUILD_SSH_KEY` | Optional GitHub Actions secret | Read-only, revocable deploy key for Docker builds that fetch private dependencies. Do not use a personal SSH key. | For public repositories, use a staging/review token that cannot access production Control Plane resources. Generated review-app deploys skip fork PR @@ -132,7 +133,6 @@ Most apps do not need these: | Name | Notes | | --- | --- | | `DOCKER_BUILD_EXTRA_ARGS` | Newline-delimited extra Docker build tokens. | -| `DOCKER_BUILD_SSH_KEY` | Read-only, revocable deploy key for Docker builds that fetch private dependencies. Do not use a personal SSH key. | | `DOCKER_BUILD_SSH_KNOWN_HOSTS` | SSH known_hosts entries when SSH build hosts are not GitHub.com. | | `REVIEW_APP_DEPLOYING_ICON_URL` | Cosmetic custom image URL for the animated deploying icon. Set to `none` to use the text fallback icon. | | `STAGING_APP_BRANCH` | Custom staging branch. The branch must also appear in `cpflow-deploy-staging.yml`'s push filter. | diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow index f49c419..1bdb316 100755 --- a/bin/test-cpflow-github-flow +++ b/bin/test-cpflow-github-flow @@ -75,7 +75,7 @@ Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| end if refs.empty? - puts "no upstream cpflow reusable workflow refs found" + abort "no upstream cpflow reusable workflow refs found" elsif refs.length > 1 refs.each do |ref, paths| puts "#{ref}: #{paths.uniq.sort.join(', ')}" From 2f96b84c6972ff7cffa3ea3d602b1cc55196cfdf Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 14 Oct 2026 23:06:33 -1000 Subject: [PATCH 18/25] Prepare SQLite in the stateful entrypoint instead of a cron runner --- .controlplane/controlplane.yml | 2 -- .controlplane/readme.md | 4 ++-- .controlplane/release_script.sh | 25 ------------------------- 3 files changed, 2 insertions(+), 29 deletions(-) delete mode 100755 .controlplane/release_script.sh diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index 7cdefc9..b1d5495 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -22,8 +22,6 @@ aliases: - rails additional_workloads: [] - release_script: release_script.sh - stale_app_image_deployed_days: 5 image_retention_days: 7 diff --git a/.controlplane/readme.md b/.controlplane/readme.md index d256525..5930928 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -13,8 +13,8 @@ release, repin them with `bin/pin-cpflow-github-ref `. The app uses SQLite and local Active Storage in production. The Control Plane templates therefore mount persistent volumes at `/app/data` and `/app/storage`, set `DATABASE_PATH=/app/data/production.sqlite3`, and leave the image's -`/app/db` migrations visible to the release script. The release script runs -`bin/rails db:prepare` before a new image is made live. The Rails workload +`/app/db` migrations visible. The Rails entrypoint runs `bin/rails db:prepare` +before starting the server; no cron release runner mounts these volumes. The workload uses one `stateful` replica because SQLite and Active Storage use ext4 volumes. Each volume starts at the provider's 10 GiB minimum. CPU and memory are fixed; Capacity AI is not enabled for this stateful workload. diff --git a/.controlplane/release_script.sh b/.controlplane/release_script.sh deleted file mode 100755 index 3f94bf6..0000000 --- a/.controlplane/release_script.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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" From 8b5bc03205a6ba7a7653be8e54442a2ea465a7ff Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 16 Oct 2026 23:43:30 -1000 Subject: [PATCH 19/25] Keep CI deployment outside secret-policy administration --- .controlplane/controlplane.yml | 3 +++ .controlplane/readme.md | 23 ++++++++++++++++++++--- .controlplane/templates/identity.yml | 2 ++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .controlplane/templates/identity.yml diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml index b1d5495..95612ee 100644 --- a/.controlplane/controlplane.yml +++ b/.controlplane/controlplane.yml @@ -11,8 +11,11 @@ aliases: common: &common cpln_org: shakacode-open-source-examples-staging default_location: aws-us-east-2 + # CI can update this app but cannot administer secret-access policies. + skip_secrets_setup: true setup_app_templates: - app + - identity - db - storage - rails diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 5930928..e0bce68 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -21,6 +21,10 @@ Capacity AI is not enabled for this stateful workload. ## One-Time Bootstrap +The maintainer bootstraps each named app before CI deploys it. CI skips secret +setup and has no policy-administration permission. The explicit identity template +lets CI refresh an existing app without editing its secret-access policy. + Create the shared review-app secret before enabling review deployments. Review apps execute pull-request code, so use a disposable value that grants no access to staging, production, or third-party services: @@ -41,9 +45,22 @@ cpflow setup-app \ --skip-post-creation-hook ``` -Add a distinct `SECRET_KEY_BASE` to the generated staging app -secret dictionary. For later template changes, run `cpflow apply-template` and -ensure the app identity can `reveal` the app secret policy. +Create `react-on-rails-migration-example-staging-secrets` with a distinct +`SECRET_KEY_BASE`. Bootstrap each review app with the same `setup-app` command +and its concrete review-app name before requesting its first CI deployment. + +Outside CI, grant each app identity `reveal` on only its matching runtime +dictionary through a maintainer-owned policy. Keep the default +`-secrets-policy` readable but without direct app-identity bindings; +cpflow cleanup can then inspect it without needing policy-edit permission. +CI needs `use` on the two dictionary references, not their plaintext values. +Limit its app, workload, volume, identity and image permissions to the bootstrapped +app names. Add a new review app to that allowlist before deploying it. + +Seed the first image with `cpflow build-image -a APP --org ORG`, then +`cpflow deploy-image -a APP --org ORG` before enabling CI updates. The pinned +workflow refreshes existing templates while preserving their configured image; +an app with only the initial placeholder image cannot pass that refresh. ## GitHub Configuration diff --git a/.controlplane/templates/identity.yml b/.controlplane/templates/identity.yml new file mode 100644 index 0000000..c383342 --- /dev/null +++ b/.controlplane/templates/identity.yml @@ -0,0 +1,2 @@ +kind: identity +name: "{{APP_IDENTITY}}" From 0b887a3f3106ce6a0e4e40b300626b58d558b05d Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 16 Oct 2026 23:49:05 -1000 Subject: [PATCH 20/25] Align CI help with maintainer-owned secret bootstrap --- .github/cpflow-help.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md index ed582a3..8fb76f2 100644 --- a/.github/cpflow-help.md +++ b/.github/cpflow-help.md @@ -77,15 +77,12 @@ Before the first staging deploy, bootstrap the persistent staging app once: cpflow setup-app -a "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" --skip-post-creation-hook ``` -`setup-app` reads `.controlplane/controlplane.yml`'s `setup_app_templates` and -creates the app identity, app secret dictionary, app secret policy, policy -binding, and template resources. Use `--skip-post-creation-hook` so first-time -bootstrap does not try to run database setup before an image exists. For later -template updates on an existing persistent app, use `cpflow apply-template` -with the same template list and make sure the app identity has `reveal` -permission on the app secret policy. - -Review apps are temporary; bootstrap persistent staging explicitly. +This app sets `skip_secrets_setup: true`: `setup-app` applies its identity and +resource templates but does not create secret dictionaries or access policies. +Follow the [manual bootstrap procedure](../.controlplane/readme.md#one-time-bootstrap) +to provision the separate signing keys, runtime grants and initial image outside +CI. Bootstrap each named staging or review app and allowlist it before enabling +CI updates. The CI credential does not administer access policies.
    Version locking and advanced options From d6e8ea2c373b36dcfc8e779a0dac298e13b3e2a6 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 17 Oct 2026 00:01:16 -1000 Subject: [PATCH 21/25] Pin Ruby for the deployment runner --- .ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..1cf8253 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.6 From b77c2f90694d0af1f277d71bc5610da6ced8b16b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 17 Oct 2026 19:57:24 -1000 Subject: [PATCH 22/25] Use temporary verification and upgrade React on Rails to 17.2.0 --- .controlplane/controlplane.yml | 44 ------ .controlplane/readme.md | 72 --------- .controlplane/templates/app.yml | 19 --- .controlplane/templates/db.yml | 6 - .controlplane/templates/identity.yml | 2 - .controlplane/templates/rails.yml | 40 ----- .controlplane/templates/storage.yml | 6 - .github/cpflow-help.md | 145 ------------------ .../cpflow-cleanup-stale-review-apps.yml | 17 -- .../workflows/cpflow-delete-review-app.yml | 46 ------ .../workflows/cpflow-deploy-review-app.yml | 45 ------ .github/workflows/cpflow-deploy-staging.yml | 24 --- .github/workflows/cpflow-help-command.yml | 26 ---- .github/workflows/cpflow-review-app-help.yml | 21 --- .controlplane/Dockerfile => Dockerfile | 3 +- Gemfile | 2 +- Gemfile.lock | 4 +- README.md | 13 +- .../entrypoint.sh => bin/docker-entrypoint | 0 bin/pin-cpflow-github-ref | 72 --------- bin/test-cpflow-github-flow | 93 ----------- bin/verify-container | 64 ++++++++ package.json | 2 +- yarn.lock | 8 +- 24 files changed, 83 insertions(+), 691 deletions(-) delete mode 100644 .controlplane/controlplane.yml delete mode 100644 .controlplane/readme.md delete mode 100644 .controlplane/templates/app.yml delete mode 100644 .controlplane/templates/db.yml delete mode 100644 .controlplane/templates/identity.yml delete mode 100644 .controlplane/templates/rails.yml delete mode 100644 .controlplane/templates/storage.yml delete mode 100644 .github/cpflow-help.md delete mode 100644 .github/workflows/cpflow-cleanup-stale-review-apps.yml delete mode 100644 .github/workflows/cpflow-delete-review-app.yml delete mode 100644 .github/workflows/cpflow-deploy-review-app.yml delete mode 100644 .github/workflows/cpflow-deploy-staging.yml delete mode 100644 .github/workflows/cpflow-help-command.yml delete mode 100644 .github/workflows/cpflow-review-app-help.yml rename .controlplane/Dockerfile => Dockerfile (98%) rename .controlplane/entrypoint.sh => bin/docker-entrypoint (100%) delete mode 100755 bin/pin-cpflow-github-ref delete mode 100755 bin/test-cpflow-github-flow create mode 100755 bin/verify-container diff --git a/.controlplane/controlplane.yml b/.controlplane/controlplane.yml deleted file mode 100644 index 95612ee..0000000 --- a/.controlplane/controlplane.yml +++ /dev/null @@ -1,44 +0,0 @@ -# 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 SQLite data under `/app/data` and Active Storage under `/app/storage` -# on persistent volumes without hiding migrations under `/app/db`. - -allow_org_override_by_env: true -allow_app_override_by_env: true - -aliases: - common: &common - cpln_org: shakacode-open-source-examples-staging - default_location: aws-us-east-2 - # CI can update this app but cannot administer secret-access policies. - skip_secrets_setup: true - setup_app_templates: - - app - - identity - - db - - storage - - rails - - one_off_workload: rails - app_workloads: - - rails - additional_workloads: [] - - stale_app_image_deployed_days: 5 - image_retention_days: 7 - -apps: - react-on-rails-migration-example-staging: - <<: *common - - react-on-rails-migration-example-review: - <<: *common - match_if_app_name_starts_with: true - # Optional: if a review app needs an existing shared org-level secret, - # declare its policy here and reference it in templates with - # {{SHARED_SECRET_}}. - # shared_secret_grants: - # - name: database - # secret_name: react-on-rails-migration-example-review-database-secrets - # policy_name: react-on-rails-migration-example-review-database-secrets-policy diff --git a/.controlplane/readme.md b/.controlplane/readme.md deleted file mode 100644 index e0bce68..0000000 --- a/.controlplane/readme.md +++ /dev/null @@ -1,72 +0,0 @@ -# Control Plane Deployment Notes - -This repository uses `cpflow` for opt-in pull-request review apps, automatic -staging deploys from `main`. Production promotion is not configured. -The generated GitHub Actions use `cpflow` v5.3.0 and pin the immutable release -commit `b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6`; see -[`.github/cpflow-help.md`](../.github/cpflow-help.md) for the complete commands, -settings, and upgrade procedure. After regenerating wrappers for a future -release, repin them with `bin/pin-cpflow-github-ref `. - -## Runtime Shape - -The app uses SQLite and local Active Storage in production. The Control Plane -templates therefore mount persistent volumes at `/app/data` and `/app/storage`, -set `DATABASE_PATH=/app/data/production.sqlite3`, and leave the image's -`/app/db` migrations visible. The Rails entrypoint runs `bin/rails db:prepare` -before starting the server; no cron release runner mounts these volumes. The workload -uses one `stateful` replica because SQLite and Active Storage use ext4 volumes. -Each volume starts at the provider's 10 GiB minimum. CPU and memory are fixed; -Capacity AI is not enabled for this stateful workload. - -## One-Time Bootstrap - -The maintainer bootstraps each named app before CI deploys it. CI skips secret -setup and has no policy-administration permission. The explicit identity template -lets CI refresh an existing app without editing its secret-access policy. - -Create the shared review-app secret before enabling review deployments. Review -apps execute pull-request code, so use a disposable value that grants no access -to staging, production, or third-party services: - -```sh -cpln secret create-dictionary \ - --name react-on-rails-migration-example-review-secrets \ - --org "$CPLN_ORG_STAGING" \ - --entry "SECRET_KEY_BASE=$(bin/rails secret)" -``` - -Bootstrap persistent staging before its first deploy: - -```sh -cpflow setup-app \ - -a react-on-rails-migration-example-staging \ - --org "$CPLN_ORG_STAGING" \ - --skip-post-creation-hook -``` - -Create `react-on-rails-migration-example-staging-secrets` with a distinct -`SECRET_KEY_BASE`. Bootstrap each review app with the same `setup-app` command -and its concrete review-app name before requesting its first CI deployment. - -Outside CI, grant each app identity `reveal` on only its matching runtime -dictionary through a maintainer-owned policy. Keep the default -`-secrets-policy` readable but without direct app-identity bindings; -cpflow cleanup can then inspect it without needing policy-edit permission. -CI needs `use` on the two dictionary references, not their plaintext values. -Limit its app, workload, volume, identity and image permissions to the bootstrapped -app names. Add a new review app to that allowlist before deploying it. - -Seed the first image with `cpflow build-image -a APP --org ORG`, then -`cpflow deploy-image -a APP --org ORG` before enabling CI updates. The pinned -workflow refreshes existing templates while preserving their configured image; -an app with only the initial placeholder image cannot pass that refresh. - -## GitHub Configuration - -Store `CPLN_TOKEN_STAGING` as a repository secret. Set the repository variables -`CPLN_ORG_STAGING` to `shakacode-open-source-examples-staging` and -`STAGING_APP_NAME` to `react-on-rails-migration-example-staging`; both review -apps and automatic staging deploys use that staging organization. The review -app prefix is inferred from `.controlplane/controlplane.yml` unless -`REVIEW_APP_PREFIX` overrides it. diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml deleted file mode 100644 index c087e05..0000000 --- a/.controlplane/templates/app.yml +++ /dev/null @@ -1,19 +0,0 @@ -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: DATABASE_PATH - value: /app/data/production.sqlite3 - # Review apps execute pull request code, so only mount review-safe values - # through this generated secret dictionary. - - name: SECRET_KEY_BASE - value: "cpln://secret/{{APP_SECRETS}}.SECRET_KEY_BASE" - staticPlacement: - locationLinks: - - "{{APP_LOCATION_LINK}}" diff --git a/.controlplane/templates/db.yml b/.controlplane/templates/db.yml deleted file mode 100644 index 59bddf0..0000000 --- a/.controlplane/templates/db.yml +++ /dev/null @@ -1,6 +0,0 @@ -kind: volumeset -name: app-db -spec: - fileSystemType: ext4 - initialCapacity: 10 - performanceClass: general-purpose-ssd diff --git a/.controlplane/templates/identity.yml b/.controlplane/templates/identity.yml deleted file mode 100644 index c383342..0000000 --- a/.controlplane/templates/identity.yml +++ /dev/null @@ -1,2 +0,0 @@ -kind: identity -name: "{{APP_IDENTITY}}" diff --git a/.controlplane/templates/rails.yml b/.controlplane/templates/rails.yml deleted file mode 100644 index 09943cd..0000000 --- a/.controlplane/templates/rails.yml +++ /dev/null @@ -1,40 +0,0 @@ -kind: workload -name: rails -spec: - type: stateful - containers: - - name: rails - cpu: 300m - inheritEnv: true - image: "{{APP_IMAGE_LINK}}" - memory: 512Mi - ports: - - number: 3000 - # Keep this as `http` even when fronting Rails with Thruster — the Control Plane - # load balancer still serves HTTP/2 to end users. Setting `http2` here causes - # `502 Bad Gateway` with "protocol error". - # See https://www.shakacode.com/control-plane-flow/docs/thruster/ for details. - protocol: http - volumes: - # Keep migrations and schema from the image visible under /app/db; - # only the SQLite data file belongs on persistent storage. - - path: /app/data - recoveryPolicy: retain - uri: cpln://volumeset/app-db - - path: /app/storage - recoveryPolicy: retain - uri: cpln://volumeset/app-storage - defaultOptions: - autoscaling: - minScale: 1 - maxScale: 1 - metric: disabled - timeoutSeconds: 60 - firewallConfig: - external: - inboundAllowCIDR: - - 0.0.0.0/0 - outboundAllowCIDR: - - 0.0.0.0/0 - # cpflow apply-template replaces {{APP_IDENTITY_LINK}} with the app workload identity link. - identityLink: "{{APP_IDENTITY_LINK}}" diff --git a/.controlplane/templates/storage.yml b/.controlplane/templates/storage.yml deleted file mode 100644 index 8956013..0000000 --- a/.controlplane/templates/storage.yml +++ /dev/null @@ -1,6 +0,0 @@ -kind: volumeset -name: app-storage -spec: - fileSystemType: ext4 - initialCapacity: 10 - performanceClass: general-purpose-ssd diff --git a/.github/cpflow-help.md b/.github/cpflow-help.md deleted file mode 100644 index 8fb76f2..0000000 --- a/.github/cpflow-help.md +++ /dev/null @@ -1,145 +0,0 @@ -# Review App Help - -You asked for review app help. -These commands are generated by [cpflow](https://github.com/shakacode/control-plane-flow). - -## Review App Commands - -Comment with exactly one command, with no surrounding text or trailing spaces. -A single trailing newline from GitHub's comment editor is accepted. - -| Command | What it does | -| --- | --- | -| `+review-app-deploy` | Builds the PR image, creates the review app if needed, deploys, and comments with the review URL. | -| `+review-app-delete` | Deletes the review app. This also runs automatically when the PR closes. | -| `+review-app-help` | Posts this help message on the PR. | - -For complete setup, version-pinning, and troubleshooting guidance, see the upstream -[CI automation guide](https://github.com/shakacode/control-plane-flow/blob/v5.3.0/docs/ci-automation.md). - -
    -GitHub Actions setup options - -## GitHub Actions Secrets - -For the normal generated review-app path, GitHub Actions needs one secret: - -| Name | Where | Notes | -| --- | --- | --- | -| `CPLN_TOKEN_STAGING` | GitHub Actions secret | Service-account token scoped to the staging Control Plane org on controlplane.com. | -| `DOCKER_BUILD_SSH_KEY` | Optional GitHub Actions secret | Read-only, revocable deploy key for Docker builds that fetch private dependencies. Do not use a personal SSH key. | - -For public repositories, use a staging/review token that cannot access -production Control Plane resources. Generated review-app deploys skip fork PR -heads because Docker builds use repository secrets. If a forked change needs a -review app, first move the reviewed change to a trusted branch in this -repository. - -## GitHub Actions Variables - -No GitHub Actions variables are required for the standard review-app path when -`.controlplane/controlplane.yml` has exactly one review app entry with -`match_if_app_name_starts_with: true`. cpflow infers the review-app prefix and -staging org from that config. - -Review apps run pull request code. Any value mounted through -`cpln://secret/...` can be read by that code after the workload starts, so keep -review-app secret dictionaries limited to disposable databases, review-only -renderer credentials, and license values that are acceptable for review-app -exposure. - -For public demos, starter staging apps, and long-lived review apps, keep the app -workload `type: standard` with one warm replica, set its autoscaling metric to -`disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and -memory allocation at that fixed replica count. Shared Postgres and other -stateful workloads are the usual exceptions and should stay manually sized; -Capacity AI is for supported stateless app/service workloads. If true idle -scale-to-zero is explicitly required, create a separate `serverless` workload -before the first deploy or plan a delete/recreate migration because Control -Plane will not change an existing `standard` workload to `serverless` in place. - -Optional overrides exist for forks, clones, and unusual apps: - -| Name | Notes | -| --- | --- | -| `CPLN_ORG_STAGING` | Control Plane org on controlplane.com for staging and review apps. Overrides the org inferred from `controlplane.yml`. | -| `REVIEW_APP_PREFIX` | Override the review-app prefix inferred from `controlplane.yml`. | -| `PRIMARY_WORKLOAD` | Public workload used for review URLs and health checks; defaults to `rails`. | - -
    - -## Automatic staging - -Staging deploys use the same `CPLN_TOKEN_STAGING` secret plus `STAGING_APP_NAME`. -Before the first staging deploy, bootstrap the persistent staging app once: - -```sh -cpflow setup-app -a "$STAGING_APP_NAME" --org "$CPLN_ORG_STAGING" --skip-post-creation-hook -``` - -This app sets `skip_secrets_setup: true`: `setup-app` applies its identity and -resource templates but does not create secret dictionaries or access policies. -Follow the [manual bootstrap procedure](../.controlplane/readme.md#one-time-bootstrap) -to provision the separate signing keys, runtime grants and initial image outside -CI. Bootstrap each named staging or review app and allowlist it before enabling -CI updates. The CI credential does not administer access policies. - -
    -Version locking and advanced options - -## Version Locking - -Generated wrappers pin Control Plane Flow with a release tag, for example -`v5.3.0`. Reusable review-app, staging, cleanup, and -helper workflows pin the tag in their `uses:` ref. Production promotion is not configured. - -Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same -checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the -release tag your wrappers are pinned to: a `CPFLOW_VERSION=5.3.x` runtime -override goes with a wrapper pinned to `uses: ...@v5.3.x` (substitute the -release you pinned above). - -After updating the `cpflow` gem in this repo, update the generated wrappers in -the same PR: - -```sh -cpflow update-github-actions -bin/test-cpflow-github-flow -``` - -If `cpflow` is bundled by the app, use: - -```sh -bundle exec cpflow update-github-actions -bin/test-cpflow-github-flow bundle exec cpflow -``` - -Do not leave downstream apps pinned to a moving branch such as `main`. For a -short-lived test of an unreleased upstream PR, pin to a full 40-character commit -SHA and leave `CPFLOW_VERSION` unset: - -```sh -bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha> -bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow -``` - -## Advanced GitHub Actions Variables - -Most apps do not need these: - -| Name | Notes | -| --- | --- | -| `DOCKER_BUILD_EXTRA_ARGS` | Newline-delimited extra Docker build tokens. | -| `DOCKER_BUILD_SSH_KNOWN_HOSTS` | SSH known_hosts entries when SSH build hosts are not GitHub.com. | -| `REVIEW_APP_DEPLOYING_ICON_URL` | Cosmetic custom image URL for the animated deploying icon. Set to `none` to use the text fallback icon. | -| `STAGING_APP_BRANCH` | Custom staging branch. The branch must also appear in `cpflow-deploy-staging.yml`'s push filter. | -| `CPLN_CLI_VERSION` | Pin a specific `@controlplane/cli` version; normally leave unset. | - -The PR-open help workflow posts a short command reference whenever the generated -wrapper exists. That is intentional for configured demo repos. Forks or clones -that copy the workflow before configuring Control Plane can remove -`.github/workflows/cpflow-review-app-help.yml` or uncomment and adapt the -wrapper-level `if:` guard shown in that file, for example -`vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != ''`. - -
    diff --git a/.github/workflows/cpflow-cleanup-stale-review-apps.yml b/.github/workflows/cpflow-cleanup-stale-review-apps.yml deleted file mode 100644 index 6f1fd42..0000000 --- a/.github/workflows/cpflow-cleanup-stale-review-apps.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Cleanup Stale Review Apps - -on: - workflow_dispatch: - schedule: - - cron: "0 0 * * *" - -permissions: - contents: read - -jobs: - cleanup: - # Cleanup targets the current inferred review-app prefix. If you changed - # naming conventions, manually delete review apps under the old prefix. - uses: shakacode/control-plane-flow/.github/workflows/cpflow-cleanup-stale-review-apps.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - secrets: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-delete-review-app.yml b/.github/workflows/cpflow-delete-review-app.yml deleted file mode 100644 index 70c0e1f..0000000 --- a/.github/workflows/cpflow-delete-review-app.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Delete Review App - -run-name: "Delete Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" - -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 - reconcile_intent_run_id: - description: Authenticated internal handoff; manual values are rejected - required: false - type: string - -permissions: - actions: write - contents: read - deployments: write - issues: write - pull-requests: write - -jobs: - delete-review-app: - # pull_request_target is intentional: fork PR-close events need access to - # staging secrets to delete review apps and update PR comments. The upstream - # reusable workflow checks out trusted base-branch action code, not fork code. - # author_association is a cheap caller-side cost filter. The reusable workflow - # still checks the commenter's current repository permission before privileged work. - if: | - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body) && - 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' - # This `if:` mirrors the upstream job guard to avoid a billable workflow_call - # when the event does not match. Keep both conditions in sync. - uses: shakacode/control-plane-flow/.github/workflows/cpflow-delete-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - secrets: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml deleted file mode 100644 index 20bb103..0000000 --- a/.github/workflows/cpflow-deploy-review-app.yml +++ /dev/null @@ -1,45 +0,0 @@ -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 - reconcile_intent_run_id: - description: Authenticated internal handoff; manual values are rejected - required: false - type: string - -permissions: - actions: write - contents: read - deployments: write - issues: write - pull-requests: write - -jobs: - # The reusable job exposes `image_built`; downstream jobs can read - # `needs.deploy.outputs.image_built`. A value of `false` means this check did not validate the Docker image. - deploy: - # author_association is a cheap caller-side cost filter. The reusable workflow - # still checks the commenter's current repository permission before privileged work. - 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 && - contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body) && - contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - secrets: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml deleted file mode 100644 index 2456303..0000000 --- a/.github/workflows/cpflow-deploy-staging.yml +++ /dev/null @@ -1,24 +0,0 @@ -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 unless `cpflow generate-github-actions --staging-branch BRANCH` - # was used. If STAGING_APP_BRANCH is later changed in repository variables, keep - # this list in sync so pushes to that branch actually trigger the workflow. - branches: ["main"] - workflow_dispatch: - -permissions: - contents: read - -jobs: - deploy-staging: - uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-staging.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 - with: - staging_app_branch_default: "main" - secrets: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }} diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml deleted file mode 100644 index 59358f0..0000000 --- a/.github/workflows/cpflow-help-command.yml +++ /dev/null @@ -1,26 +0,0 @@ -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: - contents: read - issues: write - pull-requests: write - -jobs: - help: - if: | - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - contains(fromJson('["+review-app-help","+review-app-help\n","+review-app-help\r\n"]'), github.event.comment.body) && - contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || - github.event_name == 'workflow_dispatch' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-help-command.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml deleted file mode 100644 index b061ca3..0000000 --- a/.github/workflows/cpflow-review-app-help.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Show Review App Commands on PR Open - -on: - # pull_request_target is intentional: it has write permission to comment on PRs from - # forks, where `pull_request` would be read-only. This is safe because no PR code is - # checked out — the job only calls the upstream reusable workflow with a hardcoded - # message. - pull_request_target: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - show-help: - # This is intentionally unconditional: committing this wrapper opts the repo in - # to PR-open help. Remove it, or uncomment and adapt this guard, if forks or - # clones should stay quiet until Control Plane is configured: - # if: vars.REVIEW_APP_PREFIX != '' || vars.CPLN_ORG_STAGING != '' - uses: shakacode/control-plane-flow/.github/workflows/cpflow-review-app-help.yml@b1e5ff4a04adfccfd8b59996e8abdbb5defb3fd6 diff --git a/.controlplane/Dockerfile b/Dockerfile similarity index 98% rename from .controlplane/Dockerfile rename to Dockerfile index b7750b3..d72bad3 100644 --- a/.controlplane/Dockerfile +++ b/Dockerfile @@ -78,6 +78,7 @@ RUN if [ -f package.json ]; then \ fi ENV RAILS_ENV=production +ENV RAILS_SERVE_STATIC_FILES=true # Compiling assets requires any non-blank value, but the placeholder must not # remain in the final image environment. @@ -87,7 +88,7 @@ RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK bin/shakapacker-precompile-hook RUN SECRET_KEY_BASE=NOT_USED_NON_BLANK rails assets:precompile # add entrypoint -COPY .controlplane/entrypoint.sh ./ +COPY bin/docker-entrypoint /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ARG GIT_COMMIT=unknown ENV GIT_COMMIT=$GIT_COMMIT diff --git a/Gemfile b/Gemfile index d7d67a1..64ded9b 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,7 @@ gem "jbuilder" gem "mini_racer", "~> 0.22.1" # The library which provides react helpers and more in rails -gem "react_on_rails", "17.2.0.rc.0" +gem "react_on_rails", "17.2.0" # Modern javscript compressor gem "terser" diff --git a/Gemfile.lock b/Gemfile.lock index 47b538c..4671667 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -226,7 +226,7 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort - react_on_rails (17.2.0.rc.0) + react_on_rails (17.2.0) addressable connection_pool execjs (~> 2.5) @@ -316,7 +316,7 @@ DEPENDENCIES pry-remote puma (~> 8.0) rails (~> 8.1.4) - react_on_rails (= 17.2.0.rc.0) + react_on_rails (= 17.2.0) selenium-webdriver shakapacker (= 10.3.2) sprockets-rails diff --git a/README.md b/README.md index 5bbbf12..c60f528 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,13 @@ This repository is a migrated version of [ganchdev/react-rails-example](https:// - This app is intentionally minimal and focused on migration outcomes. - Generated React on Rails files are kept in-repo so users can inspect exact changes after migration. -## Control Plane Flow +## Temporary Verification -This repo now includes a shared `cpflow` setup for review apps, automatic -staging deploys from `main`. See -`.controlplane/readme.md` for the required Control Plane and GitHub setup. +Run `bin/verify-container` to build the production image and check server-rendered +HTML on `/`, `/posts/0`, and `/hello_world`. Docker must be running. The command +uses a random signing key, an ephemeral SQLite database, and a random localhost +port. It removes its container and image tag on success, failure, or interruption. +No cloud app, persistent volume, registry upload, or permanent endpoint is created. + +For interactive browser checks, use `bin/dev` and stop it afterward. This repository +has no deployment workflows, automatic deployments, or permanent staging app. diff --git a/.controlplane/entrypoint.sh b/bin/docker-entrypoint similarity index 100% rename from .controlplane/entrypoint.sh rename to bin/docker-entrypoint diff --git a/bin/pin-cpflow-github-ref b/bin/pin-cpflow-github-ref deleted file mode 100755 index d9fae2f..0000000 --- a/bin/pin-cpflow-github-ref +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "pathname" - -USAGE = <<~USAGE - Usage: bin/pin-cpflow-github-ref [--allow-moving-ref] - - Use a release tag for normal operation, e.g. v5.0.0. - Use a full 40-character commit SHA for temporary unreleased upstream testing. - This updates generated reusable-workflow `uses:` refs. - Regenerate from the cpflow gem when templates changed. - Use --allow-moving-ref only for short-lived local branch/ref experiments. -USAGE - -ALLOWED_OPTIONS = ["--allow-moving-ref"].freeze -FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i -RELEASE_TAG = /\Av\d+\.\d+\.\d+(?:[-.][0-9A-Za-z][0-9A-Za-z.-]*)?\z/ -options, positional = ARGV.partition { |arg| arg.start_with?("--") } -unknown_options = options - ALLOWED_OPTIONS - -unless unknown_options.empty? - warn "Unknown option(s): #{unknown_options.join(', ')}" - warn USAGE - exit 1 -end - -unless positional.length == 1 - warn USAGE - exit 1 -end - -ref = positional.first -allow_moving_ref = options.include?("--allow-moving-ref") - -unless ref.match?(%r{\A[0-9A-Za-z._/-]+\z}) - warn "Ref contains unsupported characters: #{ref.inspect}" - exit 1 -end - -unless ref.match?(FULL_COMMIT_SHA) || ref.match?(RELEASE_TAG) || allow_moving_ref - warn "Ref must be a full 40-character commit SHA or a v-prefixed release tag: #{ref.inspect}" - warn "Use --allow-moving-ref only for a short-lived branch/ref experiment." - exit 1 -end - -root = Pathname.new(__dir__).join("..").expand_path -workflow_paths = Dir[root.join(".github/workflows/cpflow-*.yml")] - -if workflow_paths.empty? - warn "No cpflow workflow wrappers found." - exit 1 -end - -changed = [] -workflow_paths.each do |path| - text = File.read(path) - updated = text - .gsub(%r{(uses:\s+shakacode/control-plane-flow/\.github/workflows/[^@\s]+@)[^\s]+}, "\\1#{ref}") - - next if updated == text - - File.write(path, updated) - changed << Pathname.new(path).relative_path_from(root).to_s -end - -puts "Pinned cpflow GitHub ref to #{ref}" -if changed.empty? - puts "No files changed." -else - changed.each { |path| puts "updated #{path}" } -end diff --git a/bin/test-cpflow-github-flow b/bin/test-cpflow-github-flow deleted file mode 100755 index 1bdb316..0000000 --- a/bin/test-cpflow-github-flow +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT" - -cpflow_cmd=(cpflow) -if [[ $# -gt 0 ]]; then - cpflow_cmd=("$@") -fi - -echo "==> cpflow github-flow-readiness" -"${cpflow_cmd[@]}" github-flow-readiness - -echo "==> parse generated GitHub Actions YAML" -ruby <<'RUBY' -require "yaml" - -Dir[".github/actions/**/action.yml", ".github/workflows/*.yml"].sort.each do |path| - YAML.load_file(path, aliases: true) - puts "parsed #{path}" -end -RUBY - -echo "==> check composite action input descriptions" -ruby <<'RUBY' -require "yaml" - -bad = [] -Dir[".github/actions/**/action.yml"].sort.each do |path| - doc = YAML.load_file(path, aliases: true) - doc.fetch("inputs", {}).each do |name, spec| - bad << "#{path}:#{name}" if spec["description"].to_s.include?("${{") - end -end - -abort bad.join("\n") unless bad.empty? -puts "no action metadata descriptions contain GitHub expressions" -RUBY - -echo "==> check cpflow reusable workflow refs" -ruby <<'RUBY' -require "yaml" - -CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z} - -refs = Hash.new { |hash, key| hash[key] = [] } - -Dir[".github/workflows/cpflow-*.yml"].sort.each do |path| - doc = YAML.load_file(path, aliases: true) - doc.fetch("jobs", {}).each do |job_name, job| - next unless job.is_a?(Hash) - - with = job["with"].is_a?(Hash) ? job["with"] : {} - input_ref = with["control_plane_flow_ref"] - uses_match = job["uses"].to_s.match(CONTROL_PLANE_FLOW_WORKFLOW) - - if job["secrets"] == "inherit" - abort "#{path}:#{job_name} uses secrets: inherit; pass only the named secrets the upstream workflow needs" - end - - if input_ref - abort "#{path}:#{job_name} passes obsolete control_plane_flow_ref. Remove it; the upstream reusable workflow checks out its own source from GitHub's job.workflow_* context." - end - - unless uses_match - next - end - - uses_ref = uses_match[1] - refs[uses_ref] << "#{path}:#{job_name}" - - - end -end - -if refs.empty? - abort "no upstream cpflow reusable workflow refs found" -elsif refs.length > 1 - refs.each do |ref, paths| - puts "#{ref}: #{paths.uniq.sort.join(', ')}" - end - abort "cpflow workflow wrappers use multiple upstream refs: #{refs.keys.sort.join(', ')}" -else - puts "cpflow refs: #{refs.keys.sort.join(', ')}" -end -RUBY - -echo "==> actionlint" -actionlint \ - -ignore "SC2129" \ - -ignore 'property "workflow_(ref|sha|repository|file_path)" is not defined in object type' \ - .github/workflows/cpflow-*.yml diff --git a/bin/verify-container b/bin/verify-container new file mode 100755 index 0000000..ea87859 --- /dev/null +++ b/bin/verify-container @@ -0,0 +1,64 @@ +#!/usr/bin/env ruby +# Build and exercise a production image without provisioning a hosted app. +require 'json' +require 'net/http' +require 'open3' +require 'securerandom' + +Dir.chdir(File.expand_path('..', __dir__)) +Signal.trap('TERM') { raise Interrupt } +name = "rormig-verification-#{Process.pid}-#{SecureRandom.hex(4)}" +image = "#{name}:local" + +def run!(*command) + abort "Command failed: #{command.first(2).join(' ')}" unless system(*command) +end + +def response_for(url) + uri = URI(url) + Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) do |http| + http.get(uri.request_uri) + end +end + +begin + head, status = Open3.capture2('git', 'rev-parse', 'HEAD') + abort 'Cannot determine the verification revision' unless status.success? + run!('docker', 'build', '--platform', 'linux/amd64', '--build-arg', "GIT_COMMIT=#{head.strip}", '--tag', image, '.') + env = { 'SECRET_KEY_BASE' => SecureRandom.hex(64) } + output, error, status = Open3.capture3(env, 'docker', 'run', '--detach', '--rm', '--name', name, + '--publish', '127.0.0.1::3000', '--env', 'SECRET_KEY_BASE', + '--env', 'DATABASE_PATH=/tmp/verification.sqlite3', image, 'bin/rails', 'server', '-b', '0.0.0.0') + abort "Container startup failed: #{error}" unless status.success? + port, status = Open3.capture2('docker', 'port', name, '3000/tcp') + abort 'Cannot determine the localhost port' unless status.success? + base = "http://#{port.strip}" + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 120 + loop do + begin + response = response_for("#{base}/hello_world") + break if response.code == '200' + rescue IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout + # Rails may still be preparing SQLite and starting Puma. + end + abort 'Container did not become healthy within 120 seconds' if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + sleep 1 + end + checked_assets = [] + { '/' => 'Tail beef', '/posts/0' => 'Tail beef', '/hello_world' => 'Hello, Stranger!' }.each do |path, heading| + response = response_for("#{base}#{path}") + headings = response.body.scan(/]*>(.*?)<\/h[1-3]>/m).flatten.map { |html| html.gsub(/<[^>]*>/, '') } + abort "SSR check failed for #{path}" unless response.code == '200' && headings.any? { |text| text.include?(heading) } && response.body.include?('data-component-name=') + response.body.scan(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/i).flatten.uniq.each do |asset| + next unless asset.start_with?('/') && !asset.start_with?('//') + next if checked_assets.include?(asset) + abort "Asset check failed for #{asset}" unless response_for("#{base}#{asset}").code == '200' + checked_assets << asset + end + puts "PASS #{path}: HTTP 200, server HTML and reachable assets" + end + puts "Verified working-tree image (base revision #{head.strip}); localhost-only container #{name}" +ensure + system('docker', 'rm', '--force', name, out: File::NULL, err: File::NULL) + system('docker', 'image', 'rm', image, out: File::NULL, err: File::NULL) +end diff --git a/package.json b/package.json index f5c9c31..e6736e0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "react": "19.3.0", "react-dom": "19.3.0", "react-jazzicon": "^1.0.4", - "react-on-rails": "17.2.0-rc.0", + "react-on-rails": "17.2.0", "react_ujs": "^3.3.1", "sass-loader": "^17.0.1", "shakapacker": "10.3.2", diff --git a/yarn.lock b/yarn.lock index 607e8b6..feddcda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2786,10 +2786,10 @@ react-jazzicon@^1.0.4: dependencies: mersenne-twister "^1.1.0" -react-on-rails@17.2.0-rc.0: - version "17.2.0-rc.0" - resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-17.2.0-rc.0.tgz#32ab8f2c521a02dffe162d9c1957969da3bd2d21" - integrity sha512-Ew810LOL2gRiwZEaCjwvslShJX+/Ec7fZvGGX8L+VO0UMxCcoXy6sfAy8GfGoAjyHS7x/2lt59eCIYjPc2l53w== +react-on-rails@17.2.0: + version "17.2.0" + resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-17.2.0.tgz#6dfb1f9b9eb2c8912c5b5d2d06230b484d80cd3a" + integrity sha512-t6vOTT7fnyIB4VtW3chl3b1DcmQVR01sNhg1nevTol0hySweWCdigAOCfFyTDyGgSiXYD2ZGrZUSKGz2oVOm3Q== react-refresh@0.19.0: version "0.19.0" From 0f8ff184f5eed8d0cecd99495871ce3464f1fc29 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 17 Oct 2026 20:02:43 -1000 Subject: [PATCH 23/25] Handle malformed Unicode in the server encoder shim --- config/webpack/serverWebpackConfig.js | 3 ++- test/integration/rendering_test.rb | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 53f9b34..f780b90 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -59,7 +59,8 @@ const configureServer = () => { if (typeof globalThis.TextEncoder === 'undefined') { globalThis.TextEncoder = class TextEncoder { encode(input = '') { - const encoded = unescape(encodeURIComponent(String(input))); + // TextEncoder replaces unpaired UTF-16 surrogates instead of throwing. + const encoded = unescape(encodeURIComponent(String(input).toWellFormed())); return Uint8Array.from(encoded, (character) => character.charCodeAt(0)); } }; diff --git a/test/integration/rendering_test.rb b/test/integration/rendering_test.rb index 7ce15d5..37c1172 100644 --- a/test/integration/rendering_test.rb +++ b/test/integration/rendering_test.rb @@ -31,4 +31,11 @@ class RenderingTest < ActionDispatch::IntegrationTest assert_select 'input#name[value="Stranger"]' assert_select 'script[data-component-name="HelloWorld"]' end + + test "server text encoding replaces lone surrogates and retains Unicode" do + context = ReactOnRails::ServerRenderingPool.pool.create_js_context + + assert_equal [239, 191, 189], context.eval(%q{Array.from(new TextEncoder().encode("\uD83D"))}) + assert_equal [195, 169, 240, 159, 152, 128], context.eval(%q{Array.from(new TextEncoder().encode("é😀"))}) + end end From 2e927f96ec57bd4b9ea2803a418caccd348d65d8 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 17 Oct 2026 20:08:01 -1000 Subject: [PATCH 24/25] Apply server environment overrides before database preparation --- bin/docker-entrypoint | 11 +++++++++++ test/lib/docker_entrypoint_test.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 test/lib/docker_entrypoint_test.rb diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index b041530..811978a 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -40,6 +40,17 @@ is_rails_server_command() { # Generated Dockerfiles use WORKDIR /app; adjust this path if your hand-edited # image runs the entrypoint from a different working directory. if is_rails_server_command "$@"; then + # Apply the recognized env prefix to preparation as well as the server. + if [ "${1:-}" = "env" ]; then + shift + while [ "$#" -gt 0 ]; do + case "$1" in + *=*) export "$1"; shift ;; + --) shift; break ;; + *) break ;; + esac + done + fi echo " -- Preparing database" ./bin/rails db:prepare fi diff --git a/test/lib/docker_entrypoint_test.rb b/test/lib/docker_entrypoint_test.rb new file mode 100644 index 0000000..c6fc58a --- /dev/null +++ b/test/lib/docker_entrypoint_test.rb @@ -0,0 +1,29 @@ +require "test_helper" +require "tmpdir" +require "fileutils" +require "open3" + +class DockerEntrypointTest < ActiveSupport::TestCase + test "env-prefixed server commands prepare the same database they run" do + Dir.mktmpdir("rormig-entrypoint-") do |directory| + FileUtils.mkdir_p(File.join(directory, "bin")) + rails = File.join(directory, "bin/rails") + log = File.join(directory, "commands.log") + File.write(rails, <<~'SH') + #!/bin/sh + printf '%s|%s|%s\n' "$DATABASE_PATH" "$RAILS_ENV" "$*" >> "$ENTRYPOINT_PROBE" + SH + FileUtils.chmod(0o755, rails) + _output, error, status = Open3.capture3( + { "DATABASE_PATH" => "parent.sqlite3", "RAILS_ENV" => "parent", "ENTRYPOINT_PROBE" => log }, + Rails.root.join("bin/docker-entrypoint").to_s, + "env", "DATABASE_PATH=custom.sqlite3", "RAILS_ENV=production", "bin/rails", "server", + chdir: directory + ) + + assert status.success?, error + assert_equal ["custom.sqlite3|production|db:prepare", "custom.sqlite3|production|server"], + File.readlines(log, chomp: true) + end + end +end From 8467bc5c01cf41d27ca579cd82eb624f98ebe195 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 17 Oct 2026 20:14:04 -1000 Subject: [PATCH 25/25] Limit temporary database preparation to direct Rails commands --- README.md | 4 ++ bin/docker-entrypoint | 62 ++++-------------------------- test/lib/docker_entrypoint_test.rb | 38 ++++++++++++------ 3 files changed, 38 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index c60f528..ea259af 100644 --- a/README.md +++ b/README.md @@ -32,5 +32,9 @@ uses a random signing key, an ephemeral SQLite database, and a random localhost port. It removes its container and image tag on success, failure, or interruption. No cloud app, persistent volume, registry upload, or permanent endpoint is created. +The image prepares SQLite automatically only for direct `rails server` / `rails s` +commands, including `bin/rails`. Pass runtime overrides with Docker's `--env` +flags. Custom wrapped commands must prepare their database explicitly. + For interactive browser checks, use `bin/dev` and stop it afterward. This repository has no deployment workflows, automatic deployments, or permanent staging app. diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 811978a..1b6e87a 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,59 +1,13 @@ #!/bin/sh set -e -# Runs before the main command -is_rails_server_command() { - if [ "${1:-}" = "env" ]; then - shift - while [ "$#" -gt 0 ]; do - case "${1}" in - *=*) shift ;; - --) shift; break ;; - -*) return 1 ;; - *) break ;; - esac - done - fi +# Only direct Rails server commands prepare the temporary database automatically. +# Pass runtime overrides with docker run --env; wrapped commands prepare explicitly. +case "${1:-}:${2:-}" in + rails:s|rails:server|bin/rails:s|bin/rails:server|./bin/rails:s|./bin/rails:server) + echo " -- Preparing database" + ./bin/rails db:prepare + ;; +esac - if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then - shift 2 - fi - - # Matches generated, optionally env-prefixed, flag-free Thruster invocations. - # Hand-edited commands with env flags or Thruster flags before rails skip - # generated DB prep. - if [ "${1:-}" = "thrust" ] || [ "${1:-}" = "bin/thrust" ] || [ "${1:-}" = "./bin/thrust" ]; then - shift - fi - - # Thruster may be wrapped with its own `bundle exec`, and the Rails command - # it proxies may also be wrapped with `bundle exec`. - if [ "${1:-}" = "bundle" ] && [ "${2:-}" = "exec" ]; then - shift 2 - fi - - { [ "${1:-}" = "rails" ] || [ "${1:-}" = "bin/rails" ] || [ "${1:-}" = "./bin/rails" ]; } && - { [ "${2:-}" = "server" ] || [ "${2:-}" = "s" ]; } -} - -# Match generated Rails server commands; workers and renderers skip DB prep. -# Generated Dockerfiles use WORKDIR /app; adjust this path if your hand-edited -# image runs the entrypoint from a different working directory. -if is_rails_server_command "$@"; then - # Apply the recognized env prefix to preparation as well as the server. - if [ "${1:-}" = "env" ]; then - shift - while [ "$#" -gt 0 ]; do - case "$1" in - *=*) export "$1"; shift ;; - --) shift; break ;; - *) break ;; - esac - done - fi - echo " -- Preparing database" - ./bin/rails db:prepare -fi - -echo " -- Finishing entrypoint.sh, executing command" exec "$@" diff --git a/test/lib/docker_entrypoint_test.rb b/test/lib/docker_entrypoint_test.rb index c6fc58a..d37398b 100644 --- a/test/lib/docker_entrypoint_test.rb +++ b/test/lib/docker_entrypoint_test.rb @@ -4,26 +4,40 @@ require "open3" class DockerEntrypointTest < ActiveSupport::TestCase - test "env-prefixed server commands prepare the same database they run" do + test "direct server commands prepare with the inherited runtime environment" do + assert_equal ["custom.sqlite3|production||db:prepare", "custom.sqlite3|production||server"], + probe("bin/rails", "server") + end + + test "wrapped commands keep native env behavior and own their preparation" do + assert_equal ["override.sqlite3|production|1|server"], + probe("env", "DATABASE_PATH=override.sqlite3", "SOME.FLAG=1", "bin/rails", "server") + end + + test "non-server commands skip database preparation" do + assert_equal ["custom.sqlite3|production||runner"], probe("bin/rails", "runner") + end + + private + + def probe(*command) Dir.mktmpdir("rormig-entrypoint-") do |directory| FileUtils.mkdir_p(File.join(directory, "bin")) rails = File.join(directory, "bin/rails") log = File.join(directory, "commands.log") - File.write(rails, <<~'SH') - #!/bin/sh - printf '%s|%s|%s\n' "$DATABASE_PATH" "$RAILS_ENV" "$*" >> "$ENTRYPOINT_PROBE" - SH + File.write(rails, <<~'SCRIPT') + #!/usr/bin/env ruby + File.open(ENV.fetch("ENTRYPOINT_PROBE"), "a") do |file| + file.puts [ENV["DATABASE_PATH"], ENV["RAILS_ENV"], ENV["SOME.FLAG"], ARGV.join(" ")].join("|") + end + SCRIPT FileUtils.chmod(0o755, rails) _output, error, status = Open3.capture3( - { "DATABASE_PATH" => "parent.sqlite3", "RAILS_ENV" => "parent", "ENTRYPOINT_PROBE" => log }, - Rails.root.join("bin/docker-entrypoint").to_s, - "env", "DATABASE_PATH=custom.sqlite3", "RAILS_ENV=production", "bin/rails", "server", - chdir: directory + { "DATABASE_PATH" => "custom.sqlite3", "RAILS_ENV" => "production", "ENTRYPOINT_PROBE" => log }, + Rails.root.join("bin/docker-entrypoint").to_s, *command, chdir: directory ) - assert status.success?, error - assert_equal ["custom.sqlite3|production|db:prepare", "custom.sqlite3|production|server"], - File.readlines(log, chomp: true) + File.readlines(log, chomp: true) end end end