From 5a0171829cfa7b7314d2f364df6817d872ee4ba7 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 25 Jan 2025 22:36:46 -1000 Subject: [PATCH] Improvements to deployments (#615) Improve deployments and CI/CD workflows Summary: Introduced new GitHub Actions workflows for Docker image building and deployments. Enhanced Control Plane CLI and environment setup configurations. Streamlined concurrency management and deployment status tracking. Added new scripts and environment variables for commit tracking and deployment actions. Added documentation for CI automation and Review Apps. --- .controlplane/Dockerfile | 8 +- .controlplane/readme.md | 29 ++ .controlplane/shakacode-team.md | 6 + .dockerignore | 6 +- .github/actions/build-docker-image/action.yml | 32 ++ .../delete-control-plane-app/action.yml | 20 + .../deploy-to-control-plane/action.yml | 109 +++-- .../scripts/delete-app.sh | 36 ++ .../deploy-to-control-plane/scripts/deploy.sh | 51 +++ .../scripts/get-commit-sha.sh | 34 ++ .github/actions/setup-environment/action.yml | 23 +- .github/workflows/delete-review-app.yml | 109 +++++ .../deploy-to-control-plane-review.yml | 85 ---- .../deploy-to-control-plane-staging.yml | 8 +- .github/workflows/deploy-to-control-plane.yml | 404 ++++++++++++++++++ .github/workflows/help-command.yml | 90 ++++ .../nightly-remove-stale-review-apps.yml | 34 +- .../promote-staging-to-production.yml | 56 +++ .gitignore | 2 +- CHANGELOG.md | 6 + 20 files changed, 1010 insertions(+), 138 deletions(-) create mode 100644 .controlplane/shakacode-team.md create mode 100644 .github/actions/build-docker-image/action.yml create mode 100644 .github/actions/delete-control-plane-app/action.yml create mode 100755 .github/actions/deploy-to-control-plane/scripts/delete-app.sh create mode 100755 .github/actions/deploy-to-control-plane/scripts/deploy.sh create mode 100755 .github/actions/deploy-to-control-plane/scripts/get-commit-sha.sh create mode 100644 .github/workflows/delete-review-app.yml delete mode 100644 .github/workflows/deploy-to-control-plane-review.yml create mode 100644 .github/workflows/deploy-to-control-plane.yml create mode 100644 .github/workflows/help-command.yml create mode 100644 .github/workflows/promote-staging-to-production.yml diff --git a/.controlplane/Dockerfile b/.controlplane/Dockerfile index 3ca0447ec..8a809700f 100644 --- a/.controlplane/Dockerfile +++ b/.controlplane/Dockerfile @@ -2,6 +2,10 @@ ARG RUBY_VERSION=3.3.4 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base +# Current commit hash environment variable +ARG GIT_COMMIT +ENV GIT_COMMIT_SHA=${GIT_COMMIT} + # Install packages needed to build gems and node modules RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential curl git libpq-dev libvips node-gyp pkg-config python-is-python3 @@ -76,7 +80,3 @@ ENTRYPOINT ["./.controlplane/entrypoint.sh"] # Default args to pass to the entry point that can be overridden # For Kubernetes and ControlPlane, these are the "workload args" CMD ["./bin/rails", "server"] - -# Current commit hash environment variable -ARG GIT_COMMIT_SHA -ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} diff --git a/.controlplane/readme.md b/.controlplane/readme.md index cfab19a8f..d3fe18501 100644 --- a/.controlplane/readme.md +++ b/.controlplane/readme.md @@ -123,3 +123,32 @@ cpflow build-image -a $APP_NAME --commit ABCD ### `entrypoint.sh` - waits for Postgres and Redis to be available - runs `rails db:prepare` to create/seed or migrate the database + +## CI Automation, Review Apps and Staging + +_Note, some of the URL references are internal for the ShakaCode team._ + + Review Apps (deployment of apps based on a PR) are done via Github Actions. + +The review apps work by creating isolated deployments for each branch through this automated process. When a branch is pushed, the action: + +1. Sets up the necessary environment and tools +2. Creates a unique deployment for that branch if it doesn't exist +3. Builds a Docker image tagged with the branch's commit SHA +4. Deploys this image to Control Plane with its own isolated environment + +This allows teams to: +- Preview changes in a production-like environment +- Test features independently +- Share working versions with stakeholders +- Validate changes before merging to main branches + +The system uses Control Plane's infrastructure to manage these deployments, with each branch getting its own resources as defined in the controlplane.yml configuration. + + +### Workflow for Developing Github Actions for Review Apps + +1. Create a PR with changes to the Github Actions workflow +2. Make edits to file such as `.github/actions/deploy-to-control-plane/action.yml` +3. Run a script like `ga .github && gc -m fixes && gp` to commit and push changes (ga = git add, gc = git commit, gp = git push) +4. Check the Github Actions tab in the PR to see the status of the workflow diff --git a/.controlplane/shakacode-team.md b/.controlplane/shakacode-team.md new file mode 100644 index 000000000..0a6273a0b --- /dev/null +++ b/.controlplane/shakacode-team.md @@ -0,0 +1,6 @@ +# Internal Notes to the Shakacode Team + +## 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) diff --git a/.dockerignore b/.dockerignore index 8ee7cce96..4ddbcbc53 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,10 @@ dump.rdb .DS_Store # Ignore bundle dependencies -vendor/ruby +vendor/bundle + +# Ignore GitHub Actions and workflows +.github/ # RVM gemset .ruby-gemset @@ -45,6 +48,5 @@ yarn-debug.log* ################################################### # Specific to .dockerignore .git/ -.github/ spec/ scripts/ diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 000000000..e1b5df73f --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,32 @@ +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: true + +runs: + using: "composite" + steps: + - name: Build Docker Image + id: build + shell: bash + run: | + echo "🏗️ Building Docker image for PR #${PR_NUMBER} (commit ${{ inputs.commit }})..." + + if cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}"; then + echo "✅ Docker image build successful for PR #${PR_NUMBER} (commit ${{ inputs.commit }})" + else + echo "❌ Docker image build failed for PR #${PR_NUMBER} (commit ${{ inputs.commit }})" + exit 1 + fi diff --git a/.github/actions/delete-control-plane-app/action.yml b/.github/actions/delete-control-plane-app/action.yml new file mode 100644 index 000000000..d5d13ef74 --- /dev/null +++ b/.github/actions/delete-control-plane-app/action.yml @@ -0,0 +1,20 @@ +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 + org: + description: 'Organization name' + required: true + +runs: + using: "composite" + steps: + - name: Delete Application + shell: bash + run: ${{ github.action_path }}/../deploy-to-control-plane/scripts/delete-app.sh + env: + APP_NAME: ${{ inputs.app_name }} + CPLN_ORG: ${{ inputs.org }} diff --git a/.github/actions/deploy-to-control-plane/action.yml b/.github/actions/deploy-to-control-plane/action.yml index 43a9eb97b..1e5c9fa78 100644 --- a/.github/actions/deploy-to-control-plane/action.yml +++ b/.github/actions/deploy-to-control-plane/action.yml @@ -1,56 +1,91 @@ # Control Plane GitHub Action -name: Deploy-To-Control-Plane -description: 'Deploys both to staging and to review apps' +name: Deploy to Control Plane +description: 'Deploys an application to Control Plane' inputs: app_name: - description: 'The name of the app to deploy' + description: 'Name of the application' required: true - default: org: - description: 'The org of the app to deploy' + description: 'Organization name' required: true - default: + github_token: + description: 'GitHub token' + required: true + wait_timeout: + description: 'Timeout in seconds for waiting for workloads to be ready' + required: false + default: '900' + +outputs: + review_app_url: + description: 'URL of the deployed application' + value: ${{ steps.deploy.outputs.review_app_url }} runs: - using: 'composite' + using: "composite" steps: - name: Setup Environment uses: ./.github/actions/setup-environment - - name: Set Short SHA - id: vars + - name: Get Commit SHA + id: get_sha shell: bash - run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" - - # Caching step - - uses: actions/cache@v2 - with: - path: /tmp/.docker-cache - key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/package.json', '**/yarn.lock') }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile', '**/package.json', '**/yarn.lock') }} - ${{ runner.os }}-docker- + run: ${{ github.action_path }}/scripts/get-commit-sha.sh + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + PR_NUMBER: ${{ env.PR_NUMBER }} - - name: cpflow setup-app - shell: bash - run: | - if ! cpflow exists -a ${{ inputs.app_name }} ; then - cpflow setup-app -a ${{ inputs.app_name }} - fi - # Provision all infrastructure on Control Plane. - # app react-webpack-rails-tutorial will be created per definition in .controlplane/controlplane.yml - - name: cpflow build-image - shell: bash - run: | - cpln image docker-login - # Use BUILDKIT_PROGRESS=plain to get more verbose logging of the build - # BUILDKIT_PROGRESS=plain cpflow build-image -a ${{ inputs.app_name }} --commit ${{steps.vars.outputs.sha_short}} --org ${{inputs.org}} - cpflow build-image -a ${{ inputs.app_name }} --commit ${{steps.vars.outputs.sha_short}} --org ${{inputs.org}} - # --cache /tmp/.docker-cache - name: Deploy to Control Plane + id: deploy shell: bash run: | - echo "Deploying to Control Plane" - cpflow deploy-image -a ${{ inputs.app_name }} --run-release-phase --org ${{inputs.org}} --verbose + echo "🚀 Deploying app for PR #${PR_NUMBER}..." + + # Create temp file for output + TEMP_OUTPUT=$(mktemp) + trap 'rm -f "${TEMP_OUTPUT}"' EXIT + + # Deploy the application and show output in real-time while capturing it + if ! cpflow deploy-image -a "${{ inputs.app_name }}" --run-release-phase --org "${{ inputs.org }}" 2>&1 | tee "${TEMP_OUTPUT}"; then + echo "❌ Deployment failed for PR #${PR_NUMBER}" + echo "Error output:" + cat "${TEMP_OUTPUT}" + exit 1 + fi + + # Extract app URL from captured output + REVIEW_APP_URL=$(grep -oP 'https://rails-[^[:space:]]*\.cpln\.app(?=\s|$)' "${TEMP_OUTPUT}" | head -n1) + if [ -z "${REVIEW_APP_URL}" ]; then + echo "❌ Failed to get app URL from deployment output" + echo "Deployment output:" + cat "${TEMP_OUTPUT}" + exit 1 + fi + + # Wait for all workloads to be ready + WAIT_TIMEOUT=${WAIT_TIMEOUT:-${{ inputs.wait_timeout }}} + if ! [[ "${WAIT_TIMEOUT}" =~ ^[0-9]+$ ]]; then + echo "❌ Invalid timeout value: ${WAIT_TIMEOUT}" + exit 1 + fi + echo "⏳ Waiting for all workloads to be ready (timeout: ${WAIT_TIMEOUT}s)" + + # Use timeout command with ps:wait and show output in real-time + if ! timeout "${WAIT_TIMEOUT}" bash -c "cpflow ps:wait -a \"${{ inputs.app_name }}\"" 2>&1 | tee -a "${TEMP_OUTPUT}"; then + TIMEOUT_EXIT=$? + if [ ${TIMEOUT_EXIT} -eq 124 ]; then + echo "❌ Timed out waiting for workloads after ${WAIT_TIMEOUT} seconds" + else + echo "❌ Workloads did not become ready for PR #${PR_NUMBER} (exit code: ${TIMEOUT_EXIT})" + fi + echo "Full output:" + cat "${TEMP_OUTPUT}" + exit 1 + fi + + echo "✅ Deployment successful for PR #${PR_NUMBER}" + echo "🌐 App URL: ${REVIEW_APP_URL}" + echo "review_app_url=${REVIEW_APP_URL}" >> $GITHUB_OUTPUT + echo "REVIEW_APP_URL=${REVIEW_APP_URL}" >> $GITHUB_ENV diff --git a/.github/actions/deploy-to-control-plane/scripts/delete-app.sh b/.github/actions/deploy-to-control-plane/scripts/delete-app.sh new file mode 100755 index 000000000..92e8fbc32 --- /dev/null +++ b/.github/actions/deploy-to-control-plane/scripts/delete-app.sh @@ -0,0 +1,36 @@ +#!/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"; 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" --force; then + echo "❌ Failed to delete application: $APP_NAME" >&2 + exit 1 +fi + +echo "✅ Successfully deleted application: $APP_NAME" diff --git a/.github/actions/deploy-to-control-plane/scripts/deploy.sh b/.github/actions/deploy-to-control-plane/scripts/deploy.sh new file mode 100755 index 000000000..9d070b64a --- /dev/null +++ b/.github/actions/deploy-to-control-plane/scripts/deploy.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# This script handles the deployment to Control Plane and extracts the Rails URL +# +# Required environment variables: +# - APP_NAME: Name of the application to deploy +# - CPLN_ORG: Control Plane organization +# +# Optional environment variables: +# - WAIT_TIMEOUT: Timeout in seconds for deployment (default: 900) +# Must be a positive integer +# +# Outputs: +# - rails_url: URL of the deployed Rails application + +set -e + +# Validate required environment variables +: "${APP_NAME:?APP_NAME environment variable is required}" +: "${CPLN_ORG:?CPLN_ORG environment variable is required}" + +# Set and validate deployment timeout +WAIT_TIMEOUT=${WAIT_TIMEOUT:-900} +if ! [[ "${WAIT_TIMEOUT}" =~ ^[0-9]+$ ]]; then + echo "❌ Invalid timeout value: ${WAIT_TIMEOUT}" + exit 1 +fi + +TEMP_OUTPUT=$(mktemp) +trap 'rm -f "$TEMP_OUTPUT"' EXIT + +# Deploy the application +echo "🚀 Deploying to Control Plane (timeout: ${WAIT_TIMEOUT}s)" +if timeout "$WAIT_TIMEOUT" cpflow deploy-image -a "$APP_NAME" --run-release-phase --org "$CPLN_ORG" --verbose | tee "$TEMP_OUTPUT"; then + # Extract Rails URL from deployment output + RAILS_URL=$(grep -oP 'https://rails-[^[:space:]]*\.cpln\.app(?=\s|$)' "$TEMP_OUTPUT" | head -n1) + if [ -n "$RAILS_URL" ]; then + echo "rails_url=$RAILS_URL" >> "$GITHUB_OUTPUT" + echo "✅ Deployment successful" + echo "🚀 Rails URL: $RAILS_URL" + else + echo "❌ Failed to extract Rails URL from deployment output" + exit 1 + fi +elif [ $? -eq 124 ]; then + echo "❌ Deployment timed out after $WAIT_TIMEOUT seconds" + exit 1 +else + echo "❌ Deployment to Control Plane failed" + exit 1 +fi diff --git a/.github/actions/deploy-to-control-plane/scripts/get-commit-sha.sh b/.github/actions/deploy-to-control-plane/scripts/get-commit-sha.sh new file mode 100755 index 000000000..9dd32cd0f --- /dev/null +++ b/.github/actions/deploy-to-control-plane/scripts/get-commit-sha.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# This script retrieves the commit SHA for deployment +# It handles both PR and direct branch deployments +# +# Required environment variables: +# - PR_NUMBER: Pull request number (optional) +# - GITHUB_TOKEN: GitHub token for API access +# +# Outputs: +# - sha: Full commit SHA +# - sha_short: Short (7 char) commit SHA + +set -e + +if [ -n "${PR_NUMBER}" ]; then + # If PR_NUMBER is set, get the PR's head SHA + if ! PR_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid'); then + echo "Failed to get PR head SHA" >&2 + exit 1 + fi + echo "sha=${PR_SHA}" >> "$GITHUB_OUTPUT" + echo "sha_short=${PR_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "Using PR head commit SHA: ${PR_SHA:0:7}" +else + # For direct branch deployments, use the current commit SHA + if ! CURRENT_SHA=$(git rev-parse HEAD); then + echo "Failed to get current SHA" >&2 + exit 1 + fi + echo "sha=${CURRENT_SHA}" >> "$GITHUB_OUTPUT" + echo "sha_short=${CURRENT_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "Using branch commit SHA: ${CURRENT_SHA:0:7}" +fi diff --git a/.github/actions/setup-environment/action.yml b/.github/actions/setup-environment/action.yml index 96185f7b1..829a9498c 100644 --- a/.github/actions/setup-environment/action.yml +++ b/.github/actions/setup-environment/action.yml @@ -14,12 +14,27 @@ runs: - name: Install Control Plane CLI and cpflow gem shell: bash run: | - sudo npm install -g @controlplane/cli@3.1.0 + sudo npm install -g @controlplane/cli@3.3.0 cpln --version - gem install cpflow -v 4.0.0 + gem install cpflow -v 4.1.0 cpflow --version - - name: cpln profile + - name: Setup Control Plane Profile shell: bash run: | - cpln profile update default + if [ -z "$CPLN_TOKEN" ]; then + echo " Error: CPLN_TOKEN environment variable is not set" + exit 1 + fi + + if [ -z "$CPLN_ORG" ]; then + echo " Error: CPLN_ORG environment variable is not set" + exit 1 + fi + + echo "Setting up Control Plane profile..." + echo "Organization: $CPLN_ORG" + cpln profile update default --org "$CPLN_ORG" --token "$CPLN_TOKEN" + + echo "Setting up Docker login for Control Plane registry..." + cpln image docker-login --org "$CPLN_ORG" diff --git a/.github/workflows/delete-review-app.yml b/.github/workflows/delete-review-app.yml new file mode 100644 index 000000000..074b7c7e8 --- /dev/null +++ b/.github/workflows/delete-review-app.yml @@ -0,0 +1,109 @@ +name: Delete Review App + +on: + issue_comment: + types: [created] + +permissions: + contents: read + deployments: write + pull-requests: write + issues: write + +env: + CPLN_ORG: ${{ secrets.CPLN_ORG }} + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN }} + APP_NAME: qa-react-webpack-rails-tutorial-pr-${{ github.event.issue.number }} + PR_NUMBER: ${{ github.event.issue.number }} + +jobs: + debug-trigger: + if: always() + runs-on: ubuntu-latest + steps: + - name: Debug Trigger Conditions + env: + EVENT_NAME: ${{ github.event_name }} + IS_PR: ${{ toJSON(github.event.issue.pull_request) }} + COMMENT: ${{ github.event.comment.body }} + run: | + echo "Debug information for delete-review-app command:" + echo "Event name: $EVENT_NAME" + echo "Is PR (raw): $IS_PR" + echo "Comment body: $COMMENT" + echo "Raw event payload:" + echo '${{ toJSON(github.event) }}' + + Process-Delete-Command: + needs: debug-trigger + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/delete-review-app' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate Required Secrets + run: | + missing_secrets=() + for secret in "CPLN_TOKEN" "CPLN_ORG"; do + if [ -z "${!secret}" ]; then + missing_secrets+=("$secret") + fi + done + + if [ ${#missing_secrets[@]} -ne 0 ]; then + echo " Required secrets are not set: ${missing_secrets[*]}" + exit 1 + fi + + - name: Setup Environment + uses: ./.github/actions/setup-environment + + - name: Create Initial Delete Comment + id: init-delete + uses: actions/github-script@v7 + with: + script: | + 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 }} + org: ${{ env.CPLN_ORG }} + github_token: ${{ secrets.GITHUB_TOKEN }} + env: + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN }} + + - name: Update Delete Status + if: always() + uses: actions/github-script@v7 + with: + script: | + 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 message = success + ? ' Review app for PR #' + prNumber + ' was successfully deleted' + : [ + ' Review app for PR #' + prNumber + ' failed to be deleted', + '', + '[Control Plane Console for Review App with PR #' + prNumber + '](' + cpConsoleUrl + ')' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: ${{ fromJSON(steps.init-delete.outputs.result).commentId }}, + body: message + }); diff --git a/.github/workflows/deploy-to-control-plane-review.yml b/.github/workflows/deploy-to-control-plane-review.yml deleted file mode 100644 index 9d6a6d82e..000000000 --- a/.github/workflows/deploy-to-control-plane-review.yml +++ /dev/null @@ -1,85 +0,0 @@ -# Control Plane GitHub Action - -name: Deploy Review App to Control Plane - -# Controls when the workflow will run -on: - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - - # Uncomment these lines to trigger the workflow on pull request events - # pull_request: - # branches: - # - master - - # deploy on comment "/deploy-review-app" - issue_comment: - types: [created, edited] - -# Convert the GitHub secret variables to environment variables for use by the Control Plane CLI -env: - CPLN_ORG: ${{secrets.CPLN_ORG_STAGING}} - CPLN_TOKEN: ${{secrets.CPLN_TOKEN_STAGING}} - # Uncomment this line to use the PR number from the pull requests trigger event (that trigger is commented) - # PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - PR_NUMBER: ${{ github.event.issue.number }} - -jobs: - deploy-to-control-plane-review: - if: ${{ github.event_name != 'issue_comment' || (github.event.comment.body == '/deploy-review-app' && github.event.issue.pull_request) }} - runs-on: ubuntu-latest - - steps: - - name: Get PR HEAD Ref - if: ${{ github.event_name == 'issue_comment' }} - id: getRef - run: echo "PR_REF=$(gh pr view $PR_NUMBER --repo ${{ github.repository }} --json headRefName | jq -r '.headRefName')" >> $GITHUB_OUTPUT - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout source code from Github - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ steps.getRef.outputs.PR_REF || github.ref }} - - - name: Add GitHub Comment - if: ${{ github.event_name == 'issue_comment' }} - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: "We started working on your review-app deployment. You can track progress in the `Actions` Tab [here](https://github.com/shakacode/react-webpack-rails-tutorial/actions/workflows/deploy-to-control-plane-review.yml) on Github." - }) - - - name: Get PR number - if: ${{ github.event_name != 'issue_comment' }} - run: | - echo "GITHUB_REPOSITORY: \"$GITHUB_REPOSITORY\"" - if [ -z "$PR_NUMBER" ]; then - echo "PR_NUMBER is not in the trigger event. Fetching PR number from open PRs." - REF="${{ github.ref }}" - REF=${REF#refs/heads/} # Remove 'refs/heads/' prefix - echo "REF: \"$REF\"" - API_RESPONSE=$(curl --location --request GET "https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls?state=open" \ - --header 'Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}') - PR_NUMBER=$(echo "$API_RESPONSE" | jq '.[] | select(.head.ref=="'$REF'") | .number') - fi - echo "PR_NUMBER: $PR_NUMBER" - if [ -z "$PR_NUMBER" ]; then - echo "PR_NUMBER is not set. Aborting." - exit 1 - fi - echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - - name: Get App Name - run: | - echo "PR_NUMBER: ${{ env.PR_NUMBER }}" - echo "APP_NAME=qa-react-webpack-rails-tutorial-pr-${{ env.PR_NUMBER }}" >> "$GITHUB_ENV" - echo "App Name: ${{ env.APP_NAME }}" - - uses: ./.github/actions/deploy-to-control-plane - with: - app_name: ${{ env.APP_NAME }} - org: ${{ env.CPLN_ORG }} diff --git a/.github/workflows/deploy-to-control-plane-staging.yml b/.github/workflows/deploy-to-control-plane-staging.yml index 1f276b5c8..095c635a7 100644 --- a/.github/workflows/deploy-to-control-plane-staging.yml +++ b/.github/workflows/deploy-to-control-plane-staging.yml @@ -1,6 +1,6 @@ # Control Plane GitHub Action -name: Deploy-To-Control-Plane-Staging +name: Deploy Main Branch to Control Plane Staging # Controls when the workflow will run on: @@ -21,8 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Check out the repo - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for proper SHA handling + ref: master # Explicitly checkout master branch - uses: ./.github/actions/deploy-to-control-plane with: diff --git a/.github/workflows/deploy-to-control-plane.yml b/.github/workflows/deploy-to-control-plane.yml new file mode 100644 index 000000000..ad1dbb490 --- /dev/null +++ b/.github/workflows/deploy-to-control-plane.yml @@ -0,0 +1,404 @@ +name: Deploy Review App to Control Plane + +run-name: ${{ (github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request)) && 'Deploying Review App' || format('Deploying {0} to Staging App', github.ref_name) }} + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + +# Use concurrency to cancel in-progress runs +concurrency: + group: deploy-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +env: + APP_NAME: qa-react-webpack-rails-tutorial-pr-${{ github.event.pull_request.number || github.event.issue.number }} + CPLN_ORG: ${{ secrets.CPLN_ORG }} + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + +jobs: + Process-Deployment-Command: + if: | + (github.event_name == 'pull_request') || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/deploy') + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + pull-requests: write + issues: write + + steps: + - name: Get PR HEAD Ref + if: github.event_name == 'issue_comment' + id: getRef + run: | + # For PR comments, get the actual PR head commit + PR_DATA=$(gh pr view $PR_NUMBER --repo ${{ github.repository }} --json headRefName,headRefOid) + echo "PR_REF=$(echo "$PR_DATA" | jq -r '.headRefName')" >> $GITHUB_OUTPUT + echo "PR_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || steps.getRef.outputs.PR_REF || github.ref }} + + - name: Validate Required Secrets + run: | + missing_secrets=() + for secret in "CPLN_TOKEN" "CPLN_ORG"; do + if [ -z "${!secret}" ]; then + missing_secrets+=("$secret") + fi + done + + if [ ${#missing_secrets[@]} -ne 0 ]; then + echo "Required secrets are not set: ${missing_secrets[*]}" + exit 1 + fi + + - name: Setup Environment + uses: ./.github/actions/setup-environment + + - 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 for Review App with PR #' + prNumber + '](' + + 'https://console.cpln.io/org/' + process.env.CPLN_ORG + '/workloads/' + process.env.APP_NAME + ')'; + } + `); + + - name: Initialize Deployment + id: init-deployment + uses: actions/github-script@v7 + with: + script: | + eval(process.env.GET_CONSOLE_LINK); + + 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}`; + } + + // Create initial deployment comment + const comment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + body: ' Initializing deployment...' + }); + + // Create GitHub deployment + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: 'review', + auto_merge: false, + required_contexts: [] + }); + + const workflowUrl = await getWorkflowUrl(context.runId); + + return { + deploymentId: deployment.data.id, + commentId: comment.data.id, + workflowUrl + }; + + - name: Set comment ID and workflow URL + run: | + echo "COMMENT_ID=${{ fromJSON(steps.init-deployment.outputs.result).commentId }}" >> $GITHUB_ENV + echo "WORKFLOW_URL=${{ fromJSON(steps.init-deployment.outputs.result).workflowUrl }}" >> $GITHUB_ENV + + - name: Set commit hash + run: | + FULL_COMMIT="${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || steps.getRef.outputs.PR_SHA || github.sha }}" + echo "COMMIT_HASH=${FULL_COMMIT:0:7}" >> $GITHUB_ENV + + - name: Update Status - Building + uses: actions/github-script@v7 + with: + script: | + eval(process.env.GET_CONSOLE_LINK); + + const buildingMessage = [ + ' Building Docker image for PR #' + process.env.PR_NUMBER + ', commit ' + '${{ env.COMMIT_HASH }}', + '', + ' [View Build Logs](' + process.env.WORKFLOW_URL + ')', + '', + getConsoleLink(process.env.PR_NUMBER) + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: process.env.COMMENT_ID, + body: buildingMessage + }); + + - name: Build Docker Image + uses: ./.github/actions/build-docker-image + with: + app_name: ${{ env.APP_NAME }} + org: ${{ env.CPLN_ORG }} + commit: ${{ env.COMMIT_HASH }} + PR_NUMBER: ${{ env.PR_NUMBER }} + + - name: Update Status - Deploying + uses: actions/github-script@v7 + with: + script: | + eval(process.env.GET_CONSOLE_LINK); + + const deployingMessage = [ + ' Deploying to Control Plane...', + '', + ' Waiting for deployment to be ready...', + '', + ' [View Deploy Logs](' + process.env.WORKFLOW_URL + ')', + '', + getConsoleLink(process.env.PR_NUMBER) + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: process.env.COMMENT_ID, + body: deployingMessage + }); + + - name: Deploy to Control Plane + uses: ./.github/actions/deploy-to-control-plane + with: + app_name: ${{ env.APP_NAME }} + org: ${{ env.CPLN_ORG }} + github_token: ${{ secrets.GITHUB_TOKEN }} + wait_timeout: ${{ vars.WAIT_TIMEOUT || 900 }} + + - name: Update Status - Deployment Complete + uses: actions/github-script@v7 + with: + script: | + eval(process.env.GET_CONSOLE_LINK); + + const prNumber = process.env.PR_NUMBER; + const appUrl = process.env.REVIEW_APP_URL; + const workflowUrl = process.env.WORKFLOW_URL; + const isSuccess = '${{ job.status }}' === 'success'; + + // Create GitHub deployment status + const deploymentStatus = { + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: ${{ fromJSON(steps.init-deployment.outputs.result).deploymentId }}, + 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 = [ + ' Deployment complete for PR #' + prNumber + ', commit ' + '${{ env.COMMIT_HASH }}', + '', + ' [Review App for PR #' + prNumber + '](' + appUrl + ')', + '', + ' [View Completed Action Build and Deploy Logs](' + workflowUrl + ')', + '', + getConsoleLink(prNumber) + ].join('\n'); + + const failureMessage = [ + ' Deployment failed for PR #' + prNumber + ', commit ' + '${{ env.COMMIT_HASH }}', + '', + ' [View Deployment Logs with Errors](' + workflowUrl + ')', + '', + getConsoleLink(prNumber) + ].join('\n'); + + // Update the existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: process.env.COMMENT_ID, + body: isSuccess ? successMessage : failureMessage + }); + + debug-help: + if: always() + runs-on: ubuntu-latest + steps: + - name: Debug Trigger Conditions + env: + EVENT_NAME: ${{ github.event_name }} + IS_PR: ${{ toJSON(github.event.issue.pull_request) }} + COMMENT: ${{ github.event.comment.body }} + run: | + echo "Debug information for help command:" + echo "Event name: $EVENT_NAME" + echo "Is PR (raw): $IS_PR" + echo "Comment body: $COMMENT" + echo "Raw event payload:" + echo '${{ toJSON(github.event) }}' + + show-help: + needs: debug-help + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/help' + runs-on: ubuntu-latest + + steps: + - name: Show Available Commands + uses: actions/github-script@v7 + with: + script: | + const helpMessage = [ + '## Available Commands', + '', + '### `/deploy`', + 'Deploys your PR branch to a review environment on Control Plane.', + '- Creates a new review app if one doesn\'t exist', + '- Updates the existing review app if it already exists', + '- Provides a unique URL to preview your changes', + '- Shows build and deployment progress in real-time', + '', + '### `/delete-review-app`', + 'Deletes the review app associated with this PR.', + '- Removes all resources from Control Plane', + '- Helpful for cleaning up when you\'re done testing', + '- Can be re-deployed later using `/deploy`', + '', + '### `/help`', + 'Shows this help message explaining available commands.', + '', + '---', + '_Note: These commands only work in pull request comments._' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: helpMessage + }); + + debug-delete: + if: always() + runs-on: ubuntu-latest + steps: + - name: Debug Trigger Conditions + env: + EVENT_NAME: ${{ github.event_name }} + IS_PR: ${{ toJSON(github.event.issue.pull_request) }} + COMMENT: ${{ github.event.comment.body }} + run: | + echo "Debug information for delete-review-app command:" + echo "Event name: $EVENT_NAME" + echo "Is PR (raw): $IS_PR" + echo "Comment body: $COMMENT" + echo "Raw event payload:" + echo '${{ toJSON(github.event) }}' + + Process-Delete-Command: + needs: debug-delete + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/delete-review-app' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate Required Secrets + run: | + missing_secrets=() + for secret in "CPLN_TOKEN" "CPLN_ORG"; do + if [ -z "${!secret}" ]; then + missing_secrets+=("$secret") + fi + done + + if [ ${#missing_secrets[@]} -ne 0 ]; then + echo "Required secrets are not set: ${missing_secrets[*]}" + exit 1 + fi + + - name: Setup Environment + uses: ./.github/actions/setup-environment + + - name: Create Initial Delete Comment + id: init-delete + uses: actions/github-script@v7 + with: + script: | + 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 }} + org: ${{ env.CPLN_ORG }} + github_token: ${{ secrets.GITHUB_TOKEN }} + env: + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN }} + + - name: Update Delete Status + if: always() + uses: actions/github-script@v7 + with: + script: | + 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 message = success + ? ' Review app for PR #' + prNumber + ' was successfully deleted' + : [ + ' Review app for PR #' + prNumber + ' failed to be deleted', + '', + ' [Control Plane Console for Review App with PR #' + prNumber + '](' + cpConsoleUrl + ')' + ].join('\n'); + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: ${{ fromJSON(steps.init-delete.outputs.result).commentId }}, + body: message + }); \ No newline at end of file diff --git a/.github/workflows/help-command.yml b/.github/workflows/help-command.yml new file mode 100644 index 000000000..20a0e8285 --- /dev/null +++ b/.github/workflows/help-command.yml @@ -0,0 +1,90 @@ +name: Show Help for Commands + +on: + issue_comment: + types: [created] + +permissions: + issues: write + pull-requests: write + +jobs: + debug-trigger: + if: always() + runs-on: ubuntu-latest + steps: + - name: Debug Trigger Conditions + env: + EVENT_NAME: ${{ github.event_name }} + IS_PR: ${{ toJSON(github.event.issue.pull_request) }} + COMMENT: ${{ github.event.comment.body }} + run: | + echo "Debug information for help command:" + echo "Event name: $EVENT_NAME" + echo "Is PR (raw): $IS_PR" + echo "Comment body: $COMMENT" + echo "Raw event payload:" + echo '${{ toJSON(github.event) }}' + + show-help: + needs: debug-trigger + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == '/help' + runs-on: ubuntu-latest + + steps: + - name: Show Available Commands + uses: actions/github-script@v7 + with: + script: | + const helpMessage = [ + '## 📚 Available Commands', + '', + '### `/deploy`', + 'Deploys your PR branch to a review environment on Control Plane.', + '- Creates a new review app if one doesn\'t exist', + '- Updates the existing review app if it already exists', + '- Provides a unique URL to preview your changes', + '- Shows build and deployment progress in real-time', + '', + '**Required Environment Variables:**', + '- `CPLN_TOKEN`: Control Plane authentication token', + '- `CPLN_ORG`: Control Plane organization name', + '', + '**Optional Configuration:**', + '- `WAIT_TIMEOUT`: Deployment timeout in seconds (default: 900)', + ' - Must be a positive integer', + ' - Can be set in GitHub Actions variables', + ' - Applies to both deployment and workload readiness checks', + '', + '### `/delete-review-app`', + 'Deletes the review app associated with this PR.', + '- Removes all resources from Control Plane', + '- Helpful for cleaning up when you\'re done testing', + '- Can be re-deployed later using `/deploy`', + '', + '**Required Environment Variables:**', + '- `CPLN_TOKEN`: Control Plane authentication token', + '- `CPLN_ORG`: Control Plane organization name', + '', + '### `/help`', + 'Shows this help message explaining available commands and configuration.', + '', + '---', + '**Note:** These commands only work in pull request comments.', + '', + '**Environment Setup:**', + '1. Set required secrets in your repository settings:', + ' - `CPLN_TOKEN`', + ' - `CPLN_ORG`', + '2. Optional: Configure `WAIT_TIMEOUT` in GitHub Actions variables to customize deployment timeout' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: helpMessage + }); diff --git a/.github/workflows/nightly-remove-stale-review-apps.yml b/.github/workflows/nightly-remove-stale-review-apps.yml index 9f3985ba0..c5f0376ae 100644 --- a/.github/workflows/nightly-remove-stale-review-apps.yml +++ b/.github/workflows/nightly-remove-stale-review-apps.yml @@ -21,9 +21,39 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup-environment - - name: Run cleanup-stale-apps script + - name: Get Stale PRs + id: stale_prs + uses: actions/github-script@v7 + with: + script: | + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + sort: 'updated', + direction: 'desc' + }); + + const stalePRs = prs.data + .filter(pr => new Date(pr.updated_at) < thirtyDaysAgo) + .map(pr => pr.number); + + console.log('Found stale PRs:', stalePRs); + return stalePRs; + + - name: Delete Stale Review Apps + if: ${{ steps.stale_prs.outputs.result != '[]' }} run: | - cpflow cleanup-stale-apps -a qa-react-webpack-rails-tutorial -y + for pr in $(echo "${{ steps.stale_prs.outputs.result }}" | jq -r '.[]'); do + APP_NAME="qa-react-webpack-rails-tutorial-pr-$pr" + echo "🗑️ Deleting stale review app for PR #$pr: $APP_NAME" + ${{ github.workspace }}/.github/actions/deploy-to-control-plane/scripts/delete-app.sh + done + env: + APP_NAME: qa-react-webpack-rails-tutorial-pr-${{ steps.stale_prs.outputs.result }} - name: Run cleanup-images script run: | diff --git a/.github/workflows/promote-staging-to-production.yml b/.github/workflows/promote-staging-to-production.yml new file mode 100644 index 000000000..041480671 --- /dev/null +++ b/.github/workflows/promote-staging-to-production.yml @@ -0,0 +1,56 @@ +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 + +jobs: + promote-to-production: + runs-on: ubuntu-latest + if: github.event.inputs.confirm_promotion == 'promote' + + env: + APP_NAME: react-webpack-rails-tutorial + CPLN_ORG: ${{ secrets.CPLN_ORG }} + UPSTREAM_TOKEN: ${{ secrets.STAGING_TOKEN }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Environment + uses: ./.github/actions/setup-environment + env: + CPLN_TOKEN: ${{ secrets.CPLN_TOKEN }} + + - name: Promote Staging to Production + id: promote + run: | + echo "🚀 Starting promotion from staging to production..." + + if ! cpflow promote-app-from-upstream -a "${APP_NAME}" -t "${UPSTREAM_TOKEN}" --org "${CPLN_ORG}"; then + echo "❌ Failed to promote staging to production" + exit 1 + fi + + echo "✅ Successfully promoted staging to production" + + - name: Create GitHub Release + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get the current date in YYYY-MM-DD format + RELEASE_DATE=$(date '+%Y-%m-%d') + + # Create a release tag + RELEASE_TAG="production-${RELEASE_DATE}" + + # Create GitHub release + gh release create "${RELEASE_TAG}" \ + --title "Production Release ${RELEASE_DATE}" \ + --notes "🚀 Production deployment on ${RELEASE_DATE}" diff --git a/.gitignore b/.gitignore index 42b8d30f9..7567a8b74 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ dump.rdb .DS_Store # Ignore bundle dependencies -vendor/ruby +vendor/bundle # RVM gemset .ruby-gemset diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed23da7b..75972ae1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. See: [merged pull requests](https://github.com/shakacode/react-webpack-rails-tutorial/pulls?utf8=%E2%9C%93&q=is%3Apr+is%3Amerged). + +## 2025-01-22 +Improvements to control-plane-flow implementation. + + + ## [2.1.0] - 2016-03-06 ### Updated