Skip to content
Open
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
108 changes: 70 additions & 38 deletions .github/actions/cpflow-build-docker-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,85 @@ inputs:
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 }}
working_directory:
description: Directory containing the app .controlplane config and Docker build context
required: false
default: "."

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
Comment on lines +53 to +54

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 SSH Key Cleanup Gap

If this action runs on a reused or self-hosted runner and the job is cancelled after SSH preparation but before the build step starts, the private key remains at ~/.ssh/cpflow_build_key because cleanup is registered only by the later step. The preparation step also overwrites any existing known_hosts file without preserving it. This can expose the key to a later job and damage the runner's SSH configuration. Cleanup should be registered where the files are created or handled by action-level post cleanup, while preserving existing SSH configuration.

How this was verified: The secret is written during the preparation step, while the only removal is an EXIT trap installed by the later build step.

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
id: build
shell: bash
env:
APP_NAME: ${{ inputs.app_name }}
COMMIT: ${{ inputs.commit }}
COMMIT_SHA: ${{ inputs.commit }}
CONTROL_PLANE_ORG: ${{ inputs.org }}
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 }}
WORKING_DIRECTORY: ${{ inputs.working_directory }}
run: |
set -euo pipefail

PR_INFO=""
docker_build_args=()
ssh_agent_started=false
build_ssh_prepped=false

cleanup_build_ssh() {
if [[ "${ssh_agent_started}" == "true" ]]; then
ssh-agent -k >/dev/null || true
fi
rm -f "${HOME}/.ssh/cpflow_build_key"
# Only remove known_hosts if this action's prep step wrote it. On self-hosted
# or reused runners we must not touch a user-managed file we did not create,
# so the flag is set inside the same prep-detection branch below.
if [[ "${build_ssh_prepped}" == "true" ]]; then
rm -f "${HOME}/.ssh/known_hosts"
fi
}
trap cleanup_build_ssh EXIT
cd "${WORKING_DIRECTORY}"

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

if [[ -n "$DOCKER_BUILD_EXTRA_ARGS" ]]; then
if [[ -n "${DOCKER_BUILD_EXTRA_ARGS}" ]]; then
while IFS= read -r arg; do
arg="${arg%$'\r'}"
[[ -n "${arg}" ]] || continue
Expand All @@ -65,35 +113,19 @@ runs:
fi

docker_build_args+=("${arg}")
done <<< "$DOCKER_BUILD_EXTRA_ARGS"
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

if [[ -f "${HOME}/.ssh/cpflow_build_key" ]]; then
# Mark prep-step ownership so cleanup_build_ssh only removes known_hosts
# when this action wrote it (see trap above).
build_ssh_prepped=true
eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null' EXIT
ssh-add - <<< "$DOCKER_BUILD_SSH_KEY"
unset DOCKER_BUILD_SSH_KEY
ssh_agent_started=true
ssh-add "${HOME}/.ssh/cpflow_build_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})"
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})"
8 changes: 7 additions & 1 deletion .github/actions/cpflow-delete-control-plane-app/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ inputs:
review_app_prefix:
description: Prefix used for review app names
required: true
working_directory:
description: Directory containing the downstream project's .controlplane/controlplane.yml
required: false
default: "."

runs:
using: composite
steps:
- name: Delete application
shell: bash
run: ${{ github.action_path }}/delete-app.sh
working-directory: ${{ inputs.working_directory }}
run: "${ACTION_PATH}/delete-app.sh"
env:
ACTION_PATH: ${{ github.action_path }}
APP_NAME: ${{ inputs.app_name }}
CPLN_ORG: ${{ inputs.cpln_org }}
REVIEW_APP_PREFIX: ${{ inputs.review_app_prefix }}
7 changes: 4 additions & 3 deletions .github/actions/cpflow-delete-control-plane-app/delete-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ case "$exists_status" in
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
if [[ -n "$exists_output" ]]; then
printf '%s\n' "$exists_output" >&2
fi
exit "$exists_status"
;;
esac

Expand Down
69 changes: 69 additions & 0 deletions .github/actions/cpflow-detect-release-phase/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Detect release phase support
description: >-
Inspects .controlplane/controlplane.yml for an app and emits `flag=--run-release-phase`
when a `release_script:` is configured. Outputs an empty `flag` otherwise.

inputs:
app_name:
description: cpflow app name to inspect
required: true
working_directory:
description: Directory containing .controlplane/controlplane.yml
required: false
default: "."

outputs:
flag:
description: Either `--run-release-phase` or empty
value: ${{ steps.detect.outputs.flag }}

runs:
using: composite
steps:
- name: Detect release phase support
id: detect
shell: bash
env:
APP_NAME: ${{ inputs.app_name }}
WORKING_DIRECTORY: ${{ inputs.working_directory }}
run: |
set -euo pipefail
cd "${WORKING_DIRECTORY}"

release_script="$(ruby - "${APP_NAME}" <<'RUBY'
require "yaml"

app_name = ARGV.fetch(0)

unless File.file?(".controlplane/controlplane.yml")
warn "Error: `.controlplane/controlplane.yml` is missing. " \
"cpflow-detect-release-phase must be invoked from a cpflow-configured project."
exit 1
end

data = YAML.safe_load(File.read(".controlplane/controlplane.yml"), aliases: true)
apps = data["apps"] || {}
app_config = apps[app_name]

unless app_config
app_config = apps.find do |name, config|
config.is_a?(Hash) &&
config["match_if_app_name_starts_with"] &&
app_name.start_with?(name)
end&.last
end

unless app_config.is_a?(Hash)
warn "Error: app '#{app_name}' is not defined under `apps:` in `.controlplane/controlplane.yml`."
exit 1
end

puts app_config["release_script"].to_s
RUBY
)"

if [[ -n "${release_script}" ]]; then
echo "flag=--run-release-phase" >> "$GITHUB_OUTPUT"
else
echo "flag=" >> "$GITHUB_OUTPUT"
fi
137 changes: 137 additions & 0 deletions .github/actions/cpflow-resolve-review-config/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Resolve Review App Config
description: Infers review app prefix and Control Plane org from controlplane.yml

inputs:
configured_cpln_org_staging:
description: Optional override for the staging Control Plane org.
required: false
default: ""
configured_review_app_prefix:
description: Optional override for the review app prefix.
required: false
default: ""
pr_number:
description: Pull request number used to build the review app name.
required: false
default: ""
working_directory:
description: Directory containing .controlplane/controlplane.yml.
required: false
default: "."

outputs:
review_app_prefix:
description: Resolved review app prefix.
value: ${{ steps.resolve.outputs.review_app_prefix }}
cpln_org:
description: Resolved Control Plane org.
value: ${{ steps.resolve.outputs.cpln_org }}
app_name:
description: Resolved review app name when pr_number is present; omitted when pr_number is empty.
value: ${{ steps.resolve.outputs.app_name }}

runs:
using: composite
steps:
- name: Resolve review app config
id: resolve
shell: bash
working-directory: ${{ inputs.working_directory }}
env:
CONFIGURED_CPLN_ORG_STAGING: ${{ inputs.configured_cpln_org_staging }}
CONFIGURED_REVIEW_APP_PREFIX: ${{ inputs.configured_review_app_prefix }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail

ruby <<'RUBY'
require "yaml"

def safe_load_yaml_file(path)
contents = File.read(path)

if YAML.method(:safe_load).parameters.any? { |type, name| type == :key && name == :aliases }
YAML.safe_load(contents, aliases: true)
else
YAML.safe_load(contents, [], [], true)
end
end

def validate_github_env_value!(name, value)
return if value.match?(/\A[A-Za-z0-9-]+\z/)

warn "::error::#{name} must contain only letters, numbers, and hyphens so it is a valid Control Plane name and can be safely written to GitHub environment files."
exit 1
end

begin
config = safe_load_yaml_file(".controlplane/controlplane.yml")
unless config.is_a?(Hash)
warn "::error::.controlplane/controlplane.yml must be a YAML mapping; got #{config.class}: #{config.inspect}"
exit 1
end

apps = config["apps"]
unless apps.is_a?(Hash)
warn "::error::.controlplane/controlplane.yml must define an apps mapping; got #{apps.class}: #{apps.inspect}"
exit 1
end

review_apps = apps.select do |_name, app_config|
app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
end

prefix = ENV.fetch("CONFIGURED_REVIEW_APP_PREFIX", "").strip
if prefix.empty?
if review_apps.length == 1
prefix = review_apps.keys.first
elsif review_apps.empty?
warn "::error::Set REVIEW_APP_PREFIX or define exactly one app with match_if_app_name_starts_with: true in .controlplane/controlplane.yml."
exit 1
else
warn "::error::Set REVIEW_APP_PREFIX because .controlplane/controlplane.yml defines multiple review app prefixes: #{review_apps.keys.sort.join(', ')}."
exit 1
end
end

app_config = apps[prefix]
unless app_config.is_a?(Hash) && app_config["match_if_app_name_starts_with"] == true
warn "::error::Review app prefix '#{prefix}' must match an app in .controlplane/controlplane.yml with match_if_app_name_starts_with: true."
exit 1
end

cpln_org = ENV.fetch("CONFIGURED_CPLN_ORG_STAGING", "").strip
cpln_org = app_config["cpln_org"].to_s.strip if cpln_org.empty?
if cpln_org.empty?
warn "::error::Set CPLN_ORG_STAGING or cpln_org for review app prefix '#{prefix}' in .controlplane/controlplane.yml."
exit 1
end

pr_number = ENV.fetch("PR_NUMBER", "").strip
unless pr_number.empty? || pr_number.match?(/\A[1-9][0-9]*\z/)
warn "::error::PR_NUMBER must be a positive integer; got: #{pr_number.inspect}"
exit 1
end

app_name = pr_number.empty? ? "" : "#{prefix}-#{pr_number}"

validate_github_env_value!("REVIEW_APP_PREFIX", prefix)
validate_github_env_value!("CPLN_ORG", cpln_org)
validate_github_env_value!("APP_NAME", app_name) unless app_name.empty?

File.open(ENV.fetch("GITHUB_ENV"), "a") do |file|
file.puts "REVIEW_APP_PREFIX=#{prefix}"
file.puts "CPLN_ORG=#{cpln_org}"
file.puts "APP_NAME=#{app_name}" unless app_name.empty?
end

File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |file|
file.puts "review_app_prefix=#{prefix}"
file.puts "cpln_org=#{cpln_org}"
file.puts "app_name=#{app_name}" unless app_name.empty?
end
rescue StandardError => e
warn "::error::Could not resolve review app config from .controlplane/controlplane.yml: #{e.class}: #{e.message}"
exit 1
end
RUBY
Loading
Loading