Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .controlplane/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ RUN SECRET_KEY_BASE=precompile_placeholder bin/rails react_on_rails:locale
# and /app/client/app are the client assets that are bundled, so not needed once built
# Helps to have smaller images b/c of smaller Docker Layer Caches and smaller final images
# SECRET_KEY_BASE is required for asset precompilation but is not persisted in the image
RUN SECRET_KEY_BASE=precompile_placeholder yarn res:build && \
RUN SECRET_KEY_BASE=precompile_placeholder bundle exec rake react_on_rails:generate_packs && \
SECRET_KEY_BASE=precompile_placeholder yarn res:build && \
SECRET_KEY_BASE=precompile_placeholder bin/rails assets:precompile && \
rm -rf /app/lib/bs /app/client/app

Expand Down
2 changes: 1 addition & 1 deletion .controlplane/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ echo " -- Waiting for services"
wait_for_service $(echo $DATABASE_URL | sed -e 's|^.*@||' -e 's|/.*$||')
wait_for_service $(echo $REDIS_URL | sed -e 's|redis://||' -e 's|/.*$||')

echo " -- Finishing entrypoint.sh, executing '$@'"
echo " -- Finishing entrypoint.sh, executing '$*'"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: $* vs $@ in the display message

The actual exec "$@" below is correct. But in a double-quoted string, $* joins all positional parameters with the first character of IFS (usually a space), while $@ expands them as separate words. For a display message they behave identically in normal cases, but $@ is the more precise choice and consistent with the exec below.

Suggested change
echo " -- Finishing entrypoint.sh, executing '$*'"
echo " -- Finishing entrypoint.sh, executing '$@'"


# Run the main command
exec "$@"
14 changes: 8 additions & 6 deletions .controlplane/templates/org.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Org level secrets are used to store sensitive information that is
# shared across multiple apps in the same organization. This is
# useful for storing things like API keys, database credentials, and
# other sensitive information that is shared across multiple apps
# in the same organization.
# App secret dictionaries store sensitive information for apps in the
# organization. This template keeps the cpflow app-secret placeholders
# {{APP_SECRETS}} and {{APP_SECRETS_POLICY}}.
#
# cpflow 5.1.1 shared_secret_grants are only for a separate shared
# org-level dictionary referenced from app/workload templates with
# {{SHARED_SECRET_<NAME>}}.

# The qa-* dictionary is bootstrapped via this template for review apps.
# Review apps run pull request code, so values in this dictionary must be
Expand All @@ -29,7 +31,7 @@ data:

---

# Policy is needed to allow identities to access secrets
# App secret policy grants app identities reveal access to this dictionary.
kind: policy
name: {{APP_SECRETS_POLICY}}
targetKind: secret
Expand Down
92 changes: 92 additions & 0 deletions .github/actions/cpflow-build-docker-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Build Docker Image
description: Builds and pushes the app image for a Control Plane workload

inputs:
app_name:
description: Name of the application
required: true
org:
description: Control Plane organization name
required: true
commit:
description: Commit SHA to tag the image with
required: true
pr_number:
description: Pull request number for status messaging
required: false
docker_build_extra_args:
description: Optional newline-delimited extra docker build tokens. Use key=value forms like --build-arg=FOO=bar.
required: false
docker_build_ssh_key:
description: Optional private SSH key used for Docker builds that fetch private dependencies with RUN --mount=type=ssh
required: false
docker_build_ssh_known_hosts:
description: Optional SSH known_hosts entries used with docker_build_ssh_key. Defaults to pinned GitHub.com host keys.
required: false

outputs:
image_tag:
description: Fully qualified image tag
value: ${{ steps.build.outputs.image_tag }}

runs:
using: composite
steps:
- name: Build Docker image
id: build
shell: bash
run: |
set -euo pipefail

PR_INFO=""
docker_build_args=()

if [[ -n "${{ inputs.pr_number }}" ]]; then
PR_INFO=" for PR #${{ inputs.pr_number }}"
fi

if [[ -n "${{ inputs.docker_build_extra_args }}" ]]; then
while IFS= read -r arg; do
arg="${arg%$'\r'}"
[[ -n "${arg}" ]] || continue

if [[ "${arg}" =~ [[:space:]] ]]; then
echo "docker_build_extra_args entries must be single docker-build tokens. " \
"Use key=value forms like --build-arg=FOO=bar." >&2
exit 1
fi

docker_build_args+=("${arg}")
done <<< "${{ inputs.docker_build_extra_args }}"
Comment on lines +48 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shell injection risk: template expressions in inline script

${{ inputs.docker_build_extra_args }} is rendered into the script before the shell runs it. GitHub Actions does not shell-escape template values, so a crafted input like "; malicious_command; echo " could escape the here-string context. The same issue applies to ${{ inputs.docker_build_ssh_key }} on line 83.

The GitHub hardening guide recommends routing such values through env: variables instead:

      env:
        DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
      run: |
        ...
        if [[ -n "$DOCKER_BUILD_EXTRA_ARGS" ]]; then
          while IFS= read -r arg; do
            ...
          done <<< "$DOCKER_BUILD_EXTRA_ARGS"
        fi

Since this action is only called from controlled workflows today the practical risk is low, but this pattern prevents future misuse if the action is ever invoked with PR-derived inputs.

fi

if [[ -n "${{ inputs.docker_build_ssh_key }}" ]]; then
mkdir -p ~/.ssh
chmod 700 ~/.ssh

if [[ -n "${{ inputs.docker_build_ssh_known_hosts }}" ]]; then
cat <<'EOF' > ~/.ssh/known_hosts
${{ inputs.docker_build_ssh_known_hosts }}
EOF
else
cat <<'EOF' > ~/.ssh/known_hosts
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
EOF
fi

chmod 600 ~/.ssh/known_hosts

eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null' EXIT
ssh-add - <<< "${{ inputs.docker_build_ssh_key }}"
docker_build_args+=("--ssh=default")
fi

echo "🏗️ Building Docker image${PR_INFO} (commit ${{ inputs.commit }})..."
cpflow build-image -a "${{ inputs.app_name }}" --commit="${{ inputs.commit }}" --org="${{ inputs.org }}" "${docker_build_args[@]}"

image_tag="${{ inputs.org }}/${{ inputs.app_name }}:${{ inputs.commit }}"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
echo "✅ Docker image build successful${PR_INFO} (commit ${{ inputs.commit }})"
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 }}
43 changes: 43 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,43 @@
#!/bin/bash

set -euo pipefail

: "${APP_NAME:?APP_NAME environment variable is required}"
: "${CPLN_ORG:?CPLN_ORG environment variable is required}"
: "${REVIEW_APP_PREFIX:?REVIEW_APP_PREFIX environment variable is required}"

expected_prefix="${REVIEW_APP_PREFIX}-"
if [[ "$APP_NAME" != "${expected_prefix}"* ]]; then
echo "❌ ERROR: refusing to delete an app outside the review app prefix" >&2
echo "App name: $APP_NAME" >&2
echo "Expected prefix: ${expected_prefix}" >&2
exit 1
fi

echo "🔍 Checking if application exists: $APP_NAME"
exists_output=""
if ! exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"; then
case "$exists_output" in
*"Double check your org"*|*"Unknown API token format"*|*"ERROR"*|*"Error:"*|*"Traceback"*|*"Net::"*)
echo "❌ ERROR: failed to determine whether application exists: $APP_NAME" >&2
printf '%s\n' "$exists_output" >&2
exit 1
;;
esac

if [[ -n "$exists_output" ]]; then
printf '%s\n' "$exists_output"
fi

echo "⚠️ Application does not exist: $APP_NAME"
exit 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Exists errors skip deletion

Medium Severity

When cpflow exists exits non-zero, the script treats any output that does not match a short list of substrings as “application does not exist” and exits successfully without calling delete. Transient API, auth, or network failures can therefore be misread as a missing app and leave review apps undeleted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7a11703. Configure here.

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"
70 changes: 70 additions & 0 deletions .github/actions/cpflow-setup-environment/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Setup Control Plane Environment
description: Sets up Ruby, installs the Control Plane CLI and cpflow gem, and configures a default profile

inputs:
token:
description: Control Plane token
required: true
org:
description: Control Plane organization
required: true
ruby_version:
description: Ruby version used for cpflow
required: false
default: "3.4.6"
cpln_cli_version:
description: "@controlplane/cli version"
required: false
default: "3.3.1"
cpflow_version:
description: cpflow gem version
required: false
default: "5.1.1"

runs:
using: composite
steps:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ inputs.ruby_version }}

- name: Install Control Plane CLI and cpflow gem
shell: bash
run: |
set -euo pipefail

sudo npm install -g @controlplane/cli@${{ inputs.cpln_cli_version }}
cpln --version

gem install cpflow -v ${{ inputs.cpflow_version }}
cpflow --version

- name: Setup Control Plane profile and registry login
shell: bash
run: |
set -euo pipefail

TOKEN="${{ inputs.token }}"
ORG="${{ inputs.org }}"

if [[ -z "$TOKEN" ]]; then
echo "Error: Control Plane token not provided" >&2
exit 1
fi

if [[ -z "$ORG" ]]; then
echo "Error: Control Plane organization not provided" >&2
exit 1
fi

Comment on lines +51 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead code: both token and org inputs are declared required: true at lines 4 and 8, so GitHub Actions will fail the job before the shell script ever runs if they are absent. These runtime checks add noise without providing any additional safety. They can be removed.

create_output=""
if ! create_output="$(cpln profile create default --token "$TOKEN" --org "$ORG" 2>&1)"; then
if ! echo "$create_output" | grep -qi "already exists"; then
echo "$create_output" >&2
exit 1
fi
fi

cpln profile update default --org "$ORG" --token "$TOKEN"
cpln image docker-login --org "$ORG"
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.4.6"

gem "cpflow", "5.1.1", require: false
gem "react_on_rails_pro", "16.7.0.rc.3"
gem "react_on_rails", "17.0.0.rc.1"
gem "react_on_rails_pro", "17.0.0.rc.1"
gem "shakapacker", "10.1.0"

# Bundle edge Rails instead: gem "rails", github: "rails/rails"
Expand Down
15 changes: 8 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ GEM
coffee-script-source (1.12.2)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
console (1.35.1)
console (1.36.0)
fiber-annotation
fiber-local (~> 1.1)
json
Expand Down Expand Up @@ -201,7 +201,7 @@ GEM
jbuilder (2.12.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.19.5)
json (2.19.8)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.5)
Expand Down Expand Up @@ -346,22 +346,22 @@ GEM
erb
psych (>= 4.0.0)
tsort
react_on_rails (16.7.0.rc.3)
react_on_rails (17.0.0.rc.1)
addressable
connection_pool
execjs (~> 2.5)
rails (>= 5.2)
rainbow (~> 3.0)
shakapacker (>= 6.0)
react_on_rails_pro (16.7.0.rc.3)
react_on_rails_pro (17.0.0.rc.1)
addressable
async (>= 2.29)
async-http (~> 0.95)
execjs (~> 2.9)
io-endpoint (~> 0.17.0)
jwt (>= 2.5, < 4)
rainbow
react_on_rails (= 16.7.0.rc.3)
react_on_rails (= 17.0.0.rc.1)
redcarpet (3.6.0)
redis (5.3.0)
redis-client (>= 0.22.0)
Expand Down Expand Up @@ -505,7 +505,7 @@ GEM
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket (1.2.10)
websocket-driver (0.8.0)
websocket-driver (0.8.1)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
Expand Down Expand Up @@ -549,7 +549,8 @@ DEPENDENCIES
rails-html-sanitizer
rails_best_practices
rainbow
react_on_rails_pro (= 16.7.0.rc.3)
react_on_rails (= 17.0.0.rc.1)
react_on_rails_pro (= 17.0.0.rc.1)
redcarpet
redis (~> 5.0)
rspec-rails (~> 6.0.0)
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

ShakaCode recently migrated [HiChee.com](https://hichee.com) to Control Plane, resulting in a two-thirds reduction in server hosting costs!

See doc in [./.controlplane/readme.md](./.controlplane/readme.md) for how to easily deploy this app to Control Plane.
See [./.controlplane/readme.md](./.controlplane/readme.md) for local `cpflow` setup plus the shared `cpflow-*` GitHub Actions flow for review apps, automatic staging deploys, and manual promotion to production.

The instructions leverage the `cpflow` CLI, with source code and many more tips on how to migrate from Heroku to Control Plane
in https://github.com/shakacode/heroku-to-control-plane.
Expand Down Expand Up @@ -184,9 +184,12 @@ assets_bundler: rspack

### Version Targets

- `react_on_rails_pro` gem: `16.7.0.rc.3`
- `react-on-rails-pro` npm package: `16.7.0-rc.3`
- `react-on-rails-pro-node-renderer` npm package: `16.7.0-rc.3`
- `react_on_rails` gem: `17.0.0.rc.1`
- `react-on-rails` npm package: `17.0.0-rc.1`
- `react_on_rails_pro` gem: `17.0.0.rc.1`
- `react-on-rails-pro` npm package: `17.0.0-rc.1`
- `react-on-rails-pro-node-renderer` npm package: `17.0.0-rc.1`
- `react-on-rails-rsc` npm package: `19.0.5-rc.5`
- `shakapacker` gem/npm package: `10.1.0`
- `@rspack/core` and `@rspack/cli`: `2.0.0-beta.7`
- `react`: `~19.0.4` (minimum for React Server Components)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ describe('CommentList', () => {
);

it('renders a list of Comments in normal order', () => {
render(
<CommentList $$comments={comments} cssTransitionGroupClassNames={cssTransitionGroupClassNames} />,
);
render(<CommentList $$comments={comments} cssTransitionGroupClassNames={cssTransitionGroupClassNames} />);

// Verify both authors are rendered in order
expect(screen.getByText('Frank')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable max-classes-per-file */

'use client';

import React from 'react';
Expand Down
Loading
Loading