Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions .controlplane/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down Expand Up @@ -364,27 +364,45 @@ 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
39 changes: 36 additions & 3 deletions .controlplane/shakacode-team.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,14 +21,40 @@ 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`.

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)
39 changes: 0 additions & 39 deletions .github/actions/build-docker-image/action.yml

This file was deleted.

106 changes: 106 additions & 0 deletions .github/actions/cpflow-build-docker-image/action.yml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EXIT trap kills the SSH agent and removes cpflow_build_key, but the ~/.ssh/known_hosts file written in the previous step is not cleaned up here. On ephemeral GitHub-hosted runners this is harmless (the VM is discarded), but if this action is ever adopted on self-hosted runners with a persistent ~/.ssh, the custom known_hosts entries would accumulate across runs.

Consider adding rm -f "${HOME}/.ssh/known_hosts" to the trap, or using a temp file under $RUNNER_TEMP instead of ~/.ssh for the known_hosts.

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})"
24 changes: 24 additions & 0 deletions .github/actions/cpflow-delete-control-plane-app/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
57 changes: 57 additions & 0 deletions .github/actions/cpflow-delete-control-plane-app/delete-app.sh
Original file line number Diff line number Diff line change
@@ -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::"*)
Comment on lines +28 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same cpflow exists error-pattern matching appears verbatim in the Check if review app exists step in cpflow-deploy-review-app.yml (line ~195). The TODO comment here is accurate but only covers this file — the workflow copy is uncovered.

If cpflow adds a new error pattern (e.g. "rate limit" or "timeout"), both locations need updating in sync. Consider extracting this into a shared script (e.g. .github/scripts/cpflow-app-exists.sh) that both the action and the workflow call, so the pattern list is maintained in one place.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment here is well-placed. To make this easier to track, consider filing an issue against the cpflow gem linking to this file, so the workaround has a visible upstream ticket. The risk of silent misclassification (an unrecognized error string treated as "app not found") is the main hazard — a regression test that mocks cpflow exists returning an exit-1 with a novel error string would catch drift before it reaches production.

Comment on lines +30 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same error-token list and detection logic is duplicated verbatim in cpflow-deploy-review-app.yml (the "Check if review app exists" step at line ~204). When this list needs to grow (e.g., a new cpflow exists error pattern), both files need to change in sync — which the TODO already flags.

A practical option: extract the detection to a small shared shell script (e.g., .github/actions/cpflow-delete-control-plane-app/cpflow-app-exists.sh) that returns 0/1/2 for exists/not-found/error, then source or call it from both places. That makes the error-token list a single source of truth and keeps the TODO resolution contained to one file.

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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case pattern matching over stderr is the right approach given cpflow exists doesn't expose a structured signal, and the inline TODO is the right way to track this. One additional defensive measure worth considering: log the full exists_output to the runner even when the "not found" path is taken, so there's an audit trail if unexpected output silently passed through the filter. Right now the output is printed only if non-empty, but it's easy to miss in logs.

Also worth noting: the pattern list doesn't include "timeout", "connection refused", or "ENOTFOUND" — all plausible network failure messages. If cpflow's Ruby HTTP client times out, it may surface Errno::ECONNREFUSED or Net::ReadTimeout, which would match "Net::", so that's covered. But curl-style timeouts would not. Low risk given this is a Ruby gem, but worth keeping in mind when bumping the cpflow version.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string-matching heuristic on cpflow exists error output (lines 43–54) is the main reliability risk in this file. The consequence of an unmatched error pattern is silently exiting 0 ("app not found") when the API actually returned an error, meaning the delete is skipped without any visible failure.

An alternative that inverts the logic: treat any non-zero exit with non-empty output as a failure unless it matches a known "not found" pattern:

# instead of matching known error strings, match known "not found" patterns
case "$exists_output" in
  *"not found"*|*"does not exist"*)
    echo "⚠️ Application does not exist: $APP_NAME"
    exit 0
    ;;
  *)
    echo "❌ ERROR: failed to determine whether application exists" >&2
    printf '%s\n' "$exists_output" >&2
    exit 1
    ;;
esac

This is safer by default — unknown output is treated as an error rather than a no-op. The tradeoff is that you'd need to know the exact "not found" wording from cpflow exists, which has the same coupling problem in the other direction. The TODO comment already captures this; just flagging it for awareness.

Loading
Loading