From 76ef95d9984dbe19206be27cc627d31a48f3e2fc Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 13:24:20 -0700 Subject: [PATCH 01/12] Inject registry creds into app rad deploy instead of control-plane Secret The Radius.Compute/containerImages recipe moved from Terraform to Bicep. The registry push/pull credentials must now reach the app deploy as rad deploy parameters so the app's Radius.Security/secrets resource (ghcr-registry-creds) materializes the registry Secret on the target cluster. - Pass registryUsername (github.actor) and registryPassword (GITHUB_TOKEN) as --parameters to both app-file rad deploy sites (custom-commands deploy branch and default deploy), via the existing argv array so secret values are not word-split and stay out of the recorded command string. Only when non-empty. - Remove the 'Provision registry credentials on control plane' step that ran kubectl create secret generic ghcr-registry-creds on the control-plane cluster. - Refresh action metadata, workflow comments, and README to match. Recipe-pack registration is unchanged (secret name stays ghcr-registry-creds). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/README.md | 7 +- .../actions/run-rad-commands/action.yml | 75 +++++++++---------- .github/extension/run-rad-commands-aws.yml | 8 +- .github/extension/run-rad-commands-azure.yml | 8 +- 4 files changed, 46 insertions(+), 52 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index c2fe2678023..3ab70f13f4e 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -82,10 +82,9 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 10. **Register cloud credentials.** Registers the cloud identity with `rad credential register azure wi` / `aws irsa` so Radius holds the identity selector and reads the projected token at runtime. 11. **Create the Radius environment and recipe pack.** `rad deploy`s a `radius-env.bicep` that defines a `Radius.Core/recipePacks` resource and the `Radius.Core/environments` resource that references it. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `radius-env.bicep` is written to the app file's directory (e.g. `.radius/`) and deployed from there, so `rad deploy` resolves the repo's own `bicepconfig.json` (which declares the `radius` extension) — bicep resolves the config nearest the `.bicep` file. The `Radius.Compute/containerImages` type ships with the Radius extension, so no separate resource-type registration is needed. -12. **Provision registry credentials on the control plane.** Creates the `ghcr-registry-creds` secret from `github.actor` and the built-in `GITHUB_TOKEN` so the containerImages recipe's in-pod BuildKit can push the application image. -13. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`) and any application parameters from the `RADIUS_DEPLOY_PARAMS` secret. -14. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. -15. **Tear down.** Runs `rad app list`, and always deletes the ephemeral `radius-cp` cluster. On failure, Radius and application logs are collected and uploaded as the `radius-logs` artifact (three-day retention). +12. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`), any application parameters from the `RADIUS_DEPLOY_PARAMS` secret, and the registry push/pull credentials as `registryUsername` (`github.actor`) and `registryPassword` (the built-in `GITHUB_TOKEN`). Those feed the app's `Radius.Security/secrets` resource (`ghcr-registry-creds`), which materializes the registry Secret on the target cluster so the containerImages recipe's in-pod BuildKit can push the application image. The secret value is passed via an argv array and never written into the recorded command string. +13. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. +14. **Tear down.** Runs `rad app list`, and always deletes the ephemeral `radius-cp` cluster. On failure, Radius and application logs are collected and uploaded as the `radius-logs` artifact (three-day retention). ### Triggers and permissions diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 62f9519615c..f5928f1c77c 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -1,10 +1,10 @@ # Provider-agnostic deploy shared by run-rad-commands-aws.yml and -# run-rad-commands-azure.yml. Provisions registry credentials on the control plane -# and runs the requested rad commands (deploying by default), writing the combined -# rad-commands-result artifact. Teardown (rad shutdown, log collection, k3d delete) -# lives in the separate `teardown` action so it can run unconditionally. +# run-rad-commands-azure.yml. Runs the requested rad commands (deploying by +# default), writing the combined rad-commands-result artifact. Teardown (rad +# shutdown, log collection, k3d delete) lives in the separate `teardown` action +# so it can run unconditionally. name: Radius - Run rad commands -description: Provision registry credentials and run the requested rad commands (deploying by default). +description: Run the requested rad commands (deploying by default). inputs: environment: @@ -30,10 +30,10 @@ inputs: required: false default: "" registry-username: - description: Username for the containerImages recipe's image-push registry secret. + description: Username passed to the app deploy as the registryUsername parameter (feeds the app's Radius.Security/secrets registry Secret). required: true registry-password: - description: Password/token for the containerImages recipe's image-push registry secret. + description: Password/token passed to the app deploy as the registryPassword parameter (feeds the app's Radius.Security/secrets registry Secret). required: true runs: @@ -54,45 +54,22 @@ runs: kubectl --kubeconfig "$TARGET_KUBECONFIG" create namespace "$APP_NS" fi - - name: Provision registry credentials on control plane - shell: bash - env: - # The Radius.Compute/containerImages recipe authenticates its BuildKit - # image push by loading the Kubernetes Secret named by registrySecretName - # via the in-cluster provider -- i.e. from the CONTROL PLANE cluster where - # the dynamic-rp/BuildKit pods run, NOT the target cluster. Provision that - # secret here directly from the default GitHub token so no registry - # credentials need to live in the application bicep. - REGISTRY_USERNAME: ${{ inputs.registry-username }} - REGISTRY_PASSWORD: ${{ inputs.registry-password }} - ENV_NS: ${{ inputs.namespace }} - run: | - set -eu - REGISTRY_SECRET_NAME="ghcr-registry-creds" - BICEP_APP_NAME=$(grep -oP "name:\s*'\K[^']+" ".radius/app.bicep" 2>/dev/null | head -1) - [ -z "$BICEP_APP_NAME" ] && BICEP_APP_NAME="app" - APP_NS="default-$BICEP_APP_NAME" - # The recipe reads the secret from context.runtime.kubernetes.namespace on - # the control plane. Materialize it in both the environment namespace and - # the application namespace so the read resolves regardless of scope. These - # kubectl calls run against the default kubeconfig (the control plane), not - # the target cluster. - for NS in "$ENV_NS" "$APP_NS"; do - kubectl get namespace "$NS" >/dev/null 2>&1 || kubectl create namespace "$NS" - kubectl create secret generic "$REGISTRY_SECRET_NAME" \ - --namespace "$NS" \ - --from-literal=username="$REGISTRY_USERNAME" \ - --from-literal=password="$REGISTRY_PASSWORD" \ - --dry-run=client -o yaml | kubectl apply -f - - echo "Provisioned $REGISTRY_SECRET_NAME in control-plane namespace $NS" - done - - name: Run rad commands shell: bash env: ENVIRONMENT: ${{ inputs.environment }} APP_FILE: ${{ inputs.app-file }} APP_IMAGE: ${{ inputs.app-image }} + # Registry push/pull credentials for the app deploy. These are passed to + # the app file's `rad deploy` as the registryUsername/registryPassword + # parameters, which feed the app's Radius.Security/secrets resource + # (`ghcr-registry-creds`). That resource materializes the registry Secret + # on the TARGET cluster, where the containerImages recipe reads it by the + # name the recipe pack registers. Read via the environment (not inlined + # into the script) so the secret value can't break the shell or be + # word-split, and is never written into the recorded command string. + REGISTRY_USERNAME: ${{ inputs.registry-username }} + REGISTRY_PASSWORD: ${{ inputs.registry-password }} # Pass caller input through the environment to avoid command injection. # Caller-supplied rad commands (falls back to the RADIUS_RAD_COMMANDS # variable upstream) so the command applies on both an explicit dispatch @@ -217,6 +194,15 @@ runs: EXTRA_PARAMS+=(--parameters "$_pname=$_pval") done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') fi + # Registry credentials for the app's Radius.Security/secrets + # resource. Appended to the argv array so the secret value is never + # word-split or written into the recorded command string. + if [ -n "$REGISTRY_USERNAME" ]; then + EXTRA_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") + fi + if [ -n "$REGISTRY_PASSWORD" ]; then + EXTRA_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") + fi if ! record "$idx" "$cmd" rad "${CMD_ARGV[@]}" "${EXTRA_PARAMS[@]}"; then OVERALL_OUTCOME="command_failed" OVERALL_EXIT=1 @@ -255,6 +241,15 @@ runs: DEPLOY_PARAMS+=(--parameters "$_pname=$_pval") done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') fi + # Registry credentials for the app's Radius.Security/secrets resource + # (`ghcr-registry-creds`). Appended to the argv array so the secret + # value is never word-split or written into the recorded command string. + if [ -n "$REGISTRY_USERNAME" ]; then + DEPLOY_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") + fi + if [ -n "$REGISTRY_PASSWORD" ]; then + DEPLOY_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") + fi # The recorded command string omits the parameters so secret values are # not written into the result artifact. if ! record 0 "deploy $APP_FILE --environment $ENVIRONMENT" \ diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index abbbdff512a..ecf09e54f3b 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -232,10 +232,10 @@ jobs: # Registry and credentials for the Radius.Compute/containerImages recipe. # The recipe builds images with the in-pod BuildKit and pushes them to # $BUILD_REGISTRY, authenticating with the Kubernetes Secret named - # $REGISTRY_SECRET_NAME. Because the recipe loads that Secret via the - # in-cluster provider (the control-plane cluster where dynamic-rp/BuildKit - # run), the secret is provisioned directly onto the control plane by the - # run-and-teardown action. Only the secret NAME is wired here via the + # $REGISTRY_SECRET_NAME. That Secret is created on the target cluster by + # the app's Radius.Security/secrets resource during `rad deploy` (fed by + # the registryUsername/registryPassword parameters injected by the + # run-rad-commands action). Only the secret NAME is wired here via the # recipe pack's registrySecretName. $BUILD_REGISTRY is ghcr.io// # so images land under the repository's package namespace; it must be lowercase. BUILD_REGISTRY=$(echo "${{ vars.RADIUS_BUILD_REGISTRY || format('ghcr.io/{0}', github.repository) }}" | tr '[:upper:]' '[:lower:]') diff --git a/.github/extension/run-rad-commands-azure.yml b/.github/extension/run-rad-commands-azure.yml index 8a7d11c7d09..4575a67ffb6 100644 --- a/.github/extension/run-rad-commands-azure.yml +++ b/.github/extension/run-rad-commands-azure.yml @@ -209,10 +209,10 @@ jobs: # Registry and credentials for the Radius.Compute/containerImages recipe. # The recipe builds images with the in-pod BuildKit and pushes them to # $BUILD_REGISTRY, authenticating with the Kubernetes Secret named - # $REGISTRY_SECRET_NAME. Because the recipe loads that Secret via the - # in-cluster provider (the control-plane cluster where dynamic-rp/BuildKit - # run), the secret is provisioned directly onto the control plane by the - # run-and-teardown action. Only the secret NAME is wired here via the + # $REGISTRY_SECRET_NAME. That Secret is created on the target cluster by + # the app's Radius.Security/secrets resource during `rad deploy` (fed by + # the registryUsername/registryPassword parameters injected by the + # run-rad-commands action). Only the secret NAME is wired here via the # recipe pack's containerImagesRegistrySecretName. $BUILD_REGISTRY is # ghcr.io// so images land under the repository's package # namespace; it must be lowercase. From b75a30d81372f5b1a344ec3eeb04638677eee8c0 Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 13:26:58 -0700 Subject: [PATCH 02/12] Skip runner-injected registry params when already in deploy params Guard against passing the same --parameters twice: only append the runner-injected registryUsername/registryPassword when RADIUS_DEPLOY_PARAMS does not already carry that key (older environments may still supply them). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index f5928f1c77c..759a35a8733 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -144,6 +144,14 @@ runs: return "$code" } + # True when RADIUS_DEPLOY_PARAMS (a JSON object) already carries the given + # key. Used to avoid passing a --parameters twice when an older environment + # still supplies registryUsername/registryPassword in the params secret. + deploy_params_has_key() { + [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ] || return 1 + printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -e --arg k "$1" 'has($k)' >/dev/null 2>&1 + } + if [ -n "${RAD_COMMANDS//[[:space:]]/}" ]; then # Caller-supplied commands: a single command string or a JSON array, run in # order with the `rad` prefix omitted. @@ -196,11 +204,13 @@ runs: fi # Registry credentials for the app's Radius.Security/secrets # resource. Appended to the argv array so the secret value is never - # word-split or written into the recorded command string. - if [ -n "$REGISTRY_USERNAME" ]; then + # word-split or written into the recorded command string. Skipped + # when the params secret already supplies the key, so the same + # --parameters is never passed twice. + if [ -n "$REGISTRY_USERNAME" ] && ! deploy_params_has_key registryUsername; then EXTRA_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") fi - if [ -n "$REGISTRY_PASSWORD" ]; then + if [ -n "$REGISTRY_PASSWORD" ] && ! deploy_params_has_key registryPassword; then EXTRA_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") fi if ! record "$idx" "$cmd" rad "${CMD_ARGV[@]}" "${EXTRA_PARAMS[@]}"; then @@ -244,10 +254,12 @@ runs: # Registry credentials for the app's Radius.Security/secrets resource # (`ghcr-registry-creds`). Appended to the argv array so the secret # value is never word-split or written into the recorded command string. - if [ -n "$REGISTRY_USERNAME" ]; then + # Skipped when the params secret already supplies the key, so the same + # --parameters is never passed twice. + if [ -n "$REGISTRY_USERNAME" ] && ! deploy_params_has_key registryUsername; then DEPLOY_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") fi - if [ -n "$REGISTRY_PASSWORD" ]; then + if [ -n "$REGISTRY_PASSWORD" ] && ! deploy_params_has_key registryPassword; then DEPLOY_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") fi # The recorded command string omits the parameters so secret values are From 89bd2c53be37e1bf7827f5e06f9d79635ecb6ac1 Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 13:41:46 -0700 Subject: [PATCH 03/12] Keep secret values out of logs and the uploaded artifact The rad-commands-result artifact captures rad stdout/stderr, but GitHub's log masking does not apply to artifact file contents. Collect the secret values (registry password / GITHUB_TOKEN and every RADIUS_DEPLOY_PARAMS value), register each with ::add-mask:: for defense-in-depth log redaction, and scrub them from each command's captured output before it is written into the artifact JSON. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 759a35a8733..1067e8eda77 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -132,6 +132,10 @@ runs: code=${PIPESTATUS[0]} echo "::endgroup::" RAN=$((RAN + 1)) + # Redact any known secret values from the captured output before it is + # written into the uploaded artifact. GitHub's log masking does not apply + # to artifact file contents, so scrub them ourselves. + scrub_secrets "$outfile" COMMANDS_JSON=$(jq \ --argjson index "$index" \ --arg cmd "$display" \ @@ -152,6 +156,41 @@ runs: printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -e --arg k "$1" 'has($k)' >/dev/null 2>&1 } + # Secret values that must never reach the logs or the uploaded artifact: + # the registry password (the runner GITHUB_TOKEN) and every value in the + # RADIUS_DEPLOY_PARAMS secret. Register each with ::add-mask:: so GitHub + # redacts it from the live logs (defense in depth -- registered secrets are + # already masked, but values sourced from vars are not), and keep the list + # for scrub_secrets to strip from the captured output. + SECRET_VALUES=() + add_secret() { + [ -n "$1" ] || return 0 + SECRET_VALUES+=("$1") + echo "::add-mask::$1" + } + add_secret "$REGISTRY_PASSWORD" + if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then + while IFS= read -r _sname; do + [ -z "$_sname" ] && continue + add_secret "$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_sname" '.[$k]')" + done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') + fi + + # Replace every known secret value in the given file with *** in place, so + # the artifact never carries a credential that `rad` happened to echo. Uses + # bash literal substitution (no regex) so values with special characters are + # matched verbatim. + scrub_secrets() { + local file="$1" content value + [ ${#SECRET_VALUES[@]} -eq 0 ] && return 0 + content=$(cat "$file") + for value in "${SECRET_VALUES[@]}"; do + [ -n "$value" ] || continue + content=${content//"$value"/***} + done + printf '%s' "$content" > "$file" + } + if [ -n "${RAD_COMMANDS//[[:space:]]/}" ]; then # Caller-supplied commands: a single command string or a JSON array, run in # order with the `rad` prefix omitted. From 17e4ebe15bd087cc533493384d486bf082169f4d Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 13:43:30 -0700 Subject: [PATCH 04/12] Inject app-only deploy params only for app-file deploys In the custom rad_commands path, image/application/registry parameters were appended to every deploy command. rad deploy rejects unknown parameters, so a custom command deploying a non-app template (e.g. radius-env.bicep) would fail. Gate the app-only parameters on the deploy target matching APP_FILE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 1067e8eda77..1d2914f5cd4 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -224,33 +224,41 @@ runs: for cmd in "${CLEAN[@]}"; do verb="${cmd%% *}" if [ "$verb" = "deploy" ]; then - # For deploy commands, append the image and the secret application - # parameters. Build an argv array so secret values with special - # characters are never word-split, and keep them out of the + # For deploy commands, build an argv array so secret values with + # special characters are never word-split, and keep them out of the # recorded command string (only the non-secret `$cmd` is recorded). # shellcheck disable=SC2086 read -ra CMD_ARGV <<< "$cmd" EXTRA_PARAMS=() - if [ -n "$APP_IMAGE" ]; then - EXTRA_PARAMS+=(--parameters "image=$APP_IMAGE") - fi - if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then - while IFS= read -r _pname; do - [ -z "$_pname" ] && continue - _pval=$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_pname" '.[$k]') - EXTRA_PARAMS+=(--parameters "$_pname=$_pval") - done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') - fi - # Registry credentials for the app's Radius.Security/secrets - # resource. Appended to the argv array so the secret value is never - # word-split or written into the recorded command string. Skipped - # when the params secret already supplies the key, so the same - # --parameters is never passed twice. - if [ -n "$REGISTRY_USERNAME" ] && ! deploy_params_has_key registryUsername; then - EXTRA_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") - fi - if [ -n "$REGISTRY_PASSWORD" ] && ! deploy_params_has_key registryPassword; then - EXTRA_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") + # The image, application, and registry parameters belong to the app + # only. `rad deploy` rejects unknown parameters, so a custom command + # that deploys a different template (e.g. an environment/recipe-pack + # bicep) must not receive them. The deploy target is the first + # positional argument after the `deploy` verb; inject the app-only + # parameters only when it is APP_FILE. + DEPLOY_TARGET="${CMD_ARGV[1]:-}" + if [ "$DEPLOY_TARGET" = "$APP_FILE" ]; then + if [ -n "$APP_IMAGE" ]; then + EXTRA_PARAMS+=(--parameters "image=$APP_IMAGE") + fi + if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then + while IFS= read -r _pname; do + [ -z "$_pname" ] && continue + _pval=$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_pname" '.[$k]') + EXTRA_PARAMS+=(--parameters "$_pname=$_pval") + done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') + fi + # Registry credentials for the app's Radius.Security/secrets + # resource. Appended to the argv array so the secret value is never + # word-split or written into the recorded command string. Skipped + # when the params secret already supplies the key, so the same + # --parameters is never passed twice. + if [ -n "$REGISTRY_USERNAME" ] && ! deploy_params_has_key registryUsername; then + EXTRA_PARAMS+=(--parameters "registryUsername=$REGISTRY_USERNAME") + fi + if [ -n "$REGISTRY_PASSWORD" ] && ! deploy_params_has_key registryPassword; then + EXTRA_PARAMS+=(--parameters "registryPassword=$REGISTRY_PASSWORD") + fi fi if ! record "$idx" "$cmd" rad "${CMD_ARGV[@]}" "${EXTRA_PARAMS[@]}"; then OVERALL_OUTCOME="command_failed" From 2f7df2a878cc1220f1b6c87c0b4920a5e40edf44 Mon Sep 17 00:00:00 2001 From: sk593 Date: Wed, 22 Jul 2026 13:47:40 -0700 Subject: [PATCH 05/12] Harden secret masking against multiline and special characters Address review feedback on the secret-redaction helpers: - add_secret now emits ::add-mask:: one line at a time, strips CR, and escapes '%' (the workflow-command escape char). A multiline secret previously left its second and later lines unmasked and printed verbatim, and an unescaped '%' could break or inject the workflow command. - Clarify that scrub_secrets double-quotes the search value inside the substitution, which disables pattern matching so glob metacharacters (* ? [ ] \) in a secret are matched literally rather than as a glob. Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 1d2914f5cd4..b1bfdd6916e 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -166,7 +166,18 @@ runs: add_secret() { [ -n "$1" ] || return 0 SECRET_VALUES+=("$1") - echo "::add-mask::$1" + # Mask one line at a time. GitHub reliably masks a single-line value, but + # a multiline ::add-mask:: leaves the second and later lines unmasked (and + # prints them verbatim). Strip CR and escape '%' -- the workflow-command + # escape character -- so a value can neither break nor inject the command, + # then emit a mask for each non-empty line. + local _line + while IFS= read -r _line || [ -n "$_line" ]; do + _line=${_line//$'\r'/} + [ -n "$_line" ] || continue + _line=${_line//'%'/'%25'} + echo "::add-mask::$_line" + done <<< "$1" } add_secret "$REGISTRY_PASSWORD" if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then @@ -177,9 +188,11 @@ runs: fi # Replace every known secret value in the given file with *** in place, so - # the artifact never carries a credential that `rad` happened to echo. Uses - # bash literal substitution (no regex) so values with special characters are - # matched verbatim. + # the artifact never carries a credential that `rad` happened to echo. The + # search value is double-quoted inside the substitution, which disables + # pattern matching so it is compared as a literal string -- glob + # metacharacters (* ? [ ] \) in a secret are matched verbatim, not as + # patterns. scrub_secrets() { local file="$1" content value [ ${#SECRET_VALUES[@]} -eq 0 ] && return 0 From 8553f7ab1ef40c07e051d8b5a05a08114d8043a4 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 10:38:35 -0700 Subject: [PATCH 06/12] Rename registry-push Secret to radius-ghcr-registry-creds Prefix the containerImages registry-push Secret name with radius- for parity with the app.bicep authored by the ai-extensions radius-app-bicep skill. This is the value passed to the recipe pack via containerImagesRegistrySecretName (azure) / registrySecretName (aws inline pack), so the recipe reads a Secret of the new name matching what the app's Radius.Security/secrets resource now creates. Only the Secret NAME changes; the app-deploy registryUsername/registryPassword injection is unaffected. Signed-off-by: sk593 --- .github/extension/README.md | 2 +- .github/extension/actions/run-rad-commands/action.yml | 4 ++-- .github/extension/run-rad-commands-aws.yml | 2 +- .github/extension/run-rad-commands-azure.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index 3ab70f13f4e..62006d903a5 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -82,7 +82,7 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 9. **Restore persisted state (`rad startup`).** Restores the control-plane databases and the Terraform recipe-state Secrets saved by the previous run, so `rad deploy` plans against prior state rather than an empty backend. A no-op on the first run. 10. **Register cloud credentials.** Registers the cloud identity with `rad credential register azure wi` / `aws irsa` so Radius holds the identity selector and reads the projected token at runtime. 11. **Create the Radius environment and recipe pack.** `rad deploy`s a `radius-env.bicep` that defines a `Radius.Core/recipePacks` resource and the `Radius.Core/environments` resource that references it. Azure downloads the `azure-avm` pack (Azure Verified Modules) from [resource-types-contrib](https://github.com/radius-project/resource-types-contrib); AWS generates an inline `aws-terraform` pack. `radius-env.bicep` is written to the app file's directory (e.g. `.radius/`) and deployed from there, so `rad deploy` resolves the repo's own `bicepconfig.json` (which declares the `radius` extension) — bicep resolves the config nearest the `.bicep` file. The `Radius.Compute/containerImages` type ships with the Radius extension, so no separate resource-type registration is needed. -12. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`), any application parameters from the `RADIUS_DEPLOY_PARAMS` secret, and the registry push/pull credentials as `registryUsername` (`github.actor`) and `registryPassword` (the built-in `GITHUB_TOKEN`). Those feed the app's `Radius.Security/secrets` resource (`ghcr-registry-creds`), which materializes the registry Secret on the target cluster so the containerImages recipe's in-pod BuildKit can push the application image. The secret value is passed via an argv array and never written into the recorded command string. +12. **Run the requested rad commands.** Validates each command in `rad_commands` against the allowed-command set, then runs them in order (stopping on the first failure) and writes a combined `rad-commands-result` artifact. When `rad_commands` is empty it runs the default `rad deploy --environment `, passing the `image` parameter (the `image` input, defaulting to `github.sha`), any application parameters from the `RADIUS_DEPLOY_PARAMS` secret, and the registry push/pull credentials as `registryUsername` (`github.actor`) and `registryPassword` (the built-in `GITHUB_TOKEN`). Those feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), which materializes the registry Secret on the target cluster so the containerImages recipe's in-pod BuildKit can push the application image. The secret value is passed via an argv array and never written into the recorded command string. 13. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the `radius-state` git orphan branch. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. 14. **Tear down.** Runs `rad app list`, and always deletes the ephemeral `radius-cp` cluster. On failure, Radius and application logs are collected and uploaded as the `radius-logs` artifact (three-day retention). diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index b1bfdd6916e..6be160c1b6a 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -63,7 +63,7 @@ runs: # Registry push/pull credentials for the app deploy. These are passed to # the app file's `rad deploy` as the registryUsername/registryPassword # parameters, which feed the app's Radius.Security/secrets resource - # (`ghcr-registry-creds`). That resource materializes the registry Secret + # (`radius-ghcr-registry-creds`). That resource materializes the registry Secret # on the TARGET cluster, where the containerImages recipe reads it by the # name the recipe pack registers. Read via the environment (not inlined # into the script) so the secret value can't break the shell or be @@ -312,7 +312,7 @@ runs: done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') fi # Registry credentials for the app's Radius.Security/secrets resource - # (`ghcr-registry-creds`). Appended to the argv array so the secret + # (`radius-ghcr-registry-creds`). Appended to the argv array so the secret # value is never word-split or written into the recorded command string. # Skipped when the params secret already supplies the key, so the same # --parameters is never passed twice. diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index ecf09e54f3b..8e05b8c74a4 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -239,7 +239,7 @@ jobs: # recipe pack's registrySecretName. $BUILD_REGISTRY is ghcr.io// # so images land under the repository's package namespace; it must be lowercase. BUILD_REGISTRY=$(echo "${{ vars.RADIUS_BUILD_REGISTRY || format('ghcr.io/{0}', github.repository) }}" | tr '[:upper:]' '[:lower:]') - REGISTRY_SECRET_NAME="ghcr-registry-creds" + REGISTRY_SECRET_NAME="radius-ghcr-registry-creds" # Select the provider-specific Terraform recipe pack. Both provider packs # bundle the shared Kubernetes compute/data recipes and differ only in the diff --git a/.github/extension/run-rad-commands-azure.yml b/.github/extension/run-rad-commands-azure.yml index 4575a67ffb6..01b5b4010b6 100644 --- a/.github/extension/run-rad-commands-azure.yml +++ b/.github/extension/run-rad-commands-azure.yml @@ -217,7 +217,7 @@ jobs: # ghcr.io// so images land under the repository's package # namespace; it must be lowercase. BUILD_REGISTRY=$(echo "${{ vars.RADIUS_BUILD_REGISTRY || format('ghcr.io/{0}', github.repository) }}" | tr '[:upper:]' '[:lower:]') - REGISTRY_SECRET_NAME="ghcr-registry-creds" + REGISTRY_SECRET_NAME="radius-ghcr-registry-creds" # Download the default Azure recipe pack from resource-types-contrib, # pinned to $RECIPE_PACK_REF. The recipe logic lives upstream; here we From fdecb92f0bef16486f5989b89afb2c1a70f3a5b5 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 14:36:02 -0700 Subject: [PATCH 07/12] Address review: robust deploy-target parsing, group-title escaping, AWS containerImages Bicep - run-rad-commands action: determine the deploy target as the first non-flag argument after `deploy`, skipping flags and value-taking flag values, so `deploy -e dev app.bicep` still injects app-only parameters. - record(): escape workflow-command metacharacters (CR, newline, %) in the ::group:: title to prevent breakage/injection from caller command strings. - AWS workflow: switch Radius.Compute/containerImages to the Bicep recipe to match the Azure hosted recipe pack. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 38 ++++++++++++++++--- .github/extension/run-rad-commands-aws.yml | 10 ++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 6be160c1b6a..9e2bd1e9181 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -125,9 +125,17 @@ runs: record() { local index="$1"; shift local display="$1"; shift - local outfile code + local outfile code gtitle outfile=$(mktemp) - echo "::group::rad $display" + # The group title carries the caller-supplied command string, so escape + # the workflow-command metacharacters: strip CR, fold newlines to spaces + # (a title is single-line), and escape '%' -- the escape char -- so the + # value can neither break the ::group:: command nor inject into the log. + gtitle="rad $display" + gtitle=${gtitle//$'\r'/} + gtitle=${gtitle//$'\n'/ } + gtitle=${gtitle//'%'/'%25'} + echo "::group::$gtitle" "$@" 2>&1 | tee "$outfile" code=${PIPESTATUS[0]} echo "::endgroup::" @@ -246,10 +254,28 @@ runs: # The image, application, and registry parameters belong to the app # only. `rad deploy` rejects unknown parameters, so a custom command # that deploys a different template (e.g. an environment/recipe-pack - # bicep) must not receive them. The deploy target is the first - # positional argument after the `deploy` verb; inject the app-only - # parameters only when it is APP_FILE. - DEPLOY_TARGET="${CMD_ARGV[1]:-}" + # bicep) must not receive them. Find the deploy target -- the first + # positional argument after the `deploy` verb -- skipping flags and + # the values of value-taking flags, since rad accepts flags before + # the file (e.g. `deploy -e dev app.bicep`). Inject the app-only + # parameters only when the target is APP_FILE. + DEPLOY_TARGET="" + _val_flags=" -w --workspace -g --group -a --application -e --environment -p --parameters " + _i=1 + while [ "$_i" -lt "${#CMD_ARGV[@]}" ]; do + _tok="${CMD_ARGV[$_i]}" + case "$_tok" in + -*=*) + _i=$((_i + 1)) ;; + -*) + case "$_val_flags" in + *" $_tok "*) _i=$((_i + 2)) ;; + *) _i=$((_i + 1)) ;; + esac ;; + *) + DEPLOY_TARGET="$_tok"; break ;; + esac + done if [ "$DEPLOY_TARGET" = "$APP_FILE" ]; then if [ -n "$APP_IMAGE" ]; then EXTRA_PARAMS+=(--parameters "image=$APP_IMAGE") diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index 8e05b8c74a4..30b1277ff14 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -241,9 +241,9 @@ jobs: BUILD_REGISTRY=$(echo "${{ vars.RADIUS_BUILD_REGISTRY || format('ghcr.io/{0}', github.repository) }}" | tr '[:upper:]' '[:lower:]') REGISTRY_SECRET_NAME="radius-ghcr-registry-creds" - # Select the provider-specific Terraform recipe pack. Both provider packs - # bundle the shared Kubernetes compute/data recipes and differ only in the - # mySQL database recipe and cloud provider config. + # Select the provider-specific recipe pack. Both provider packs share the + # Bicep containerImages recipe and the Kubernetes compute/data recipes, + # and differ only in the mySQL database recipe and cloud provider config. PACK_NAME="aws-terraform" MYSQL_RECIPE=$(cat < Date: Thu, 23 Jul 2026 14:43:01 -0700 Subject: [PATCH 08/12] Address review: make AWS containerImages recipe ref pinnable for reproducibility Introduce RADIUS_KUBE_RECIPES_REF (default 'latest') so the AWS inline recipe pack's Bicep containerImages source can be pinned to an immutable digest or a specific tag, instead of hard-coding the mutable ':latest' tag. A digest ref is joined with '@', a tag with ':'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .github/extension/run-rad-commands-aws.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index 30b1277ff14..475f54d48c6 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -241,6 +241,19 @@ jobs: BUILD_REGISTRY=$(echo "${{ vars.RADIUS_BUILD_REGISTRY || format('ghcr.io/{0}', github.repository) }}" | tr '[:upper:]' '[:lower:]') REGISTRY_SECRET_NAME="radius-ghcr-registry-creds" + # Tag/digest of the Bicep containerImages recipe. Defaults to the mutable + # `latest` tag (as the Azure hosted pack uses), but is overridable via the + # RADIUS_KUBE_RECIPES_REF repo variable so the pack can be pinned to an + # immutable digest (e.g. sha256:...) for reproducible runs, matching how + # the Terraform recipes below are pinned to $REF. A digest ref is joined + # with '@', a tag with ':'. + KUBE_RECIPES_REF="${{ vars.RADIUS_KUBE_RECIPES_REF || 'latest' }}" + case "$KUBE_RECIPES_REF" in + sha256:*) _ci_sep="@" ;; + *) _ci_sep=":" ;; + esac + CONTAINERIMAGES_SOURCE="ghcr.io/radius-project/kube-recipes/containerimages${_ci_sep}${KUBE_RECIPES_REF}" + # Select the provider-specific recipe pack. Both provider packs share the # Bicep containerImages recipe and the Kubernetes compute/data recipes, # and differ only in the mySQL database recipe and cloud provider config. @@ -282,7 +295,7 @@ jobs: recipes: { 'Radius.Compute/containerImages': { kind: 'bicep' - source: 'ghcr.io/radius-project/kube-recipes/containerimages:latest' + source: '$CONTAINERIMAGES_SOURCE' parameters: { registry: '$BUILD_REGISTRY' registrySecretName: '$REGISTRY_SECRET_NAME' From e490011edda09faf3f7118102b75acb6119636af Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 14:51:15 -0700 Subject: [PATCH 09/12] Address review: scrub recorded command string and validate deploy-params JSON - Scrub the recorded command `display` with the known SECRET_VALUES before it reaches the ::group:: title or the JSON `command` field, so a secret passed inline via --parameters can't be uploaded in the result artifact. - Validate RADIUS_DEPLOY_PARAMS is a JSON object once, up front, and reference the validated copy everywhere; suppress jq stderr so a malformed value can't spill raw secret content into the logs. Malformed input is rejected with a generic message and treated as empty. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 9e2bd1e9181..309913e02de 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -126,6 +126,11 @@ runs: local index="$1"; shift local display="$1"; shift local outfile code gtitle + # The recorded command string can contain a caller-supplied secret (e.g. + # a value passed inline via `--parameters`). GitHub's log masking does not + # apply to artifact contents, so strip every known secret from `display` + # before it reaches either the ::group:: title or the JSON `command` field. + display=$(scrub_string "$display") outfile=$(mktemp) # The group title carries the caller-supplied command string, so escape # the workflow-command metacharacters: strip CR, fold newlines to spaces @@ -160,8 +165,8 @@ runs: # key. Used to avoid passing a --parameters twice when an older environment # still supplies registryUsername/registryPassword in the params secret. deploy_params_has_key() { - [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ] || return 1 - printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -e --arg k "$1" 'has($k)' >/dev/null 2>&1 + [ -n "$DEPLOY_PARAMS_JSON" ] || return 1 + printf '%s' "$DEPLOY_PARAMS_JSON" | jq -e --arg k "$1" 'has($k)' >/dev/null 2>&1 } # Secret values that must never reach the logs or the uploaded artifact: @@ -187,29 +192,49 @@ runs: echo "::add-mask::$_line" done <<< "$1" } - add_secret "$REGISTRY_PASSWORD" + + # RADIUS_DEPLOY_PARAMS is a secret expected to be a JSON object. Validate it + # once, up front, so the jq calls below never emit a parse error -- which + # could echo raw secret content into the logs -- when the value is malformed. + # A blank/unset value means "no params"; a malformed value is rejected with a + # generic message (no secret content) and treated as empty. + DEPLOY_PARAMS_JSON="" if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then + if printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -e 'type == "object"' >/dev/null 2>&1; then + DEPLOY_PARAMS_JSON="$RADIUS_DEPLOY_PARAMS" + else + echo "::error::RADIUS_DEPLOY_PARAMS is not a valid JSON object; ignoring it." >&2 + fi + fi + + add_secret "$REGISTRY_PASSWORD" + if [ -n "$DEPLOY_PARAMS_JSON" ]; then while IFS= read -r _sname; do [ -z "$_sname" ] && continue - add_secret "$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_sname" '.[$k]')" - done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') + add_secret "$(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r --arg k "$_sname" '.[$k]' 2>/dev/null)" + done < <(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r 'keys_unsorted[]' 2>/dev/null) fi - # Replace every known secret value in the given file with *** in place, so - # the artifact never carries a credential that `rad` happened to echo. The + # Strip every known secret value from a string, replacing each with ***. The # search value is double-quoted inside the substitution, which disables # pattern matching so it is compared as a literal string -- glob # metacharacters (* ? [ ] \) in a secret are matched verbatim, not as # patterns. - scrub_secrets() { - local file="$1" content value - [ ${#SECRET_VALUES[@]} -eq 0 ] && return 0 - content=$(cat "$file") + scrub_string() { + local s="$1" value for value in "${SECRET_VALUES[@]}"; do [ -n "$value" ] || continue - content=${content//"$value"/***} + s=${s//"$value"/***} done - printf '%s' "$content" > "$file" + printf '%s' "$s" + } + + # Replace every known secret value in the given file with *** in place, so + # the artifact never carries a credential that `rad` happened to echo. + scrub_secrets() { + local file="$1" + [ ${#SECRET_VALUES[@]} -eq 0 ] && return 0 + printf '%s' "$(scrub_string "$(cat "$file")")" > "$file" } if [ -n "${RAD_COMMANDS//[[:space:]]/}" ]; then @@ -280,12 +305,12 @@ runs: if [ -n "$APP_IMAGE" ]; then EXTRA_PARAMS+=(--parameters "image=$APP_IMAGE") fi - if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then + if [ -n "$DEPLOY_PARAMS_JSON" ]; then while IFS= read -r _pname; do [ -z "$_pname" ] && continue - _pval=$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_pname" '.[$k]') + _pval=$(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r --arg k "$_pname" '.[$k]' 2>/dev/null) EXTRA_PARAMS+=(--parameters "$_pname=$_pval") - done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') + done < <(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r 'keys_unsorted[]' 2>/dev/null) fi # Registry credentials for the app's Radius.Security/secrets # resource. Appended to the argv array so the secret value is never @@ -330,12 +355,12 @@ runs: # --parameters name=value pairs. Each value is read back from the JSON # by key with jq so embedded '=', spaces, or newlines are preserved and # never re-split by the shell. - if [ -n "${RADIUS_DEPLOY_PARAMS//[[:space:]]/}" ]; then + if [ -n "$DEPLOY_PARAMS_JSON" ]; then while IFS= read -r _pname; do [ -z "$_pname" ] && continue - _pval=$(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r --arg k "$_pname" '.[$k]') + _pval=$(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r --arg k "$_pname" '.[$k]' 2>/dev/null) DEPLOY_PARAMS+=(--parameters "$_pname=$_pval") - done < <(printf '%s' "$RADIUS_DEPLOY_PARAMS" | jq -r 'keys_unsorted[]') + done < <(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r 'keys_unsorted[]' 2>/dev/null) fi # Registry credentials for the app's Radius.Security/secrets resource # (`radius-ghcr-registry-creds`). Appended to the argv array so the secret From 292319152d5542aeb0bd55cb0b8a21b5310fea39 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 14:56:39 -0700 Subject: [PATCH 10/12] Address review: treat null/empty deploy-params values as absent - deploy_params_has_key() now returns false when the key's value is JSON null or an empty string, so runner-provided registry credentials are still injected when an older params secret carries the key with no usable value. - When building the mask list, emit nothing for a JSON null instead of the literal string "null", so masking/scrubbing can't redact unrelated "null" output. A genuine string value is still masked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../extension/actions/run-rad-commands/action.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 309913e02de..c78c8c84028 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -162,11 +162,13 @@ runs: } # True when RADIUS_DEPLOY_PARAMS (a JSON object) already carries the given - # key. Used to avoid passing a --parameters twice when an older environment - # still supplies registryUsername/registryPassword in the params secret. + # key with a usable value. Used to avoid passing a --parameters twice when + # an older environment still supplies registryUsername/registryPassword in + # the params secret. A key whose value is null or an empty string is treated + # as absent, so the runner-provided credentials are still injected. deploy_params_has_key() { [ -n "$DEPLOY_PARAMS_JSON" ] || return 1 - printf '%s' "$DEPLOY_PARAMS_JSON" | jq -e --arg k "$1" 'has($k)' >/dev/null 2>&1 + printf '%s' "$DEPLOY_PARAMS_JSON" | jq -e --arg k "$1" 'has($k) and (.[$k] != null) and (.[$k] != "")' >/dev/null 2>&1 } # Secret values that must never reach the logs or the uploaded artifact: @@ -211,7 +213,9 @@ runs: if [ -n "$DEPLOY_PARAMS_JSON" ]; then while IFS= read -r _sname; do [ -z "$_sname" ] && continue - add_secret "$(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r --arg k "$_sname" '.[$k]' 2>/dev/null)" + # Emit nothing for a JSON null so add_secret is not asked to mask/scrub + # the literal string "null" (which would redact unrelated output). + add_secret "$(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r --arg k "$_sname" 'if .[$k] == null then empty else .[$k] end' 2>/dev/null)" done < <(printf '%s' "$DEPLOY_PARAMS_JSON" | jq -r 'keys_unsorted[]' 2>/dev/null) fi From 83e130688b1b245e023aaf9f857d2270776aaa85 Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 15:00:41 -0700 Subject: [PATCH 11/12] Address review: scrub individual lines of multi-line secrets in artifact add_secret now records each stripped, non-empty line of a secret in SECRET_VALUES in addition to the full value. Masking is emitted per line, so without this the artifact scrubber only matched the exact multi-line string and a value echoed line-by-line by rad could leak into the uploaded artifact. Lines are added before % escaping so they match the real output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../extension/actions/run-rad-commands/action.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index c78c8c84028..12c94d68d08 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -180,18 +180,22 @@ runs: SECRET_VALUES=() add_secret() { [ -n "$1" ] || return 0 + # Keep the full value (covers output emitted as one block) and each + # individual line, so artifact scrubbing matches what per-line masking + # protects: rad may echo a multi-line value line by line, and a per-line + # ::add-mask:: only redacts the live logs, not the uploaded artifact. SECRET_VALUES+=("$1") # Mask one line at a time. GitHub reliably masks a single-line value, but # a multiline ::add-mask:: leaves the second and later lines unmasked (and - # prints them verbatim). Strip CR and escape '%' -- the workflow-command - # escape character -- so a value can neither break nor inject the command, - # then emit a mask for each non-empty line. + # prints them verbatim). Strip CR, add the line to the scrub set (before + # escaping), then escape '%' -- the workflow-command escape character -- + # so a value can neither break nor inject the command. local _line while IFS= read -r _line || [ -n "$_line" ]; do _line=${_line//$'\r'/} [ -n "$_line" ] || continue - _line=${_line//'%'/'%25'} - echo "::add-mask::$_line" + SECRET_VALUES+=("$_line") + echo "::add-mask::${_line//'%'/'%25'}" done <<< "$1" } From 8a215a107d99e23c9604755d7e48f2a865162cee Mon Sep 17 00:00:00 2001 From: sk593 Date: Thu, 23 Jul 2026 17:41:17 -0700 Subject: [PATCH 12/12] Address review: normalize deploy target before comparing to APP_FILE The custom rad_commands deploy path gated app-only parameter injection on a raw string compare, which missed equivalent spellings of the app file. Normalize both sides -- strip a matched pair of surrounding quotes (read -ra preserves them) and a single leading './' -- so `deploy ./.radius/app.bicep` and `deploy ".radius/app.bicep"` still inject image/deploy params/registry creds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: sk593 --- .../actions/run-rad-commands/action.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/extension/actions/run-rad-commands/action.yml b/.github/extension/actions/run-rad-commands/action.yml index 12c94d68d08..18746a32ce3 100644 --- a/.github/extension/actions/run-rad-commands/action.yml +++ b/.github/extension/actions/run-rad-commands/action.yml @@ -309,7 +309,21 @@ runs: DEPLOY_TARGET="$_tok"; break ;; esac done - if [ "$DEPLOY_TARGET" = "$APP_FILE" ]; then + # Compare the target to APP_FILE after normalizing equivalent + # spellings: `read -ra` keeps surrounding quotes verbatim, and a + # caller may write `./.radius/app.bicep`. Strip a matched pair of + # surrounding single/double quotes and a single leading `./` from + # both sides so these forms still match. + normalize_target() { + local t="$1" + case "$t" in + \"*\") t=${t#\"}; t=${t%\"} ;; + \'*\') t=${t#\'}; t=${t%\'} ;; + esac + t=${t#./} + printf '%s' "$t" + } + if [ "$(normalize_target "$DEPLOY_TARGET")" = "$(normalize_target "$APP_FILE")" ]; then if [ -n "$APP_IMAGE" ]; then EXTRA_PARAMS+=(--parameters "image=$APP_IMAGE") fi