diff --git a/.controlplane/readme.md b/.controlplane/readme.md index 5ac7ff3c1..010bc2616 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). +Check [how the `cpflow` gem is used in the generated GitHub Actions flow](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.github/actions/cpflow-build-docker-image/action.yml). Here is a brief [video overview](https://www.youtube.com/watch?v=llaQoAV_6Iw). --- @@ -364,27 +364,69 @@ openssl rand -hex 64 _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. +Review Apps (deployment of apps based on a PR) are done via the generated +`cpflow-*` GitHub Actions flow. -The review apps work by creating isolated deployments for each branch through this automated process. When a branch is pushed, the action: +The review apps work by creating isolated deployments for pull requests through +this automated process. When an approved collaborator comments exactly +`/deploy-review-app` on a PR, 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 +2. Creates a unique review app if it doesn't exist +3. Builds a Docker image tagged with the PR commit SHA 4. Deploys this image to Control Plane with its own isolated environment +After the review app exists, new pushes to the PR redeploy it automatically. +Use `/delete-review-app` to delete it manually; closing the PR deletes it +automatically. Pushes to the staging branch deploy staging, and production +promotion is manual from the `cpflow-promote-staging-to-production` workflow. +The production promotion workflow checks that production has all environment +variable names present in staging; it does not compare secret values. + +The repository variables and secrets must match the app names in +`.controlplane/controlplane.yml`. In particular, `REVIEW_APP_PREFIX` should +include the `-pr` suffix for this app, such as +`qa-react-webpack-rails-tutorial-pr`, so generated review apps are named +`qa-react-webpack-rails-tutorial-pr-1234`. + 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. +The system uses Control Plane's infrastructure to manage these deployments, with +each review app getting its own resources as defined in the controlplane.yml +configuration. -### 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` +1. Create a PR with changes to the GitHub Actions workflow +2. Make edits to files such as `.github/actions/cpflow-build-docker-image/action.yml` or `.github/workflows/cpflow-deploy-review-app.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 +4. Check the GitHub Actions tab in the PR to see the status of the workflow + +### Keeping Generated cpflow Workflows Updated + +Treat `.github/actions/cpflow-*` and `.github/workflows/cpflow-*` as generated +workflow files with project-specific settings layered on top. When `cpflow` +releases generator fixes or the upstream `control-plane-flow` repo changes the +GitHub Actions flow, update a project by regenerating the flow from the desired +`cpflow` version or branch, reviewing the diff, and keeping any local app names, +repository variables, secrets, and docs aligned with `.controlplane/controlplane.yml`. + +For this app, validate a regenerated flow with: + +```bash +bundle exec ruby /path/to/control-plane-flow/bin/cpflow generate-github-actions +bundle exec ruby /path/to/control-plane-flow/bin/cpflow github-flow-readiness +actionlint .github/workflows/cpflow-*.yml +bundle exec rubocop +``` + +Then open a normal PR and let GitHub Actions prove the generated review-app, +staging, lint, JS, and RSpec workflows before merging. If a project needs to +track generator changes automatically, use a scheduled maintenance PR or +Renovate-style workflow that bumps the `cpflow` version, regenerates these files, +and runs the same validation commands. diff --git a/.controlplane/shakacode-team.md b/.controlplane/shakacode-team.md index cd581b6fb..186dbca9e 100644 --- a/.controlplane/shakacode-team.md +++ b/.controlplane/shakacode-team.md @@ -6,6 +6,13 @@ Deployments are handled by Control Plane configuration in this repo and GitHub A ### Review Apps - Add a comment `/deploy-review-app` to any PR to deploy a review app +- The generated app name is `${REVIEW_APP_PREFIX}-${PR_NUMBER}`. Keep + `REVIEW_APP_PREFIX` set to `qa-react-webpack-rails-tutorial-pr` so review + apps use names like `qa-react-webpack-rails-tutorial-pr-1234`, matching the + prefix-backed config in `.controlplane/controlplane.yml`. +- New pushes to a PR redeploy only after the review app already exists. +- Add `/delete-review-app` to delete a review app manually; closing the PR also + deletes it automatically. ### Staging Environment - **Automatic**: Any merge to the `master` branch automatically deploys to staging @@ -14,14 +21,49 @@ Deployments are handled by Control Plane configuration in this repo and GitHub A - [Staging App](https://staging.reactrails.com/) ### Production Environment -- **Manual**: Run the [promote-staging-to-production workflow](https://github.com/shakacode/react-webpack-rails-tutorial/actions/workflows/promote-staging-to-production.yml) on GitHub +- **Manual**: Run the [cpflow-promote-staging-to-production workflow](https://github.com/shakacode/react-webpack-rails-tutorial/actions/workflows/cpflow-promote-staging-to-production.yml) on GitHub - **URLs**: - [Control Plane Console - Production](https://console.cpln.io/console/org/shakacode-open-source-examples-production/gvc/react-webpack-rails-tutorial-production/workload/rails/-info) - [Production App](https://reactrails.com/) -See [./README.md](./README.md) for more details. +### GitHub Repository Settings + +Required repository secrets: + +- `CPLN_TOKEN_STAGING` +- `CPLN_TOKEN_PRODUCTION` + +Required repository variables: + +- `CPLN_ORG_STAGING=shakacode-open-source-examples-staging` +- `CPLN_ORG_PRODUCTION=shakacode-open-source-examples-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-pr` +- `STAGING_APP_BRANCH=master` +- `PRIMARY_WORKLOAD=rails` + +Optional repository settings: + +- `DOCKER_BUILD_SSH_KEY`: secret for private SSH dependencies during Docker builds. +- `DOCKER_BUILD_EXTRA_ARGS`: newline-delimited Docker build tokens, such as `--build-arg=FOO=bar`. +- `DOCKER_BUILD_SSH_KNOWN_HOSTS`: custom `known_hosts` entries when SSH build hosts are not GitHub.com. + +If staging moves off `master`, update both `STAGING_APP_BRANCH` and the branch +filter in `.github/workflows/cpflow-deploy-staging.yml`. + +### Keeping cpflow Automation Current + +When the upstream `control-plane-flow` repo changes the generated GitHub Actions +flow, regenerate the `cpflow-*` actions/workflows in this repo from the target +`cpflow` version or branch, review the diff, and keep the repository variables +above aligned with `.controlplane/controlplane.yml`. Validate with +`cpflow github-flow-readiness`, `actionlint .github/workflows/cpflow-*.yml`, and +the normal CI checks before merging. + +See [readme.md](readme.md) for more details. ## Links - [Control Plane Org for Staging and Review Apps](https://console.cpln.io/console/org/shakacode-open-source-examples-staging/-info) -- [Control Plane Org for Deployed App](https://console.cpln.io/console/org/shakacode-open-source-examples/-info) +- [Control Plane Org for Production App](https://console.cpln.io/console/org/shakacode-open-source-examples-production/-info) 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..19663a6cf --- /dev/null +++ b/.github/actions/cpflow-build-docker-image/action.yml @@ -0,0 +1,106 @@ +name: Build Docker Image +description: Builds and pushes the app image for a Control Plane workload + +inputs: + app_name: + description: Name of the application + required: true + org: + description: Control Plane organization name + required: true + commit: + description: Commit SHA to tag the image with + required: true + pr_number: + description: Pull request number for status messaging + required: false + docker_build_extra_args: + description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar. + required: false + docker_build_ssh_key: + description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh + required: false + docker_build_ssh_known_hosts: + description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys. + required: false + +runs: + using: composite + steps: + # Keep SSH key handling in a dedicated step so DOCKER_BUILD_SSH_KEY is never present + # in the main build step's environment. ACTIONS_STEP_DEBUG=true dumps env before any + # command runs, so keeping the key out of env there avoids even admin-triggered exposure. + - name: Prepare SSH agent for Docker build + if: ${{ inputs.docker_build_ssh_key != '' }} + shell: bash + env: + # Pass the key via env so the file write is a single printf call rather than a + # heredoc with a fixed terminator (a heredoc would silently truncate the key if + # any line of the key value happened to match the terminator). Scope is still + # this step only — the build step below does not receive DOCKER_BUILD_SSH_KEY. + DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }} + DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }} + run: | + set -euo pipefail + + umask 077 + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + if [[ -n "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" ]]; then + printf '%s\n' "${DOCKER_BUILD_SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts + else + printf '%s\n' \ + 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \ + 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' \ + 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' \ + > ~/.ssh/known_hosts + fi + chmod 600 ~/.ssh/known_hosts + + printf '%s\n' "${DOCKER_BUILD_SSH_KEY}" > ~/.ssh/cpflow_build_key + chmod 600 ~/.ssh/cpflow_build_key + + - name: Build Docker image + shell: bash + env: + APP_NAME: ${{ inputs.app_name }} + COMMIT_SHA: ${{ inputs.commit }} + CONTROL_PLANE_ORG: ${{ inputs.org }} + DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + + PR_INFO="" + docker_build_args=() + + if [[ -n "${PR_NUMBER}" ]]; then + PR_INFO=" for PR #${PR_NUMBER}" + fi + + if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then + while IFS= read -r arg; do + arg="${arg%$'\r'}" + [[ -n "${arg}" ]] || continue + + if [[ "${arg}" =~ [[:space:]] ]]; then + echo "docker_build_extra_args entries must be single docker-build tokens. " \ + "Use key=value forms like --build-arg=FOO=bar." >&2 + exit 1 + fi + + docker_build_args+=("${arg}") + done <<< "${DOCKER_BUILD_EXTRA_ARGS}" + fi + + if [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then + eval "$(ssh-agent -s)" + trap 'ssh-agent -k >/dev/null; rm -f "${HOME}/.ssh/cpflow_build_key"' EXIT + ssh-add "${HOME}/.ssh/cpflow_build_key" + docker_build_args+=("--ssh=default") + fi + + echo "🏗️ Building Docker image${PR_INFO} (commit ${COMMIT_SHA})..." + cpflow build-image -a "${APP_NAME}" --commit="${COMMIT_SHA}" --org="${CONTROL_PLANE_ORG}" "${docker_build_args[@]}" + echo "✅ Docker image build successful${PR_INFO} (commit ${COMMIT_SHA})" diff --git a/.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..4830b2392 --- /dev/null +++ b/.github/actions/cpflow-delete-control-plane-app/delete-app.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +: "${APP_NAME:?APP_NAME environment variable is required}" +: "${CPLN_ORG:?CPLN_ORG environment variable is required}" +: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}" + +expected_prefix="${REVIEW_APP_PREFIX}-" +if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then + echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2 + echo "App name: $APP_NAME" >&2 + echo "Expected prefix: ${expected_prefix}" >&2 + exit 1 +fi + +echo "🔍 Checking if application exists: $APP_NAME" +# Contract this relies on from `cpflow exists`: +# - Exit status 0 → app exists (stdout may contain an informational banner). +# - Exit status non-zero, no +# recognizable error tokens → app does not exist; treat as a no-op success. +# - Exit status non-zero with +# tokens like "Double check +# your org", "Unknown API +# token format", "ERROR", +# "Error:", "Traceback", or +# "Net::" → a real failure; surface and exit 1. +# TODO: replace this string-matching with a structured signal once `cpflow exists` exposes one +# (e.g. a distinct exit code for "not found" vs. API/auth errors, or `cpflow exists --json`). +# Until then, keep this list in sync if `cpflow exists` starts emitting new error patterns — +# any unmatched error string would otherwise be silently treated as "app not found". +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..978ca8e7a --- /dev/null +++ b/.github/actions/cpflow-setup-environment/action.yml @@ -0,0 +1,84 @@ +name: Setup Control Plane Environment +description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile + +inputs: + token: + description: Control Plane token + required: true + org: + description: Control Plane organization + required: true + ruby_version: + description: >- + Ruby version used for cpflow. When empty (the default), ruby/setup-ruby auto-detects + from .ruby-version, .tool-versions, or the Gemfile. + required: false + default: "" + cpln_cli_version: + # Bump this default when a new @controlplane/cli release lands that you want to roll out. + description: "@controlplane/cli version" + required: false + default: "3.3.1" + cpflow_version: + description: cpflow gem version + required: false + default: "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 }} --no-document + cpflow --version + + - name: Setup Control Plane profile and registry login + shell: bash + env: + # Pass the token via CPLN_TOKEN so cpln picks it up from the environment + # rather than `--token`, which would leak it into /proc//cmdline and ps output. + CPLN_TOKEN: ${{ inputs.token }} + ORG: ${{ inputs.org }} + run: | + set -euo pipefail + + if [[ -z "$CPLN_TOKEN" ]]; then + echo "Error: Control Plane token not provided" >&2 + exit 1 + fi + + if [[ -z "$ORG" ]]; then + echo "Error: Control Plane organization not provided" >&2 + exit 1 + fi + + # Later workflow steps call cpflow/cpln, so persist the token without putting it on argv. + # This intentionally gives subsequent trusted steps in the same job a CPLN_TOKEN env var. + token_delimiter="CPLN_TOKEN_$(openssl rand -hex 8)" + { + echo "CPLN_TOKEN<<${token_delimiter}" + printf '%s\n' "$CPLN_TOKEN" + echo "${token_delimiter}" + } >> "$GITHUB_ENV" + + create_output="" + if ! create_output="$(cpln profile create default --org "$ORG" 2>&1)"; then + if ! echo "$create_output" | grep -qi "already exists"; then + echo "$create_output" >&2 + exit 1 + fi + fi + + cpln profile update default --org "$ORG" + cpln image docker-login --org "$ORG" diff --git a/.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..b188d10c4 --- /dev/null +++ b/.github/workflows/cpflow-delete-review-app.yml @@ -0,0 +1,131 @@ +name: Delete Review App + +on: + # Use pull_request_target for close cleanup so fork PR review apps can be deleted with + # base-repo secrets. This workflow checks out the base repository, not untrusted PR code. + pull_request_target: + types: [closed] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number targeted for deletion + required: true + type: number + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + # Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave + # partial state behind. Let the in-progress deletion finish before the next run starts. + cancel-in-progress: false + +env: + APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + 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..b3ed99106 --- /dev/null +++ b/.github/workflows/cpflow-deploy-review-app.yml @@ -0,0 +1,427 @@ +name: Deploy Review App to Control Plane + +run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}" + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to deploy + required: true + type: number + +permissions: + contents: read + deployments: write + issues: write + pull-requests: write + +concurrency: + group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + # Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the + # review app in a partially-deployed state (workload update in progress, rollout not + # settled). Let an in-flight deploy finish before the next push starts a new run. + cancel-in-progress: false + +env: + APP_NAME: ${{ vars.REVIEW_APP_PREFIX }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + +jobs: + deploy: + # Skip synchronize/opened events from fork PRs at the job level — they cannot access + # repository secrets anyway, so running any steps just burns billable minutes. Users + # can still manually deploy a fork PR via `/deploy-review-app` (gated below by + # author_association) or workflow_dispatch. + if: | + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/deploy-review-app' && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) + runs-on: ubuntu-latest + + steps: + - name: Initial checkout + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + id: config + shell: bash + run: | + set -euo pipefail + + missing=() + + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.REVIEW_APP_PREFIX }}" ]] || missing+=("variable:REVIEW_APP_PREFIX") + + if [[ ${#missing[@]} -gt 0 ]]; then + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + { + echo "Control Plane review app automation is not configured yet." + echo + echo "Missing required GitHub configuration:" + printf -- '- %s\n' "${missing[@]}" + echo + echo "Pushes to this pull request will skip review app deploys until the repository is configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Resolve PR ref and commit + if: steps.config.outputs.ready == 'true' + id: resolve-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + case "${{ github.event_name }}" in + workflow_dispatch) + pr_number="${{ github.event.inputs.pr_number }}" + ;; + issue_comment) + pr_number="${{ github.event.issue.number }}" + ;; + pull_request) + pr_number="${{ github.event.pull_request.number }}" + ;; + *) + echo "Unsupported event type: ${{ github.event_name }}" >&2 + exit 1 + ;; + esac + + pr_data="$(gh pr view "$pr_number" --json headRefOid,headRepository,headRepositoryOwner)" + pr_sha="$(echo "$pr_data" | jq -r '.headRefOid')" + pr_repository="$(echo "$pr_data" | jq -r '[.headRepositoryOwner.login, .headRepository.name] | join("/")')" + same_repo="false" + + if [[ "$pr_repository" == "$GITHUB_REPOSITORY" ]]; then + same_repo="true" + fi + + { + echo "PR_NUMBER=$pr_number" + echo "APP_NAME=${{ vars.REVIEW_APP_PREFIX }}-$pr_number" + 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 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 + + message="Review app deploys from fork pull requests are not allowed because this workflow uses repository secrets." + if ! gh pr comment "${PR_NUMBER}" --body "${message}"; then + echo "Failed to post fork rejection comment to PR #${PR_NUMBER}" >&2 + fi + + echo "${message}" >&2 + exit 1 + + - name: Checkout PR commit + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ env.PR_SHA }} + + - name: Setup environment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Detect release phase support + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + id: release-phase + shell: bash + run: | + set -euo pipefail + + # Anchor to start-of-line so commented-out release_script: entries don't enable --run-release-phase. + if cpflow config -a "${APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Check if review app exists + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' + id: check-app + shell: bash + run: | + set -euo pipefail + + # Keep this in sync with delete-app.sh. `cpflow exists` does not yet expose + # distinct structured signals for not-found vs. auth/API failures. + exists_output="" + if exists_output="$(cpflow exists -a "${APP_NAME}" --org "${CPLN_ORG}" 2>&1)"; then + if [[ -n "${exists_output}" ]]; then + printf '%s\n' "${exists_output}" + fi + + echo "exists=true" >> "$GITHUB_OUTPUT" + else + case "${exists_output}" in + *"Double check your org"*|*"Unknown API token format"*|*"ERROR"*|*"Error:"*|*"Traceback"*|*"Net::"*) + echo "Failed to determine whether review app 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 "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip auto deploy until a review app is created + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name == 'pull_request' + shell: bash + run: | + { + echo "Review app ${APP_NAME} does not exist yet." + echo "Create it with a PR comment that is exactly /deploy-review-app." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Setup review app if it does not exist yet + id: setup-review-app + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && github.event_name != 'pull_request' + shell: bash + run: | + set -euo pipefail + cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" + + - name: Create initial PR comment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: create-comment + uses: actions/github-script@v7 + with: + script: | + const result = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body: "🚀 Starting Control Plane review app deployment..." + }); + core.setOutput("comment-id", result.data.id); + + - name: Set deployment links + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + core.exportVariable("WORKFLOW_URL", workflowUrl); + core.exportVariable( + "CONSOLE_URL", + `https://console.cpln.io/console/org/${process.env.CPLN_ORG}/gvc/${process.env.APP_NAME}/-info` + ); + + - name: Initialize GitHub deployment + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: init-deployment + uses: actions/github-script@v7 + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: process.env.PR_SHA, + environment: `review/${process.env.APP_NAME}`, + auto_merge: false, + required_contexts: [], + description: `Control Plane review app for PR #${process.env.PR_NUMBER}` + }); + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: "in_progress", + description: "Deployment started" + }); + + return deployment.data.id; + + - name: Update PR comment with build status + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + const body = [ + `🏗️ Building Docker image for PR #${process.env.PR_NUMBER}, commit ${process.env.PR_SHA}`, + "", + `[View build logs](${process.env.WORKFLOW_URL})`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})` + ].join("\n"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); + + - name: Build Docker image + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: ./.github/actions/cpflow-build-docker-image + with: + app_name: ${{ env.APP_NAME }} + org: ${{ vars.CPLN_ORG_STAGING }} + commit: ${{ env.PR_SHA }} + pr_number: ${{ env.PR_NUMBER }} + docker_build_extra_args: ${{ vars.DOCKER_BUILD_EXTRA_ARGS }} + docker_build_ssh_key: ${{ secrets.DOCKER_BUILD_SSH_KEY }} + docker_build_ssh_known_hosts: ${{ vars.DOCKER_BUILD_SSH_KNOWN_HOSTS }} + + - name: Update PR comment with deploy status + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + const body = [ + "🚀 Deploying review app to Control Plane...", + "", + `[View deploy logs](${process.env.WORKFLOW_URL})`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})` + ].join("\n"); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body + }); + + - name: Deploy to Control Plane + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + env: + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" + + - name: Retrieve app URL + if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + id: workload + shell: bash + run: | + set -euo pipefail + workload_name="${PRIMARY_WORKLOAD:-rails}" + workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')" + echo "workload_url=${workload_url}" >> "$GITHUB_OUTPUT" + + - name: Finalize deployment status + if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success') + uses: actions/github-script@v7 + with: + script: | + const commentId = Number("${{ steps.create-comment.outputs.comment-id }}"); + const deploymentId = "${{ steps.init-deployment.outputs.result }}"; + const appUrl = "${{ steps.workload.outputs.workload_url }}"; + const success = "${{ job.status }}" === "success"; + + if (deploymentId) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: Number(deploymentId), + state: success ? "success" : "failure", + environment: `review/${process.env.APP_NAME}`, + environment_url: success && appUrl ? appUrl : undefined, + log_url: process.env.WORKFLOW_URL, + description: success ? "Review app ready" : "Review app deployment failed" + }); + } + + const successBody = [ + "## Review app ready", + "", + appUrl ? `[Open review app](${appUrl})` : "Review app deployed, but no endpoint URL was detected.", + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + const failureBody = [ + `❌ Review app deployment failed for PR #${process.env.PR_NUMBER}`, + "", + `[Open Control Plane console](${process.env.CONSOLE_URL})`, + `[View workflow logs](${process.env.WORKFLOW_URL})` + ].join("\n"); + + if (!Number.isFinite(commentId) || commentId <= 0) { + core.warning("Skipping PR comment update because no comment id was created."); + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: success ? successBody : failureBody + }); diff --git a/.github/workflows/cpflow-deploy-staging.yml b/.github/workflows/cpflow-deploy-staging.yml new file mode 100644 index 000000000..5175ee186 --- /dev/null +++ b/.github/workflows/cpflow-deploy-staging.yml @@ -0,0 +1,134 @@ +name: Deploy Staging to Control Plane + +run-name: Deploy Control Plane staging app + +on: + push: + # GitHub does not allow repository vars in branch filters. Default to the common + # deploy branches; if STAGING_APP_BRANCH is set to something else, add it to this list + # after generation so the validate-branch job does not burn a runner on every push. + branches: ["main", "master"] + workflow_dispatch: + +permissions: + contents: read + +env: + APP_NAME: ${{ vars.STAGING_APP_NAME }} + CPLN_ORG: ${{ vars.CPLN_ORG_STAGING }} + STAGING_APP_BRANCH: ${{ vars.STAGING_APP_BRANCH }} + +concurrency: + group: cpflow-deploy-staging-${{ github.ref_name }} + # Match the review-app and delete workflows: a cancelled `cpflow deploy-image` mid-rollout + # can leave the staging GVC in a partially-deployed state (some workloads on the new image, + # others on the old). Let an in-flight deploy finish before the next push starts a new run. + cancel-in-progress: false + +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 + + - 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' && needs.build.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_STAGING }} + org: ${{ vars.CPLN_ORG_STAGING }} + + - name: Detect release phase support + id: release-phase + shell: bash + run: | + set -euo pipefail + + # Anchor to start-of-line so commented-out release_script: entries don't enable --run-release-phase. + if cpflow config -a "${APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy staging image + env: + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" diff --git a/.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..f859fc5b1 --- /dev/null +++ b/.github/workflows/cpflow-promote-staging-to-production.yml @@ -0,0 +1,377 @@ +name: Promote Staging to Production + +on: + workflow_dispatch: + inputs: + confirm_promotion: + description: Type "promote" to confirm promotion of staging to production + required: true + type: string + +permissions: + contents: write + +env: + # Override these by editing this file or by setting the matching repository variable. + # 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: 24 + 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. 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' }} + ROLLBACK_READINESS_RETRIES: 24 + ROLLBACK_READINESS_INTERVAL: 15 + PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }} + +concurrency: + group: cpflow-promote-staging-to-production + cancel-in-progress: false + +jobs: + promote-to-production: + if: github.event.inputs.confirm_promotion == 'promote' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets and variables + shell: bash + run: | + set -euo pipefail + + missing=() + [[ -n "${{ secrets.CPLN_TOKEN_STAGING }}" ]] || missing+=("secret:CPLN_TOKEN_STAGING") + [[ -n "${{ secrets.CPLN_TOKEN_PRODUCTION }}" ]] || missing+=("secret:CPLN_TOKEN_PRODUCTION") + [[ -n "${{ vars.CPLN_ORG_STAGING }}" ]] || missing+=("variable:CPLN_ORG_STAGING") + [[ -n "${{ vars.CPLN_ORG_PRODUCTION }}" ]] || missing+=("variable:CPLN_ORG_PRODUCTION") + [[ -n "${{ vars.STAGING_APP_NAME }}" ]] || missing+=("variable:STAGING_APP_NAME") + [[ -n "${{ vars.PRODUCTION_APP_NAME }}" ]] || missing+=("variable:PRODUCTION_APP_NAME") + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub configuration:\n- %s\n' "${missing[@]}" >&2 + exit 1 + fi + + - name: Setup production environment + uses: ./.github/actions/cpflow-setup-environment + with: + token: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + org: ${{ vars.CPLN_ORG_PRODUCTION }} + + # Runs after Setup production environment so the pinned Ruby (>= 3.1) is on PATH. + # YAML.load_file(..., aliases: true) is not supported on Ruby 3.0 (system Ruby on ubuntu-22.04). + - name: Resolve production app workloads + id: workloads + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + workloads="$(ruby - "${PRODUCTION_APP_NAME}" <<'RUBY' + require "yaml" + + app = ARGV.fetch(0) + data = YAML.load_file(".controlplane/controlplane.yml", aliases: true) + apps = data["apps"] || {} + app_config = apps[app] + + unless app_config + warn "Error: app '#{app}' is not defined under `apps:` in `.controlplane/controlplane.yml`." + warn " Fix the PRODUCTION_APP_NAME repository variable or add the app to controlplane.yml." + exit 1 + end + + workloads = Array(app_config["app_workloads"]) + workloads = ["rails"] if workloads.empty? + puts workloads.join(",") + RUBY + )" + + echo "names=${workloads}" >> "$GITHUB_OUTPUT" + + - name: Detect release phase support + id: release-phase + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + # Anchor the match so commented-out release_script: lines don't accidentally enable --run-release-phase. + if cpflow config -a "${PRODUCTION_APP_NAME}" | grep -qE '^[[:space:]]*release_script:'; then + echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Verify production environment variables + env: + CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }} + CPLN_TOKEN_PRODUCTION: ${{ secrets.CPLN_TOKEN_PRODUCTION }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_STAGING: ${{ vars.CPLN_ORG_STAGING }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + staging_vars="$(CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln gvc get "${STAGING_APP_NAME}" --org "${CPLN_ORG_STAGING}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + production_vars="$(CPLN_TOKEN="${CPLN_TOKEN_PRODUCTION}" cpln gvc get "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.spec.env // [] | .[].name' | sort)" + + if [[ -z "${staging_vars}" ]]; then + echo "Staging GVC exposes no environment variables; skipping parity check." + exit 0 + fi + + missing_vars="$(comm -23 <(printf '%s\n' "${staging_vars}") <(printf '%s\n' "${production_vars}"))" + + if [[ -n "${missing_vars}" ]]; then + echo "::error::Production is missing environment variables that exist in staging" + echo "${missing_vars}" + exit 1 + fi + + - name: Capture current production image + id: capture-current + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + WORKLOAD_NAMES: ${{ steps.workloads.outputs.names }} + shell: bash + run: | + set -euo pipefail + + selected_workload="${PRIMARY_WORKLOAD:-}" + selected_image="" + selected_version="" + first_image="" + first_version="" + rollback_state='{}' + + while IFS= read -r workload_name; do + [[ -n "${workload_name}" ]] || continue + + workload_json="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json)" + workload_image="$(echo "${workload_json}" | jq -r '.spec.containers[0].image')" + workload_images="$(echo "${workload_json}" | jq -c '.spec.containers | map(.image)')" + workload_version="$(echo "${workload_json}" | jq -r '.version')" + + if [[ -z "${first_image}" ]]; then + first_image="${workload_image}" + first_version="${workload_version}" + fi + + if [[ -n "${selected_workload}" && "${workload_name}" == "${selected_workload}" ]]; then + selected_image="${workload_image}" + selected_version="${workload_version}" + fi + + rollback_state="$( + jq -c \ + --arg workload "${workload_name}" \ + --arg image "${workload_image}" \ + --arg version "${workload_version}" \ + --argjson images "${workload_images}" \ + '. + {($workload): {image: $image, version: $version, images: $images}}' \ + <<< "${rollback_state}" + )" + done < <(tr ',' '\n' <<< "${WORKLOAD_NAMES}") + + current_image="${selected_image:-${first_image}}" + current_version="${selected_version:-${first_version}}" + + echo "current_image=${current_image}" >> "$GITHUB_OUTPUT" + echo "current_version=${current_version}" >> "$GITHUB_OUTPUT" + # Randomize the heredoc delimiter so a stray "EOF" line inside rollback_state can't terminate it early. + delim="EOF_$(openssl rand -hex 8)" + { + echo "rollback_state<<${delim}" + echo "${rollback_state}" + echo "${delim}" + } >> "$GITHUB_OUTPUT" + + - name: Copy image from staging + env: + # Pass the upstream token via env rather than `-t` so it doesn't appear in /proc//cmdline. + CPLN_UPSTREAM_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + cpflow copy-image-from-upstream -a "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" + + - name: Deploy image to production + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + RELEASE_PHASE_FLAG: ${{ steps.release-phase.outputs.flag }} + shell: bash + run: | + set -euo pipefail + + deploy_args=(-a "${PRODUCTION_APP_NAME}") + if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then + deploy_args+=("${RELEASE_PHASE_FLAG}") + fi + deploy_args+=(--org "${CPLN_ORG_PRODUCTION}" --verbose) + + cpflow deploy-image "${deploy_args[@]}" + + - name: Wait for deployment health + id: health-check + env: + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + workload_name="${PRIMARY_WORKLOAD:-rails}" + read -r -a accepted_statuses <<< "${HEALTH_CHECK_ACCEPTED_STATUSES}" + + for attempt in $(seq 1 "${HEALTH_CHECK_RETRIES}"); do + echo "Health check attempt ${attempt}/${HEALTH_CHECK_RETRIES}" + + endpoint="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.endpoint // empty')" + if [[ -n "${endpoint}" ]]; then + http_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${endpoint}" 2>/dev/null || echo 000)" + echo "Endpoint: ${endpoint}, HTTP status: ${http_status}" + + for accepted in "${accepted_statuses[@]}"; do + if [[ "${http_status}" == "${accepted}" ]]; then + echo "healthy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + fi + + if [[ "${attempt}" -lt "${HEALTH_CHECK_RETRIES}" ]]; then + sleep "${HEALTH_CHECK_INTERVAL}" + fi + done + + echo "healthy=false" >> "$GITHUB_OUTPUT" + exit 1 + + - name: Roll back on failure + if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' + env: + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + # Best-effort rollback: try every workload, aggregate failures, exit non-zero at the end + # if any failed. A single cpln hiccup shouldn't leave other workloads mid-promotion. + # `set -e` is intentionally omitted so one failed workload update does not skip the rest. + set -uo pipefail + + rollback_failures=0 + + while IFS=$'\t' read -r workload_name previous_images; do + rollback_args=() + + while IFS=$'\t' read -r index image; do + rollback_args+=(--set "spec.containers[${index}].image=${image}") + done < <(echo "${previous_images}" | jq -r 'to_entries[] | "\(.key)\t\(.value)"') + + if ! cpln workload update "${workload_name}" \ + --gvc "${PRODUCTION_APP_NAME}" \ + --org "${CPLN_ORG_PRODUCTION}" \ + "${rollback_args[@]}"; then + echo "::warning::Rollback failed for workload '${workload_name}'; continuing with remaining workloads." >&2 + rollback_failures=$((rollback_failures + 1)) + fi + done < <(echo "${ROLLBACK_STATE}" | jq -r 'to_entries[] | "\(.key)\t\(.value.images | @json)"') + + if [[ "${rollback_failures}" -gt 0 ]]; then + echo "::error::${rollback_failures} workload(s) failed to roll back; inspect the logs above." >&2 + exit 1 + fi + + - name: Wait for rollback readiness + if: failure() && steps.capture-current.outputs.rollback_state != '' && steps.capture-current.outputs.rollback_state != '{}' + env: + ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + CPLN_ORG_PRODUCTION: ${{ vars.CPLN_ORG_PRODUCTION }} + shell: bash + run: | + set -euo pipefail + + mapfile -t workloads < <(echo "${ROLLBACK_STATE}" | jq -r 'keys[]') + + for workload_name in "${workloads[@]}"; do + [[ -n "${workload_name}" ]] || continue + + echo "Waiting for rollback of workload '${workload_name}' to become ready..." + ready=false + + for attempt in $(seq 1 "${ROLLBACK_READINESS_RETRIES}"); do + deployment_ready="$(cpln workload get "${workload_name}" --gvc "${PRODUCTION_APP_NAME}" --org "${CPLN_ORG_PRODUCTION}" -o json | jq -r '.status.ready // false')" + if [[ "${deployment_ready}" == "true" ]]; then + ready=true + break + fi + + if [[ "${attempt}" -lt "${ROLLBACK_READINESS_RETRIES}" ]]; then + sleep "${ROLLBACK_READINESS_INTERVAL}" + fi + done + + if [[ "${ready}" != "true" ]]; then + echo "::warning::Workload '${workload_name}' did not report ready after rollback." + fi + done + + - name: Create GitHub release + if: success() && steps.health-check.outputs.healthy == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RUN_ID: ${{ github.run_id }} + STAGING_APP_NAME: ${{ vars.STAGING_APP_NAME }} + PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }} + shell: bash + run: | + set -euo pipefail + + release_date="$(date '+%Y-%m-%d')" + timestamp="$(date '+%H%M%S')" + release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}" + + gh release create "${release_tag}" \ + --title "Production Release ${release_date} ${timestamp}" \ + --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}." + + - name: Promotion summary + if: always() + env: + HEALTHY: ${{ steps.health-check.outputs.healthy }} + PREVIOUS_IMAGE: ${{ steps.capture-current.outputs.current_image }} + PREVIOUS_VERSION: ${{ steps.capture-current.outputs.current_version }} + shell: bash + run: | + { + echo "## Promotion Summary" + echo + if [[ "${HEALTHY}" == "true" ]]; then + echo "✅ Status: deployment successful" + else + echo "❌ Status: deployment failed" + fi + echo + echo "Previous image: \`${PREVIOUS_IMAGE}\`" + echo "Previous version: ${PREVIOUS_VERSION}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/cpflow-review-app-help.yml b/.github/workflows/cpflow-review-app-help.yml new file mode 100644 index 000000000..f46412c20 --- /dev/null +++ b/.github/workflows/cpflow-review-app-help.yml @@ -0,0 +1,41 @@ +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 `actions/github-script` with a hardcoded message. + # Do not switch this to `pull_request` or add a checkout step without re-evaluating. + 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}`); - }