diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index a36aae827..6239d4c2c 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -72,7 +72,8 @@ RUN SECRET_KEY_BASE=precompile_placeholder bin/rails react_on_rails:locale # and /app/client/app are the client assets that are bundled, so not needed once built # Helps to have smaller images b/c of smaller Docker Layer Caches and smaller final images # SECRET_KEY_BASE is required for asset precompilation but is not persisted in the image -RUN SECRET_KEY_BASE=precompile_placeholder yarn res:build && \ +RUN SECRET_KEY_BASE=precompile_placeholder bundle exec rake react_on_rails:generate_packs && \ + SECRET_KEY_BASE=precompile_placeholder yarn res:build && \ SECRET_KEY_BASE=precompile_placeholder bin/rails assets:precompile && \ rm -rf /app/lib/bs /app/client/app diff --git a/.controlplane/entrypoint.sh b/.controlplane/entrypoint.sh index d80de4c3f..48bba8b78 100755 --- a/.controlplane/entrypoint.sh +++ b/.controlplane/entrypoint.sh @@ -21,7 +21,7 @@ echo " -- Waiting for services" wait_for_service $(echo $DATABASE_URL | sed -e 's|^.*@||' -e 's|/.*$||') wait_for_service $(echo $REDIS_URL | sed -e 's|redis://||' -e 's|/.*$||') -echo " -- Finishing entrypoint.sh, executing '$@'" +echo " -- Finishing entrypoint.sh, executing '$*'@'" # Run the main command exec "$@" diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 5ac7ff3c1..827ce4b4f 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -6,7 +6,7 @@ _If you need a free demo account for Control Plane (no CC required), you can con --- -Check [how the `cpflow` gem (this project) is used in the Github actions](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.github/actions/deploy-to-control-plane/action.yml). +See the reusable `cpflow-*` GitHub Actions files in this repo's [`.github`](https://github.com/shakacode/react-webpack-rails-tutorial/tree/master/.github) directory for review apps, staging deploys, and production promotion. Here is a brief [video overview](https://www.youtube.com/watch?v=llaQoAV_6Iw). --- @@ -69,55 +69,99 @@ You should be able to see this information in the Control Plane UI. and not `cpln` which is the Control Plane CLI. ```sh -# Use environment variable to prevent repetition -export APP_NAME=react-webpack-rails-tutorial +# Use the staging app defined in .controlplane/controlplane.yml +export APP_NAME=react-webpack-rails-tutorial-staging # Provision all infrastructure on Control Plane. -# app react-webpack-rails-tutorial will be created per definition in .controlplane/controlplane.yml -cpflow setup-app -a $APP_NAME +cpflow setup-app -a "$APP_NAME" -# Build and push docker image to Control Plane repository -# Note, may take many minutes. Be patient. -# Check for error messages, such as forgetting to run `cpln image docker-login --org ` -cpflow build-image -a $APP_NAME +# Build and push the Docker image to the Control Plane registry. +cpflow build-image -a "$APP_NAME" -# Promote image to app after running `cpflow build-image command` -# Note, the UX of images may not show the image for up to 5 minutes. -# However, it's ready. -cpflow deploy-image -a $APP_NAME +# Run the configured release phase before cutting staging over to the new image. +cpflow deploy-image -a "$APP_NAME" --run-release-phase -# See how app is starting up -cpflow logs -a $APP_NAME +# See how the app is starting up +cpflow logs -a "$APP_NAME" # Open app in browser (once it has started up) -cpflow open -a $APP_NAME +cpflow open -a "$APP_NAME" ``` ### Promoting code updates -After committing code, you will update your deployment of `react-webpack-rails-tutorial` with the following commands: +After committing code, you will update your staging deployment with the following commands: ```sh -# Assuming you have already set APP_NAME env variable to react-webpack-rails-tutorial -# Build and push new image with sequential image tagging, e.g. 'react-webpack-rails-tutorial:1', then 'react-webpack-rails-tutorial:2', etc. -cpflow build-image -a $APP_NAME +# Assuming APP_NAME is still react-webpack-rails-tutorial-staging +cpflow build-image -a "$APP_NAME" # Run database migrations (or other release tasks) with latest image, # while app is still running on previous image. # This is analogous to the release phase. -cpflow run -a $APP_NAME --image latest -- rails db:migrate +cpflow run -a "$APP_NAME" --image latest -- rails db:migrate # Pomote latest image to app after migrations run -cpflow deploy-image -a $APP_NAME +cpflow deploy-image -a "$APP_NAME" --run-release-phase ``` If you needed to push a new image with a specific commit SHA, you can run the following command: ```sh -# Build and push with sequential image tagging and commit SHA, e.g. 'react-webpack-rails-tutorial:123_ABCD' -cpflow build-image -a $APP_NAME --commit ABCD +# Build and push with sequential image tagging and commit SHA +cpflow build-image -a "$APP_NAME" --commit ABCD ``` +## GitHub Actions Flow + +This repo now uses the shared `cpflow-*` GitHub Actions scaffolding: + +- `.github/workflows/cpflow-review-app-help.yml` +- `.github/workflows/cpflow-help-command.yml` +- `.github/workflows/cpflow-deploy-review-app.yml` +- `.github/workflows/cpflow-delete-review-app.yml` +- `.github/workflows/cpflow-deploy-staging.yml` +- `.github/workflows/cpflow-promote-staging-to-production.yml` +- `.github/workflows/cpflow-cleanup-stale-review-apps.yml` + +Behavior: + +- comment `/deploy-review-app` on a PR to create or update a review app +- later pushes to that PR auto-redeploy the existing review app +- pushes to `master` auto-deploy staging unless `STAGING_APP_BRANCH` overrides it +- production promotion happens manually from the Actions tab +- stale review apps are cleaned up nightly + +This repo keeps its historical `qa-react-webpack-rails-tutorial` prefix for review apps, so: + +- `REVIEW_APP_PREFIX=qa-react-webpack-rails-tutorial` +- PR 123 deploys to `qa-react-webpack-rails-tutorial-123` + +Required GitHub repository secrets: + +- `CPLN_TOKEN_STAGING` +- `CPLN_TOKEN_PRODUCTION` + +Required GitHub repository variables: + +- `CPLN_ORG_STAGING` +- `CPLN_ORG_PRODUCTION` +- `STAGING_APP_NAME=react-webpack-rails-tutorial-staging` +- `PRODUCTION_APP_NAME=react-webpack-rails-tutorial-production` +- `REVIEW_APP_PREFIX=qa-react-webpack-rails-tutorial` + +Optional variables: + +- `STAGING_APP_BRANCH=master` +- `PRIMARY_WORKLOAD=rails` +- `DOCKER_BUILD_EXTRA_ARGS` + +Operational notes: + +- `/deploy-review-app` and `/delete-review-app` only run for trusted commenters (`OWNER`, `MEMBER`, `COLLABORATOR`) +- fork PRs still receive help comments, but review app deploys are skipped because the workflow builds Docker images with repository secrets +- PR pushes do not auto-create review apps; the first deploy remains opt-in + ## HTTP/2 and Thruster Configuration This application uses [Thruster](https://github.com/basecamp/thruster), a zero-config HTTP/2 proxy from Basecamp, for optimized performance on Control Plane. @@ -362,29 +406,12 @@ openssl rand -hex 64 ## CI Automation, Review Apps and Staging -_Note, some of the URL references are internal for the ShakaCode team._ - - Review Apps (deployment of apps based on a PR) are done via Github Actions. - -The review apps work by creating isolated deployments for each branch through this automated process. When a branch is pushed, the action: - -1. Sets up the necessary environment and tools -2. Creates a unique deployment for that branch if it doesn't exist -3. Builds a Docker image tagged with the branch's commit SHA -4. Deploys this image to Control Plane with its own isolated environment - -This allows teams to: -- Preview changes in a production-like environment -- Test features independently -- Share working versions with stakeholders -- Validate changes before merging to main branches - -The system uses Control Plane's infrastructure to manage these deployments, with each branch getting its own resources as defined in the controlplane.yml configuration. - +Review apps, staging deploys, and production promotion are all driven by the +`cpflow-*` workflows in `.github/workflows/`. -### Workflow for Developing Github Actions for Review Apps +### Workflow for Developing GitHub Actions for Review Apps -1. Create a PR with changes to the Github Actions workflow -2. Make edits to file such as `.github/actions/deploy-to-control-plane/action.yml` -3. Run a script like `ga .github && gc -m fixes && gp` to commit and push changes (ga = git add, gc = git commit, gp = git push) -4. Check the Github Actions tab in the PR to see the status of the workflow +1. Create a PR with changes to the GitHub Actions workflow. +2. Make edits to files such as `.github/workflows/cpflow-deploy-review-app.yml` or `.github/actions/cpflow-build-docker-image/action.yml`. +3. Commit and push the `.github` changes. +4. Check the GitHub Actions tab in the PR to see the status of the workflow. diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml deleted file mode 100644 index 45a12434c..000000000 --- a/.github/actions/build-docker-image/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build Docker Image -description: 'Builds a Docker image for the application' - -inputs: - app_name: - description: 'Name of the application' - required: true - org: - description: 'Organization name' - required: true - commit: - description: 'Commit SHA to tag the image with' - required: true - PR_NUMBER: - description: 'PR number' - required: false - -runs: - using: "composite" - steps: - - name: Build Docker Image - id: build - shell: bash - run: | - PR_INFO="" - if [ -n "${PR_NUMBER}" ]; then - PR_INFO=" for PR #${PR_NUMBER}" - fi - - echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..." - - if cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}"; then - 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 }})" - else - echo "❌ Docker image build failed${PR_INFO} (commit ${{ inputs.commit }})" - exit 1 - fi 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 000000000..e7fbf458f --- /dev/null +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -0,0 +1,92 @@ +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 new file mode 100644 index 000000000..63981dd59 --- /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 000000000..6b70722ac --- /dev/null +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -0,0 +1,43 @@ +#!/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 new file mode 100644 index 000000000..57b5f3f82 --- /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/actions/delete-control-plane-app/action.yml b/.github/actions/delete-control-plane-app/action.yml deleted file mode 100644 index caaef2729..000000000 --- a/.github/actions/delete-control-plane-app/action.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Delete Control Plane App -description: 'Deletes a Control Plane application and all its resources' - -inputs: - app_name: - description: 'Name of the application to delete' - required: true - cpln_org: - description: 'Organization name' - 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 }} diff --git a/.github/actions/delete-control-plane-app/delete-app.sh b/.github/actions/delete-control-plane-app/delete-app.sh deleted file mode 100755 index 6bc92bfcb..000000000 --- a/.github/actions/delete-control-plane-app/delete-app.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Script to delete a Control Plane application -# Required environment variables: -# - APP_NAME: Name of the application to delete -# - CPLN_ORG: Organization name - -set -e - -# Validate required environment variables -: "${APP_NAME:?APP_NAME environment variable is required}" -: "${CPLN_ORG:?CPLN_ORG environment variable is required}" - -# Safety check: prevent deletion of production or staging apps -if echo "$APP_NAME" | grep -iqE '(production|staging)'; then - echo "❌ ERROR: Cannot delete apps containing 'production' or 'staging' in their name" >&2 - echo "🛑 This is a safety measure to prevent accidental deletion of production or staging environments" >&2 - echo " App name: $APP_NAME" >&2 - exit 1 -fi - -# Check if app exists before attempting to delete -echo "🔍 Checking if application exists: $APP_NAME" -if ! cpflow exists -a "$APP_NAME" --org "$CPLN_ORG"; then - echo "⚠️ Application does not exist: $APP_NAME" - exit 0 -fi - -# Delete the application -echo "🗑️ Deleting application: $APP_NAME" -if ! cpflow delete -a "$APP_NAME" --org "$CPLN_ORG" --yes; then - echo "❌ Failed to delete application: $APP_NAME" >&2 - exit 1 -fi - -echo "✅ Successfully deleted application: $APP_NAME" diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml deleted file mode 100644 index cdd7fb272..000000000 --- a/.github/actions/setup-environment/action.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Control Plane GitHub Action - -name: 'Setup Environment' -description: 'Sets up Ruby, installs Control Plane CLI, cpflow gem, and sets up the default profile' - -inputs: - token: - description: 'Control Plane token' - required: true - org: - description: 'Control Plane organization' - required: true - -runs: - using: 'composite' - steps: - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.4.6' - - - name: Install Control Plane CLI and cpflow gem - shell: bash - run: | - sudo npm install -g @controlplane/cli@3.3.1 - cpln --version - gem install cpflow -v 4.1.1 - cpflow --version - - - name: Setup Control Plane Profile - shell: bash - run: | - TOKEN="${{ inputs.token }}" - ORG="${{ inputs.org }}" - - if [ -z "$TOKEN" ]; then - echo " Error: Control Plane token not provided" - exit 1 - fi - - if [ -z "$ORG" ]; then - echo " Error: Control Plane organization not provided" - exit 1 - fi - - echo "Setting up Control Plane profile..." - echo "Organization: $ORG" - cpln profile update default --org "$ORG" --token "$TOKEN" - - echo "Setting up Docker login for Control Plane registry..." - 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 000000000..d8dc5a951 --- /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 000000000..7a29629c8 --- /dev/null +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -0,0 +1,128 @@ +name: Delete Review App + +on: + pull_request_target: + types: [closed] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number targeted for deletion + required: true + type: number + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: cpflow-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: + if: | + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/delete-review-app' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_target' && github.event.action == 'closed') || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.REVIEW_APP_PREFIX }}" ]] || missing+=("variable:REVIEW_APP_PREFIX") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Set workflow links + uses: actions/github-script@v7 + with: + script: | + const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + core.exportVariable("WORKFLOW_URL", workflowUrl); + core.exportVariable( + "CONSOLE_URL", + `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/-info` + ); + + - name: Create initial PR comment + id: create-comment + uses: actions/github-script@v7 + with: + script: | + const comment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body: "🗑️ Deleting Control Plane review app..." + }); + core.setOutput("comment-id", comment.data.id); + + - name: Delete review app + uses: ./.github/actions/cpflow-delete-control-plane-app + with: + app_name: ${{ env.APP_NAME }} + cpln_org: ${{ vars.CPLN_ORG_STAGING }} + review_app_prefix: ${{ vars.REVIEW_APP_PREFIX }} + + - name: Finalize delete status + if: always() + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + const success = "${{ job.status }}" === "success"; + const body = success + ? [ + `✅ Review app for PR #${process.env.PR_NUMBER} is deleted`, + "", + `[Open organization console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n") + : [ + `❌ Failed to delete review app for PR #${process.env.PR_NUMBER}`, + "", + `[Open organization console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping delete status comment update because no comment id was created."); + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); diff --git a/.github/workflows/cpflow-deploy-review-app.yml b/.github/workflows/cpflow-deploy-review-app.yml new file mode 100644 index 000000000..e662b805a --- /dev/null +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -0,0 +1,382 @@ +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 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 + }); diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml new file mode 100644 index 000000000..00fbe80c0 --- /dev/null +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -0,0 +1,123 @@ +name: Deploy Staging to Control Plane + +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: + +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 diff --git a/.github/workflows/cpflow-help-command.yml b/.github/workflows/cpflow-help-command.yml new file mode 100644 index 000000000..1430886f4 --- /dev/null +++ b/.github/workflows/cpflow-help-command.yml @@ -0,0 +1,82 @@ +name: Review App Help Command + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to post help on + required: true + type: number + +permissions: + issues: write + pull-requests: write + +jobs: + help: + if: | + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/help' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - name: Post help message + uses: actions/github-script@v7 + with: + script: | + const helpText = [ + "# Control Plane GitHub Flow", + "", + "## PR commands", + "", + "`/deploy-review-app`", + "- Creates the review app if it does not exist", + "- Builds the PR commit image", + "- Deploys the image and comments with the review URL", + "", + "`/delete-review-app`", + "- Deletes the review app when the PR is done", + "- This also runs automatically when the PR closes", + "", + "## Repository secrets", + "", + "- `CPLN_TOKEN_STAGING`", + "- `CPLN_TOKEN_PRODUCTION`", + "- `DOCKER_BUILD_SSH_KEY` (optional, when Docker builds fetch private dependencies over SSH)", + "", + "## Repository variables", + "", + "- `CPLN_ORG_STAGING`", + "- `CPLN_ORG_PRODUCTION`", + "- `STAGING_APP_NAME`", + "- `PRODUCTION_APP_NAME`", + "- `REVIEW_APP_PREFIX`", + "- `STAGING_APP_BRANCH` (optional, defaults to `main` or `master`)", + "- `PRIMARY_WORKLOAD` (optional, defaults to `rails`)", + "- `DOCKER_BUILD_EXTRA_ARGS` (optional Docker build flags)", + "- `DOCKER_BUILD_SSH_KNOWN_HOSTS` (optional when SSH build hosts are not GitHub.com)", + "", + "## Workflow behavior", + "", + "- Review apps are opt-in and created with `/deploy-review-app`", + "- New commits redeploy existing review apps automatically", + "- Pushes to the staging branch deploy staging automatically", + "- Promotion to production is manual via the Actions tab", + "- A nightly workflow removes stale review apps" + ].join("\n"); + + const prNumber = context.eventName === "workflow_dispatch" + ? Number(context.payload.inputs.pr_number) + : context.issue.number; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: helpText + }); diff --git a/.github/workflows/cpflow-promote-staging-to-production.yml b/.github/workflows/cpflow-promote-staging-to-production.yml new file mode 100644 index 000000000..5e1f93a2b --- /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 000000000..e3958cc2d --- /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/.github/workflows/delete-review-app.yml b/.github/workflows/delete-review-app.yml deleted file mode 100644 index 0e9baacb6..000000000 --- a/.github/workflows/delete-review-app.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: Delete Review App - -on: - pull_request: - types: [closed] - issue_comment: - types: [created] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number of the review app targeted for deletion' - required: true - type: string - -permissions: - contents: read - deployments: write - pull-requests: write - issues: write - -env: - PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} - -jobs: - Process-Delete-Command: - if: | - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - github.event.comment.body == '/delete-review-app') || - (github.event_name == 'pull_request' && - github.event.action == 'closed') || - github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Validate Required Secrets and Variables - shell: bash - run: | - missing=() - - # Check required secrets - if [ -z "$CPLN_TOKEN" ]; then - missing+=("Secret: CPLN_TOKEN_STAGING") - fi - - # Check required variables - if [ -z "$CPLN_ORG" ]; then - missing+=("Variable: CPLN_ORG_STAGING") - fi - - if [ -z "$PREFIX" ]; then - missing+=("Variable: REVIEW_APP_PREFIX") - fi - - if [ ${#missing[@]} -ne 0 ]; then - echo "Required secrets/variables are not set: ${missing[*]}" - exit 1 - fi - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - org: ${{ env.CPLN_ORG }} - token: ${{ env.CPLN_TOKEN }} - - - name: Set shared functions - id: shared-functions - uses: actions/github-script@v7 - with: - script: | - core.exportVariable('GET_CONSOLE_LINK', ` - function getConsoleLink(prNumber) { - return '🎮 [Control Plane Console](' + - 'https://console.cpln.io/console/org/' + process.env.CPLN_ORG + '/gvc/' + process.env.APP_NAME + '/-info)'; - } - `); - - - name: Setup Workflow URL - id: setup-workflow-url - uses: actions/github-script@v7 - with: - script: | - async function getWorkflowUrl(runId) { - // Get the current job ID - const jobs = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId - }); - - const currentJob = jobs.data.jobs.find(job => job.status === 'in_progress'); - const jobId = currentJob?.id; - - if (!jobId) { - console.log('Warning: Could not find current job ID'); - return `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - } - - return `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}/job/${jobId}`; - } - - const workflowUrl = await getWorkflowUrl(context.runId); - core.exportVariable('WORKFLOW_URL', workflowUrl); - return { workflowUrl }; - - - name: Create Initial Delete Comment - id: create-delete-comment - uses: actions/github-script@v7 - with: - script: | - eval(process.env.GET_CONSOLE_LINK); - - let message = '🗑️ Starting app deletion'; - if ('${{ github.event_name }}' === 'pull_request') { - const merged = '${{ github.event.pull_request.merged }}' === 'true'; - message += merged ? ' (PR merged)' : ' (PR closed)'; - } - - const comment = await github.rest.issues.createComment({ - issue_number: process.env.PR_NUMBER, - owner: context.repo.owner, - repo: context.repo.repo, - body: '🗑️ Starting app deletion...' - }); - return { commentId: comment.data.id }; - - - name: Delete Review App - uses: ./.github/actions/delete-control-plane-app - with: - app_name: ${{ env.APP_NAME }} - cpln_org: ${{ vars.CPLN_ORG_STAGING }} - - - name: Update Delete Status - uses: actions/github-script@v7 - with: - script: | - eval(process.env.GET_CONSOLE_LINK); - - const success = '${{ job.status }}' === 'success'; - const prNumber = process.env.PR_NUMBER; - const cpConsoleUrl = `https://console.cpln.io/org/${process.env.CPLN_ORG}/workloads/${process.env.APP_NAME}`; - - const successMessage = [ - '✅ Review app for PR #' + prNumber + ' was successfully deleted', - '', - ' [View Completed Delete Logs](' + process.env.WORKFLOW_URL + ')', - '', - ' [Control Plane Organization](https://console.cpln.io/console/org/' + process.env.CPLN_ORG + '/-info)' - ].join('\n'); - - const failureMessage = [ - '❌ Review app for PR #' + prNumber + ' failed to be deleted', - '', - ' [View Delete Logs with Errors](' + process.env.WORKFLOW_URL + ')', - '', - getConsoleLink(prNumber) - ].join('\n'); - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: ${{ fromJSON(steps.create-delete-comment.outputs.result).commentId }}, - body: success ? successMessage : failureMessage - }); diff --git a/.github/workflows/deploy-to-control-plane-review-app.yml b/.github/workflows/deploy-to-control-plane-review-app.yml deleted file mode 100644 index 111f15486..000000000 --- a/.github/workflows/deploy-to-control-plane-review-app.yml +++ /dev/null @@ -1,319 +0,0 @@ -name: Deploy PR Review App to Control Plane - -run-name: Deploy PR Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - -# Controls when the workflow will run -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 - -env: - PREFIX: ${{ vars.REVIEW_APP_PREFIX }} - APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} - -jobs: - deploy: - if: | - (github.event_name == 'pull_request') || - (github.event_name == 'workflow_dispatch') || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - contains(github.event.comment.body, '/deploy-review-app')) - runs-on: ubuntu-latest - steps: - - name: Initial Checkout - uses: actions/checkout@v4 - - - name: Get PR HEAD Ref - id: getRef - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get PR number based on event type - 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 "Error: Unsupported event type ${{ github.event_name }}" - exit 1 - ;; - esac - - if [[ -z "$PR_NUMBER" ]]; then - echo "Error: Could not determine PR number" - echo "Event type: ${{ github.event_name }}" - echo "Event action: ${{ github.event.action }}" - echo "Ref name: ${{ github.ref_name }}" - echo "Available event data:" - echo "- PR number from inputs: ${{ github.event.inputs.pr_number }}" - echo "- PR number from issue: ${{ github.event.issue.number }}" - echo "- PR number from pull_request: ${{ github.event.pull_request.number }}" - exit 1 - fi - - # Get PR data - if [[ -z "$PR_DATA" ]]; then - PR_DATA=$(gh pr view "$PR_NUMBER" --json headRefName,headRefOid) - if [[ -z "$PR_DATA" ]]; then - echo "Error: PR DATA for PR #$PR_NUMBER not found" - echo "Event type: ${{ github.event_name }}" - echo "Event action: ${{ github.event.action }}" - echo "Ref name: ${{ github.ref_name }}" - echo "Attempted to fetch PR data with: gh pr view $PR_NUMBER" - exit 1 - fi - fi - - # Extract and set PR data - echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - echo "APP_NAME=${{ vars.REVIEW_APP_PREFIX }}-$PR_NUMBER" >> $GITHUB_ENV - echo "PR_REF=$(echo $PR_DATA | jq -r .headRefName)" >> $GITHUB_OUTPUT - echo "PR_SHA=$(echo $PR_DATA | jq -r .headRefOid)" >> $GITHUB_ENV - - - name: Checkout the correct ref - uses: actions/checkout@v4 - with: - ref: ${{ env.PR_SHA }} - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - - - name: Check if Review App Exists - id: check-app - env: - CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - run: | - # First check if cpflow exists - if ! command -v cpflow &> /dev/null; then - echo "Error: cpflow command not found" - exit 1 - fi - - # Check if app exists and save state - if ! cpflow exists -a ${{ env.APP_NAME }}; then - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "Canceling job as review app has not been previously deployed." - fi - echo "APP_EXISTS=false" >> $GITHUB_ENV - else - echo "APP_EXISTS=true" >> $GITHUB_ENV - fi - - - name: Setup Control Plane App if Not Existing - if: env.APP_EXISTS == 'false' && github.event_name != 'pull_request' - env: - CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - run: | - echo "🔧 Setting up new Control Plane app..." - cpflow setup-app -a ${{ env.APP_NAME }} --org ${{ vars.CPLN_ORG_STAGING }} - echo "APP_EXISTS=true" >> $GITHUB_ENV - - - name: Create Initial Comment - if: env.APP_EXISTS == 'true' - uses: actions/github-script@v7 - id: create-comment - with: - script: | - const result = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: process.env.PR_NUMBER, - body: '🚀 Starting deployment process...\n\n' - }); - core.setOutput('comment-id', result.data.id); - - - name: Set Deployment URLs - if: env.APP_EXISTS == 'true' - id: set-urls - uses: actions/github-script@v7 - with: - script: | - // Set workflow URL for logs - const getWorkflowUrl = async (runId) => { - const { data: run } = await github.rest.actions.getWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId - }); - - // Get the job ID for this specific job - const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId - }); - - const currentJob = jobs.jobs.find(job => job.name === context.job); - return `${run.html_url}/job/${currentJob.id}`; - }; - - const workflowUrl = await getWorkflowUrl(context.runId); - core.exportVariable('WORKFLOW_URL', workflowUrl); - core.exportVariable('CONSOLE_LINK', - '🎮 [Control Plane Console](' + - 'https://console.cpln.io/console/org/' + process.env.CPLN_ORG + '/gvc/' + process.env.APP_NAME + '/-info)' - ); - - - name: Initialize GitHub Deployment - if: env.APP_EXISTS == 'true' - uses: actions/github-script@v7 - id: init-deployment - with: - script: | - const ref = process.env.PR_SHA; - const environment = process.env.ENVIRONMENT_NAME || 'review-app'; - - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: ref, - environment: environment, - auto_merge: false, - required_contexts: [], - description: `Deployment for PR #${process.env.PR_NUMBER}` - }); - - // Create initial deployment status - 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 Status - Building - if: env.APP_EXISTS == 'true' - uses: actions/github-script@v7 - with: - script: | - const buildingMessage = [ - '🏗️ Building Docker image for PR #${{ env.PR_NUMBER }}, commit ${{ env.PR_SHA }}', - '', - '📝 [View Build Logs](${{ env.WORKFLOW_URL }})', - '', - process.env.CONSOLE_LINK - ].join('\n'); - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: ${{ steps.create-comment.outputs.comment-id }}, - body: buildingMessage - }); - - - name: Build Docker Image - if: env.APP_EXISTS == 'true' - id: build - uses: ./.github/actions/build-docker-image - with: - app_name: ${{ env.APP_NAME }} - org: ${{ vars.CPLN_ORG_STAGING }} - commit: ${{ env.PR_SHA }} - PR_NUMBER: ${{ env.PR_NUMBER }} - - - name: Update Status - Deploying - if: env.APP_EXISTS == 'true' - uses: actions/github-script@v7 - with: - script: | - const deployingMessage = [ - '## 🚀 Deploying to Control Plane...', - '', - '⏳ **Waiting for deployment to be ready...**', - '', - '📝 [View Deploy Logs](${{ env.WORKFLOW_URL }})', - process.env.CONSOLE_LINK - ].join('\n'); - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: ${{ steps.create-comment.outputs.comment-id }}, - body: deployingMessage - }); - - - name: Deploy to Control Plane - if: env.APP_EXISTS == 'true' - run: cpflow deploy-image -a ${{ env.APP_NAME }} --run-release-phase --org ${{ vars.CPLN_ORG_STAGING }} --verbose - - - name: Retrieve App URL - if: env.APP_EXISTS == 'true' - id: workload - run: echo "WORKLOAD_URL=$(cpln workload get rails --gvc ${{ env.APP_NAME }} | tee | grep -oP 'https://[^[:space:]]*\.cpln\.app(?=\s|$)' | head -n1)" >> "$GITHUB_OUTPUT" - - - name: Update Status - Deployment Complete - if: env.APP_EXISTS == 'true' - uses: actions/github-script@v7 - with: - script: | - const prNumber = process.env.PR_NUMBER; - const appUrl = '${{ steps.workload.outputs.WORKLOAD_URL }}'; - const workflowUrl = process.env.WORKFLOW_URL; - const isSuccess = '${{ job.status }}' === 'success'; - - const consoleLink = process.env.CONSOLE_LINK; - - // Create GitHub deployment status - const deploymentStatus = { - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: ${{ steps.init-deployment.outputs.result }}, - state: isSuccess ? 'success' : 'failure', - environment_url: isSuccess ? appUrl : undefined, - log_url: workflowUrl, - environment: 'review' - }; - - await github.rest.repos.createDeploymentStatus(deploymentStatus); - - // Define messages based on deployment status - const successMessage = [ - '## 🎉 ✨ Deploy Complete! 🚀', - '', - '### 🌐 [**➡️ Open Review App**](' + appUrl + ')', - '', - '_Deployment successful for PR #' + prNumber + ', commit ' + '${{ env.PR_SHA }}' + '_', - '', - consoleLink, - '📋 [View Completed Action Build and Deploy Logs](' + workflowUrl + ')' - ].join('\n'); - - const failureMessage = [ - '❌ Deployment failed for PR #' + prNumber + ', commit ' + '${{ env.PR_SHA }}', - '', - consoleLink, - '', - '📋 [View Deployment Logs with Errors](' + workflowUrl + ')' - ].join('\n'); - - // Update the existing comment - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: ${{ steps.create-comment.outputs.comment-id }}, - body: isSuccess ? successMessage : failureMessage - }); diff --git a/.github/workflows/deploy-to-control-plane-staging.yml b/.github/workflows/deploy-to-control-plane-staging.yml deleted file mode 100644 index de2c02073..000000000 --- a/.github/workflows/deploy-to-control-plane-staging.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Control Plane GitHub Action - -name: Deploy to Control Plane Staging -run-name: Deploy Control Plane Staging App - -# Controls when the workflow will run -on: - push: - branches: - - '*' - workflow_dispatch: - -# Convert the GitHub secret variables to environment variables for use by the Control Plane CLI -env: - APP_NAME: ${{ vars.STAGING_APP_NAME }} - CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} - STAGING_APP_BRANCH: ${{ vars.STAGING_APP_BRANCH }} - -concurrency: - group: deploy-staging - cancel-in-progress: true - -jobs: - - validate-branch: - runs-on: ubuntu-latest - outputs: - is_deployable: ${{ steps.check_branch.outputs.is_deployable }} - steps: - - name: Check if allowed branch - id: check_branch - run: | - if [[ -n "${STAGING_APP_BRANCH}" ]]; then - if [[ "${GITHUB_REF#refs/heads/}" == "${STAGING_APP_BRANCH}" ]]; then - echo "is_deployable=true" >> $GITHUB_OUTPUT - else - echo "Branch '${GITHUB_REF#refs/heads/}' is not the configured deployment branch '${STAGING_APP_BRANCH}'" - echo "is_deployable=false" >> $GITHUB_OUTPUT - fi - elif [[ "${GITHUB_REF}" == "refs/heads/main" || "${GITHUB_REF}" == "refs/heads/master" ]]; then - echo "is_deployable=true" >> $GITHUB_OUTPUT - else - echo "Branch '${GITHUB_REF#refs/heads/}' is not main/master (no STAGING_APP_BRANCH configured)" - echo "is_deployable=false" >> $GITHUB_OUTPUT - fi - - build: - needs: validate-branch - if: needs.validate-branch.outputs.is_deployable == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - - - name: Build Docker Image - id: build - uses: ./.github/actions/build-docker-image - with: - app_name: ${{ env.APP_NAME }} - org: ${{ vars.CPLN_ORG_STAGING }} - commit: ${{ github.sha }} - - deploy: - needs: build - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - - - name: Deploy to Control Plane - run: cpflow deploy-image -a ${{ env.APP_NAME }} --run-release-phase --org ${{ vars.CPLN_ORG_STAGING }} --verbose diff --git a/.github/workflows/help-command.yml b/.github/workflows/help-command.yml deleted file mode 100644 index 9407e6a0e..000000000 --- a/.github/workflows/help-command.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Help Command - -on: - issue_comment: - types: [created] - workflow_dispatch: - inputs: - pr_number: - description: 'Pull Request number to post help comment on' - required: true - type: string - -permissions: - issues: write - pull-requests: write - -jobs: - help: - if: ${{ (github.event.issue.pull_request && github.event.comment.body == '/help') || github.event_name == 'workflow_dispatch' }} - runs-on: ubuntu-latest - - steps: - - name: Show Available Commands - uses: actions/github-script@v7 - with: - script: | - const sections = { - commands: { - deploy: { - title: '## `/deploy-review-app`', - purpose: '**Purpose:** Deploy a review app for your pull request', - details: [ - '**What it does:**', - '- Creates a new review app in Control Plane', - '- Deploys your changes to the review environment', - '- Provides a unique URL to preview your changes', - '- Shows build and deployment progress in real-time', - '', - '**Optional Configuration:**', - '- `WAIT_TIMEOUT`: Deployment timeout in seconds (default: 900)', - ' - Must be a positive integer', - ' - Example: `/deploy timeout=1800`' - ] - }, - destroy: { - title: '## `/delete-review-app`', - purpose: '**Purpose:** Remove the review app for your pull request', - details: [ - '**What it does:**', - '- Deletes the review app from Control Plane', - '- Cleans up associated resources', - '- Updates PR with deletion status' - ] - } - }, - setup: { - title: '## Environment Setup', - sections: [ - { - title: '**Required Environment Secrets:**', - items: [ - '- `CPLN_TOKEN_STAGING`: Control Plane authentication token', - '- `CPLN_TOKEN_PRODUCTION`: Control Plane authentication token' - ] - }, - { - title: '**Required GitHub Actions Variables:**', - items: [ - '- `CPLN_ORG_STAGING`: Control Plane Staging Org Name', - '- `CPLN_ORG_PRODUCTION`: Control Plane Production Org Name' - ] - }, - { - title: '**Required GitHub Actions Variables (these need to match your control_plane.yml file:**', - items: [ - '- `PRODUCTION_APP_NAME`: Control Plane production app name', - '- `STAGING_APP_NAME`: Control Plane staging app name', - '- `REVIEW_APP_PREFIX`: Control Plane review app prefix' - ] - } - ], - note: 'Optional: Configure `WAIT_TIMEOUT` in GitHub Actions variables to customize deployment timeout' - }, - integration: { - title: '## Control Plane Integration', - details: [ - '1. Review app naming convention:', - ' ```', - ' ${{ vars.REVIEW_APP_PREFIX }}-', - ' ```', - '2. Console URL: `https://console.cpln.io/console/org/{CPLN_ORG}/gvc/{APP_NAME}/-info`' - ] - }, - cleanup: { - title: '## Automatic Cleanup', - details: [ - 'Review apps are automatically deleted when:', - '1. The pull request is closed', - '2. The `/delete-review-app` command is used', - '3. A new deployment is requested (old one is cleaned up first)' - ] - }, - help: { - title: '## Need Help?', - details: [ - 'For additional assistance:', - '1. Check the [Control Plane documentation](https://docs.controlplane.com/)', - '2. Contact the infrastructure team', - '3. Open an issue in this repository' - ] - } - }; - - const generateHelpText = () => { - const parts = ['# Available Commands', '']; - - // Add commands - Object.values(sections.commands).forEach(cmd => { - parts.push(cmd.title, cmd.purpose, '', ...cmd.details, ''); - }); - - parts.push('---'); - - // Add setup section - parts.push(sections.setup.title, ''); - sections.setup.sections.forEach(section => { - parts.push(section.title, ...section.items, ''); - }); - parts.push(sections.setup.note, ''); - - // Add remaining sections - ['integration', 'cleanup', 'help'].forEach(section => { - parts.push(sections[section].title, '', ...sections[section].details, ''); - }); - - return parts.join('\n'); - }; - - const helpText = generateHelpText(); - - const prNumber = context.eventName === 'workflow_dispatch' - ? parseInt(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/nightly-remove-stale-review-apps.yml b/.github/workflows/nightly-remove-stale-review-apps.yml deleted file mode 100644 index d57c3e6e7..000000000 --- a/.github/workflows/nightly-remove-stale-review-apps.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Nightly Remove Stale Review Apps and Images - -on: - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - schedule: - - cron: '0 0 * * *' - -jobs: - remove-stale-review-apps: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_STAGING }} - org: ${{ vars.CPLN_ORG_STAGING }} - - - name: Delete Stale Review Apps - run: cpflow cleanup-stale-apps -a "qa-react-webpack-rails-tutorial" --yes diff --git a/.github/workflows/promote-staging-to-production.yml b/.github/workflows/promote-staging-to-production.yml deleted file mode 100644 index caba62e0f..000000000 --- a/.github/workflows/promote-staging-to-production.yml +++ /dev/null @@ -1,189 +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 - -env: - HEALTH_CHECK_RETRIES: 12 - HEALTH_CHECK_INTERVAL: 10 - -jobs: - promote-to-production: - runs-on: ubuntu-latest - if: github.event.inputs.confirm_promotion == 'promote' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Environment - uses: ./.github/actions/setup-environment - with: - token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - org: ${{ vars.CPLN_ORG_PRODUCTION }} - - - name: Verify Production Environment Variables - env: - CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} - CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} - run: | - echo "Checking that production has all staging environment variables..." - - # Get staging env var names - STAGING_VARS=$(CPLN_TOKEN=$CPLN_TOKEN_STAGING cpln gvc get ${{ vars.STAGING_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_STAGING }} -o json 2>/dev/null | jq -r '.spec.env[].name' | sort) - - # Get production env var names - PROD_VARS=$(CPLN_TOKEN=$CPLN_TOKEN_PRODUCTION cpln gvc get ${{ vars.PRODUCTION_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_PRODUCTION }} -o json 2>/dev/null | jq -r '.spec.env[].name' | sort) - - # Find vars in staging but not in production - MISSING=$(comm -23 <(echo "$STAGING_VARS") <(echo "$PROD_VARS")) - - if [ -n "$MISSING" ]; then - echo "::error::Production GVC is missing these environment variables that exist in staging:" - echo "$MISSING" - echo "" - echo "Please add these variables to the production GVC before promoting." - echo "This prevents deployment failures due to missing configuration." - exit 1 - fi - - echo "✅ Production has all staging environment variables" - - - name: Capture Current Production Image (for rollback) - id: capture-current - run: | - echo "Capturing current production image for potential rollback..." - - # Get the current image from the rails workload - CURRENT_IMAGE=$(cpln workload get rails \ - --gvc ${{ vars.PRODUCTION_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_PRODUCTION }} \ - -o json | jq -r '.spec.containers[0].image') - - echo "Current production image: $CURRENT_IMAGE" - echo "current_image=$CURRENT_IMAGE" >> $GITHUB_OUTPUT - - # Also capture the workload version for reference - CURRENT_VERSION=$(cpln workload get rails \ - --gvc ${{ vars.PRODUCTION_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_PRODUCTION }} \ - -o json | jq -r '.version') - - echo "Current workload version: $CURRENT_VERSION" - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - - - name: Copy Image from Staging - run: cpflow copy-image-from-upstream -a ${{ vars.PRODUCTION_APP_NAME }} -t ${{ secrets.CPLN_TOKEN_STAGING }} - - - name: Deploy Image to Production - id: deploy - run: | - echo "Deploying new image to production..." - cpflow deploy-image -a ${{ vars.PRODUCTION_APP_NAME }} --run-release-phase --org ${{ vars.CPLN_ORG_PRODUCTION }} - - - name: Wait for Deployment Health - id: health-check - run: | - echo "Waiting for deployment to become healthy..." - - for i in $(seq 1 $HEALTH_CHECK_RETRIES); do - echo "Health check attempt $i/$HEALTH_CHECK_RETRIES..." - - # Get deployment status - DEPLOYMENT_STATUS=$(cpln workload get rails \ - --gvc ${{ vars.PRODUCTION_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_PRODUCTION }} \ - -o json 2>/dev/null) - - # Check if deployment endpoint is responding - ENDPOINT=$(echo "$DEPLOYMENT_STATUS" | jq -r '.status.endpoint // empty') - - if [ -n "$ENDPOINT" ]; then - # Try to reach the health endpoint - 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 "✅ Deployment is healthy! HTTP status: $HTTP_STATUS" - echo "healthy=true" >> $GITHUB_OUTPUT - exit 0 - fi - fi - - if [ $i -lt $HEALTH_CHECK_RETRIES ]; then - echo "Deployment not ready yet, waiting ${HEALTH_CHECK_INTERVAL}s..." - sleep $HEALTH_CHECK_INTERVAL - fi - done - - echo "::error::Deployment health check failed after $HEALTH_CHECK_RETRIES attempts" - echo "healthy=false" >> $GITHUB_OUTPUT - exit 1 - - - name: Rollback on Failure - if: failure() && steps.capture-current.outputs.current_image != '' - env: - PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} - run: | - echo "::warning::Deployment failed! Rolling back to previous image..." - echo "Rolling back to: $PREVIOUS_IMAGE" - - # Update the workload to use the previous image - cpln workload update rails \ - --gvc ${{ vars.PRODUCTION_APP_NAME }} \ - --org ${{ vars.CPLN_ORG_PRODUCTION }} \ - --set spec.containers[0].image="$PREVIOUS_IMAGE" - - echo "Waiting for rollback to complete..." - sleep 30 - - # Verify rollback succeeded - ROLLBACK_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "https://$(cpln workload get rails --gvc ${{ vars.PRODUCTION_APP_NAME }} --org ${{ vars.CPLN_ORG_PRODUCTION }} -o json | jq -r '.status.endpoint' | sed 's|https://||')" 2>/dev/null || echo "000") - - if [ "$ROLLBACK_STATUS" = "200" ] || [ "$ROLLBACK_STATUS" = "301" ] || [ "$ROLLBACK_STATUS" = "302" ]; then - echo "✅ Rollback successful! Production is back online with previous image." - else - echo "::error::Rollback may have issues. HTTP status: $ROLLBACK_STATUS. Please check production manually!" - fi - - - name: Create GitHub Release - if: success() && steps.health-check.outputs.healthy == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get the current date in YYYY-MM-DD format - RELEASE_DATE=$(date '+%Y-%m-%d') - TIMESTAMP=$(date '+%H%M') - - # Create a release tag - RELEASE_TAG="production-${RELEASE_DATE}-${TIMESTAMP}" - - # Create GitHub release - gh release create "${RELEASE_TAG}" \ - --title "Production Release ${RELEASE_DATE} ${TIMESTAMP}" \ - --notes "🚀 Production deployment on ${RELEASE_DATE} at ${TIMESTAMP}" - - - name: Summary - if: always() - run: | - echo "## Promotion Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "${{ steps.health-check.outputs.healthy }}" == "true" ]; then - echo "✅ **Status:** Deployment successful" >> $GITHUB_STEP_SUMMARY - else - echo "❌ **Status:** Deployment failed (rollback attempted)" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Previous Image:** \`${{ steps.capture-current.outputs.current_image }}\`" >> $GITHUB_STEP_SUMMARY - echo "**Previous Version:** ${{ steps.capture-current.outputs.current_version }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/review-app-help.yml b/.github/workflows/review-app-help.yml deleted file mode 100644 index 027330a89..000000000 --- a/.github/workflows/review-app-help.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Show Quick Help on PR Creation - -on: - pull_request: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - show-quick-help: - runs-on: ubuntu-latest - steps: - - name: Show Quick Reference - uses: actions/github-script@v7 - with: - script: | - try { - console.log('Creating quick reference message...'); - const helpMessage = [ - '# 🚀 Quick Review App Commands', - '', - 'Welcome! Here are the commands you can use in this PR:', - '', - '### `/deploy-review-app`', - 'Deploy your PR branch for testing', - '', - '### `/delete-review-app`', - 'Remove the review app when done', - '', - '### `/help`', - 'Show detailed instructions, environment setup, and configuration options.', - '', - '---' - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: helpMessage - }); - - console.log('Quick reference posted successfully'); - } catch (error) { - console.error('Error posting quick reference:', error); - core.setFailed(`Failed to post quick reference: ${error.message}`); - } diff --git a/README.md b/README.md index 395f35569..fe245ab1c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ShakaCode recently migrated [HiChee.com](https://hichee.com) to Control Plane, resulting in a two-thirds reduction in server hosting costs! -See doc in [./.controlplane/readme.md](./.controlplane/readme.md) for how to easily deploy this app to Control Plane. +See [./.controlplane/readme.md](./.controlplane/readme.md) for local `cpflow` setup plus the shared `cpflow-*` GitHub Actions flow for review apps, automatic staging deploys, and manual promotion to production. The instructions leverage the `cpflow` CLI, with source code and many more tips on how to migrate from Heroku to Control Plane in https://github.com/shakacode/heroku-to-control-plane.