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