From a31d2c565ca60c36c5b3ae140b923affcf990c22 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:23:12 -0400 Subject: [PATCH 1/7] Publish deploy status as a workflow artifact instead of to GHCR The canvas Deployed tab needs deployment state that can be read *while a deploy is running*, not only after it finishes. GHCR cannot serve that: the artifact is only pushed once, at the end. Workflow artifacts can, because the REST API lists and downloads them while the run is still in progress. Replace the `oras push` to GHCR with `actions/upload-artifact`, publishing the same five files under a deterministic name the canvas can resolve: radius-deploy-status-- The canvas reads it two ways - `GET /actions/runs/{run_id}/artifacts` during a run, and `GET /actions/artifacts?name=` (newest first) afterwards, which needs no run id. This also removes three problems inherent to the GHCR path rather than working around them: no `packages: write` permission is needed, so the pre-flight check that validated the wrong package no longer matters; and the registry/tag derivation that was hand-duplicated in bash and TypeScript - the source of the invalid-tag bug - is gone, since an artifact name needs no counterpart in the reader beyond the same sanitization. Structural notes: - The generate step writes to a deterministic `$RUNNER_TEMP` directory rather than `mktemp -d`, because `upload-artifact` is a separate composite step that must reference the path. - `rad app graph` stderr now goes to a temp file outside that directory. The whole directory is uploaded, so anything written there ships in the artifact. - Step outputs are emitted exactly once per exit path via a helper, instead of writing defaults up front and overwriting them. Duplicate keys in `$GITHUB_OUTPUT` would leave the winning value an implementation detail, and guessing wrong fails silently - either skipping the upload after a good deploy or uploading under an empty name. - Publishing stays best-effort: every failure path warns, emits `published=false`, and exits 0, and the upload step is gated on that output, so a reporting problem still cannot fail a successful deployment. `RADIUS_GRAPH_TAG` is removed from both provider workflows; it only ever fed the OCI tag. `RADIUS_GRAPH_REGISTRY` is kept - despite the similar name it is read by the rad CLI itself (cmd/rad/cmd/root.go) to select the modeled graph archive backend, and removing it would break `rad startup`. With deploy status no longer using it, that variable now has exactly one meaning again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52550778-eb0a-45eb-bfdf-90374a382b5f Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .github/extension/README.md | 6 +- .../actions/publish-deploy-status/action.yml | 168 +++++++++--------- .github/extension/run-rad-commands-aws.yml | 1 - .github/extension/run-rad-commands-azure.yml | 1 - 4 files changed, 85 insertions(+), 91 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index ef081817032..92c2d6d63ad 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -105,7 +105,9 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 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. **Register custom types and apply custom recipe pack.** When the app's `.radius/` folder carries a `custom-types.yaml` file, the shared `apply-custom-recipe-packs` action registers those resource types with `rad resource-type create --from-file` (skipped when absent). When it carries a `custom-recipe-pack.bicep` file, the action snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 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. Before deploying the app, the shared action compiles its Bicep file once and reads the declared ARM parameters. It passes each extension-generated parameter only when the template declares it: `image` (the workflow input, defaulting to `github.sha`), `registryUsername` (`github.actor`), and `registryPassword` (the built-in `GITHUB_TOKEN`). Caller-configured application parameters from the `RADIUS_DEPLOY_PARAMS` secret remain strict and are passed unchanged. The registry parameters feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), when present, so the containerImages recipe's in-pod BuildKit can push the application image. Secret values are passed via an argv array and never written into the recorded command string. -14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.log` — per-resource provisioning state, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt`) as a single OCI artifact in GHCR (`RADIUS_GRAPH_REGISTRY`, with `RADIUS_GRAPH_TAG` or a derived `--latest` tag). It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort: if the push fails (most often no `packages: write` on the derived graph package, which the pre-flight [GHCR package push check](#ghcr-package-push-check) does not cover because it only tests `RADIUS_STATE_REGISTRY`), the action emits a warning and leaves the deployment result unchanged. +14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.log` — per-resource provisioning state, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt`) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`). It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort: if graph generation fails the action warns and leaves the deployment result unchanged. + + Workflow artifacts are the transport because the REST API can read them **while the run is still in progress** (`GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`), which is what lets the canvas show deployment state as it happens; `GET /repos/{owner}/{repo}/actions/artifacts?name=` finds the newest one later without knowing the run. They also require no extra registry, no `packages: write` permission, and no name derivation duplicated between this action and the canvas reader. 15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the state archive — the OCI-backed archive by default (pushed to GHCR, selected by the `RADIUS_STATE_*` variables), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. 16. **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). @@ -131,7 +133,7 @@ Triggers and permissions live on the **dispatcher** (`run-rad-commands.yml`); th The workflow reads cloud and cluster configuration from GitHub Actions **variables** (`vars`). Configure the relevant provider's set on the target GitHub Environment: -- Common: `KUBERNETES_NAMESPACE` (default `default`), `RADIUS_BUILD_REGISTRY` (default `ghcr.io//`), `RADIUS_RAD_COMMANDS` (optional fallback for `rad_commands`), `RADIUS_GRAPH_REGISTRY` (optional GHCR repo for deployed graph/status artifacts), `RADIUS_GRAPH_TAG` (optional tag override) +- Common: `KUBERNETES_NAMESPACE` (default `default`), `RADIUS_BUILD_REGISTRY` (default `ghcr.io//`), `RADIUS_RAD_COMMANDS` (optional fallback for `rad_commands`), `RADIUS_GRAPH_REGISTRY` (optional OCI repository for the `rad` CLI's modeled graph archive) - Azure (`run-rad-commands-azure.yml`): `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `AZURE_RESOURCE_GROUP`, `AZURE_AKS_CLUSTER_NAME` - AWS (`run-rad-commands-aws.yml`): `AWS_ROLE_ARN`, `AWS_REGION`, `AWS_ACCOUNT_ID`, `AWS_EKS_CLUSTER_NAME`, `RADIUS_VPC_ID`, `RADIUS_SUBNET_IDS` diff --git a/.github/extension/actions/publish-deploy-status/action.yml b/.github/extension/actions/publish-deploy-status/action.yml index a0c45bbea87..311c1e49fa1 100644 --- a/.github/extension/actions/publish-deploy-status/action.yml +++ b/.github/extension/actions/publish-deploy-status/action.yml @@ -1,9 +1,17 @@ # Provider-agnostic deploy-status publisher shared by run-rad-commands-aws.yml # and run-rad-commands-azure.yml. Captures the deployed app graph from the live -# control plane and publishes it as an OCI artifact to GHCR so the canvas -# "Deployed" graph view has a producer for deploy-graph.json. +# control plane and publishes it as a workflow artifact so the canvas "Deployed" +# graph view has a producer for deploy-graph.json. +# +# Workflow artifacts (rather than a GHCR OCI artifact) are the transport because +# they are readable over the REST API while the run is still in progress, which +# is what lets the canvas show deployment state as it happens: +# GET /repos/{o}/{r}/actions/runs/{run_id}/artifacts - during the run +# GET /repos/{o}/{r}/actions/artifacts?name= - newest-first, later +# They also need no extra registry, no packages:write permission, and no +# registry/tag derivation duplicated between this action and the canvas reader. name: Radius - Publish deploy status -description: Generate deploy-graph.json and publish deploy status files to GHCR. +description: Generate deploy-graph.json and publish deploy status files as a workflow artifact. inputs: environment: @@ -13,10 +21,16 @@ inputs: description: Application bicep file used for deploy. required: true +outputs: + artifact-name: + description: Name of the published deploy status artifact, empty when nothing was published. + value: ${{ steps.generate.outputs.artifact-name }} + runs: using: composite steps: - - name: Publish deployed graph and status files + - name: Generate deployed graph and status files + id: generate shell: bash env: ENVIRONMENT: ${{ inputs.environment }} @@ -25,7 +39,26 @@ runs: set -euo pipefail RESULT_FILE=/tmp/radius-output/rad-commands-result.json - STATUS_DIR=$(mktemp -d) + # A deterministic directory, not `mktemp -d`, because the upload step below + # is a separate composite step that needs to reference this path. + # RUNNER_TEMP is always set by the Actions runner; the fallback only makes + # the step runnable outside one, which is what lets it be unit tested. + STATUS_DIR="${RUNNER_TEMP:-/tmp}/radius-deploy-status" + rm -rf "$STATUS_DIR" + mkdir -p "$STATUS_DIR" + + # Emit step outputs exactly once, immediately before exiting, rather than + # writing defaults up front and overwriting them later. Duplicate keys in + # $GITHUB_OUTPUT would leave which value wins as an implementation detail, + # and getting it wrong fails silently: the upload step would be skipped on + # a good deploy, or run with an empty artifact name. + emit_outputs() { + { + echo "artifact-name=${1}" + echo "status-dir=${STATUS_DIR}" + echo "published=${2}" + } >> "$GITHUB_OUTPUT" + } # Resolve the app name from the app bicep file first (same pattern used # elsewhere in the run-rad-commands action), with a fallback to `rad app @@ -39,21 +72,26 @@ runs: if [ -z "$APP_NAME" ]; then echo "::warning::Could not determine application name; skipping deployed graph publish." + emit_outputs "" false exit 0 fi echo "Generating deployed graph for app '$APP_NAME' in environment '$ENVIRONMENT'..." - if ! rad app graph --application "$APP_NAME" --preview --include-icons --output json > "$STATUS_DIR/deploy-graph.json" 2> "$STATUS_DIR/deploy-graph.stderr"; then + # Keep stderr out of STATUS_DIR: that directory is uploaded wholesale as the + # artifact, so anything written there becomes part of the published payload. + GRAPH_STDERR=$(mktemp) + if ! rad app graph --application "$APP_NAME" --preview --include-icons --output json > "$STATUS_DIR/deploy-graph.json" 2> "$GRAPH_STDERR"; then echo "::warning::Failed to generate deployed graph; skipping publish." - cat "$STATUS_DIR/deploy-graph.stderr" || true + cat "$GRAPH_STDERR" || true + emit_outputs "" false exit 0 fi # Write sibling status files the canvas reads with deploy-graph.json. - # These are three distinct signals and must carry distinct content. The - # previous version copied rad-commands-result.json into all three, which - # published byte-identical blobs (same digest, deduplicated by the - # registry) that told the reader nothing about what actually happened. + # These are three distinct signals and must carry distinct content. An + # earlier version copied rad-commands-result.json into all three, which + # published three identical files that told the reader nothing about what + # actually happened. NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ") OUTCOME="unknown" EXIT_CODE="0" @@ -96,85 +134,41 @@ runs: "$OUTCOME" "$EXIT_CODE" "$APP_NAME" "$ENVIRONMENT" "$NOW_UTC" "$GITHUB_SHA" \ > "$STATUS_DIR/deploy-state.txt" - # Publish to GHCR as an OCI artifact. Prefer an explicit graph registry, - # then derive from RADIUS_STATE_REGISTRY for compatibility. - GRAPH_REGISTRY="${RADIUS_GRAPH_REGISTRY:-}" - if [ -z "$GRAPH_REGISTRY" ] && [ -n "${RADIUS_STATE_REGISTRY:-}" ]; then - case "$RADIUS_STATE_REGISTRY" in - *radius-state*) GRAPH_REGISTRY="${RADIUS_STATE_REGISTRY/radius-state/radius-graph}" ;; - *) GRAPH_REGISTRY="${RADIUS_STATE_REGISTRY}-graph" ;; - esac + # Derive the artifact name the canvas will look up. Sanitize under LC_ALL=C + # so the [^a-z0-9._-] class is evaluated bytewise: in the runner's UTF-8 + # locale GNU sed leaves multi-byte characters (the "é" in "café", or "™") + # intact, and those are not valid in a GitHub artifact name. Bytewise + # sanitization also keeps this in step with the canvas reader's own + # derivation, so both sides agree on the name for the same app. + BASE_NAME=$(printf '%s-%s' "$ENVIRONMENT" "$APP_NAME" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//' | LC_ALL=C cut -c1-80) + if [ -z "$BASE_NAME" ]; then + BASE_NAME="deploy-status" fi + ARTIFACT_NAME="radius-deploy-status-${BASE_NAME}" - if [ -z "$GRAPH_REGISTRY" ]; then - echo "::warning::No RADIUS_GRAPH_REGISTRY (or derivable RADIUS_STATE_REGISTRY); skipping deployed graph publish." - exit 0 - fi - - case "$GRAPH_REGISTRY" in - ghcr.io/*) ;; - *) - echo "::warning::RADIUS_GRAPH_REGISTRY must be a ghcr.io repository (got '$GRAPH_REGISTRY'); skipping deployed graph publish." - exit 0 - ;; - esac - - # Sanitize under LC_ALL=C so the [^a-z0-9._-] class is evaluated bytewise. - # In the runner's UTF-8 locale GNU sed leaves multi-byte characters (for - # example the "é" in "café", or "™") intact, which yields a tag that is - # invalid per the OCI spec and makes `oras push` fail the whole deploy. - # Byte-wise sanitization also matches the canvas reader's JS derivation - # (deriveGraphTag in adapters/canvas/src/deploy.mjs), which strips them. - BASE_TAG=$(printf '%s-%s' "$ENVIRONMENT" "$APP_NAME" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//' | LC_ALL=C cut -c1-80) - if [ -z "$BASE_TAG" ]; then - BASE_TAG="deploy-status" - fi - LATEST_TAG="${RADIUS_GRAPH_TAG:-${BASE_TAG}-latest}" - TARGET_REF="${GRAPH_REGISTRY}:${LATEST_TAG}" - - # The canvas reader derives this same tag from the app bicep's first + # The canvas derives this same name from the app bicep's first # single-quoted `name:` literal and has no `rad app list` fallback, so a - # name resolved that way publishes to a tag the reader will never request. + # name resolved that way publishes under a name it will never request. # Say so explicitly instead of leaving an empty Deployed tab to debug. - if [ "$APP_NAME_FROM_LITERAL" -eq 0 ] && [ -z "${RADIUS_GRAPH_TAG:-}" ]; then - echo "::warning::Application name '${APP_NAME}' came from 'rad app list', not a literal name in ${APP_FILE}. Publishing to '${TARGET_REF}', but the canvas derives its tag from the bicep literal and will not find this artifact. Set a literal name in the app bicep, or set RADIUS_GRAPH_TAG on both sides." - fi - - # Include metadata annotations and publish all files as one artifact so the - # reader can pull a single reference and inspect its files locally. - # - # Run from inside STATUS_DIR and pass bare file names. ORAS rejects absolute - # paths ("absolute file path detected") and, more importantly, it derives each - # layer's org.opencontainers.image.title from the path it is given. The canvas - # looks the files up by exactly those titles, so pushing absolute paths - even - # with --disable-path-validation to get past the check - would title the layer - # "/tmp/tmp.XXXX/deploy-graph.json" and the reader would never find it. The - # subshell keeps the cd from leaking into the rest of the step. - # - # Publishing status is best-effort reporting, not part of the deployment - # itself: every other failure path in this action warns and exits 0. The - # push must do the same, otherwise a problem that has nothing to do with - # the deploy - most commonly no `packages: write` access to the derived - # graph package, which the pre-flight verify-ghcr-push check does not - # cover because it only tests RADIUS_STATE_REGISTRY - marks an otherwise - # successful deploy as failed under `set -e`. - PUSH_LOG=$(mktemp) - if ! ( cd "$STATUS_DIR" && oras push "$TARGET_REF" \ - --artifact-type application/vnd.radius.deploy-status.v1+json \ - --annotation "org.opencontainers.image.created=${NOW_UTC}" \ - --annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \ - --annotation "dev.radius.application=${APP_NAME}" \ - --annotation "dev.radius.environment=${ENVIRONMENT}" \ - "deploy-graph.json:application/json" \ - "deploy-progress.log:text/plain" \ - "deploy-activity.log:text/plain" \ - "deploy-controlplane.log:text/plain" \ - "deploy-state.txt:text/plain" ) > "$PUSH_LOG" 2>&1; then - cat "$PUSH_LOG" || true - PUSH_ERR=$(tr '\n' ' ' < "$PUSH_LOG" | sed 's/[[:space:]]\+/ /g' | cut -c1-400) - echo "::warning::Failed to publish deploy status artifact to '${TARGET_REF}', so the canvas Deployed tab will not show this run. The deployment itself is unaffected. ORAS reported: ${PUSH_ERR}" - exit 0 + if [ "$APP_NAME_FROM_LITERAL" -eq 0 ]; then + echo "::warning::Application name '${APP_NAME}' came from 'rad app list', not a literal name in ${APP_FILE}. Publishing as '${ARTIFACT_NAME}', but the canvas derives the name from the bicep literal and will not find this artifact. Set a literal name in the app bicep." fi - cat "$PUSH_LOG" || true - echo "✅ Published deploy status artifact to '$TARGET_REF'." + emit_outputs "$ARTIFACT_NAME" true + + echo "Deploy status files ready in '$STATUS_DIR'; publishing as artifact '$ARTIFACT_NAME'." + # Publishing status is best-effort reporting, not part of the deployment + # itself: every failure path in the step above warns and exits 0 so a + # reporting problem never fails an otherwise successful deploy. Skipping the + # upload when nothing was generated keeps that property here. + - name: Upload deploy status artifact + if: steps.generate.outputs.published == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.generate.outputs.artifact-name }} + path: ${{ steps.generate.outputs.status-dir }} + # The canvas reads the newest artifact with this name, so retention bounds + # how long the Deployed tab keeps working after a deploy. + retention-days: 30 + if-no-files-found: warn + overwrite: true diff --git a/.github/extension/run-rad-commands-aws.yml b/.github/extension/run-rad-commands-aws.yml index 60a2f389fb2..e46ae262082 100644 --- a/.github/extension/run-rad-commands-aws.yml +++ b/.github/extension/run-rad-commands-aws.yml @@ -56,7 +56,6 @@ jobs: RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} RADIUS_GRAPH_REGISTRY: ${{ vars.RADIUS_GRAPH_REGISTRY }} - RADIUS_GRAPH_TAG: ${{ vars.RADIUS_GRAPH_TAG }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.github/extension/run-rad-commands-azure.yml b/.github/extension/run-rad-commands-azure.yml index 02fe9cdcbc9..2f2f7ce078f 100644 --- a/.github/extension/run-rad-commands-azure.yml +++ b/.github/extension/run-rad-commands-azure.yml @@ -62,7 +62,6 @@ jobs: RADIUS_STATE_REGISTRY: ${{ vars.RADIUS_STATE_REGISTRY }} RADIUS_STATE_ARCHIVE: ${{ vars.RADIUS_STATE_ARCHIVE }} RADIUS_GRAPH_REGISTRY: ${{ vars.RADIUS_GRAPH_REGISTRY }} - RADIUS_GRAPH_TAG: ${{ vars.RADIUS_GRAPH_TAG }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 From 786172f9f138cce9adb26d169a36a83a0fa0e319 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:39:27 -0400 Subject: [PATCH 2/7] Emit deploy-progress.json to the schema agreed with the canvas reader Replaces the TSV `deploy-progress.log` with structured JSON, matching the contract settled with the ai-extensions canvas reader. { "schemaVersion": 1, "application", "environment", "runId", "sequence": 1, "updatedAt", "state", "resources": [ { id, name, type, provisioningState, status, message } ] } Each resource carries both the raw `provisioningState` and a normalized `status`. The producer owns the mapping because it knows the Radius version, while emitting the raw value lets the consumer recover if that mapping ever goes stale. Unknown provisioning states normalize to `in_progress`, never `failed`, so a Radius state this action has not seen cannot paint a node red in the graph. Run-level `state` is derived from the rad command outcome. It is always terminal today - this step runs after `rad deploy` has already returned - so `in_progress` only becomes reachable once mid-run uploads exist. `sequence` is likewise always 1 for the single end-of-run upload; both fields are part of the contract now so the consumer's merge logic does not have to change when that lands. `deploy-activity.log` is kept even though the reader does not consume it, since it costs nothing and is useful when debugging a run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52550778-eb0a-45eb-bfdf-90374a382b5f Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .../actions/publish-deploy-status/action.yml | 69 +++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/.github/extension/actions/publish-deploy-status/action.yml b/.github/extension/actions/publish-deploy-status/action.yml index 311c1e49fa1..e182684d837 100644 --- a/.github/extension/actions/publish-deploy-status/action.yml +++ b/.github/extension/actions/publish-deploy-status/action.yml @@ -88,10 +88,9 @@ runs: fi # Write sibling status files the canvas reads with deploy-graph.json. - # These are three distinct signals and must carry distinct content. An - # earlier version copied rad-commands-result.json into all three, which - # published three identical files that told the reader nothing about what - # actually happened. + # These are distinct signals and must carry distinct content. An earlier + # version copied rad-commands-result.json into all of them, which published + # identical files that told the reader nothing about what actually happened. NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ") OUTCOME="unknown" EXIT_CODE="0" @@ -100,21 +99,65 @@ runs: EXIT_CODE=$(jq -r '.exitCode // 0' "$RESULT_FILE" 2>/dev/null || echo "0") fi - # deploy-progress.log - per-resource provisioning state from the live - # control plane, the closest thing to per-node status available here. + # Run-level state, using the vocabulary the canvas expects. `in_progress` + # is only reachable once mid-run uploads exist; today this step runs after + # `rad deploy` has already returned, so the state is always terminal. + case "$OUTCOME" in + success|succeeded) RUN_STATE="succeeded" ;; + *) RUN_STATE="failed" ;; + esac + + # deploy-progress.json - per-resource status, the signal that paints the + # graph. Schema is the contract agreed with the canvas reader; see + # docs/design in radius-project/ai-extensions. Both the raw + # provisioningState and a normalized status are emitted: the producer owns + # the mapping because it knows the Radius version, and the consumer can + # still recover if that mapping goes stale. Unknown states normalize to + # in_progress, never failed, so a new Radius state cannot paint a node red. + # # `rad resource list -o json` marshals []generated.GenericResource directly # (pkg/cli/cmd/resource/list/list.go), so the payload is a bare array with # no object wrapper - iterate it with `.[]?` and nothing else. - if ! rad resource list -a "$APP_NAME" -o json 2>/dev/null \ - | jq -r '.[]? | "\(.name)\t\(.type)\t\(.properties.provisioningState // "Unknown")"' \ - > "$STATUS_DIR/deploy-progress.log" 2>/dev/null; then - : > "$STATUS_DIR/deploy-progress.log" - fi - if [ ! -s "$STATUS_DIR/deploy-progress.log" ]; then - printf 'no per-resource state available for %s\n' "$APP_NAME" > "$STATUS_DIR/deploy-progress.log" + RESOURCES_JSON="[]" + if RAW_RESOURCES=$(rad resource list -a "$APP_NAME" -o json 2>/dev/null); then + RESOURCES_JSON=$(printf '%s' "$RAW_RESOURCES" | jq -c ' + [ .[]? | { + id: (.id // ""), + name: (.name // ""), + type: (.type // ""), + provisioningState: (.properties.provisioningState // ""), + status: ( + (.properties.provisioningState // "") as $s + | if $s == "Succeeded" then "success" + elif $s == "Failed" or $s == "Canceled" or $s == "Cancelled" then "failed" + else "in_progress" + end + ), + message: (.properties.status.message // "") + } ]' 2>/dev/null) || RESOURCES_JSON="[]" fi + [ -n "$RESOURCES_JSON" ] || RESOURCES_JSON="[]" + + jq -n \ + --argjson resources "$RESOURCES_JSON" \ + --arg application "$APP_NAME" \ + --arg environment "$ENVIRONMENT" \ + --argjson runId "${GITHUB_RUN_ID:-0}" \ + --arg updatedAt "$NOW_UTC" \ + --arg state "$RUN_STATE" \ + '{ + schemaVersion: 1, + application: $application, + environment: $environment, + runId: $runId, + sequence: 1, + updatedAt: $updatedAt, + state: $state, + resources: $resources + }' > "$STATUS_DIR/deploy-progress.json" # deploy-activity.log - the rad command result envelope (outcome/exit code). + # Optional for the canvas; kept because it is useful when debugging a run. if [ -f "$RESULT_FILE" ]; then cp "$RESULT_FILE" "$STATUS_DIR/deploy-activity.log" else From e23344e16c1a7f25de02d53f1139de8300740ccf Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:27:21 -0400 Subject: [PATCH 3/7] Add CI-enforced test harness for publish-deploy-status action The action's behaviour was validated once locally, so nothing guarded it against drift: the deploy-progress.json interface, byte-wise artifact name sanitization, distinct sibling status files, and warn-and-exit-0 on every failure path had no regression coverage. Add a shell unit test that extracts the action's inline run: block and executes it against a stubbed rad binary, with RUNNER_TEMP, GITHUB_OUTPUT and TMPDIR redirected into a sandbox. It asserts each \ key is emitted exactly once per exit path, that STATUS_DIR holds exactly the five status files and is rebuilt per run, and that the step runs outside an Actions runner. deploy-progress.json is a cross-repo contract with the canvas reader, which renders an empty graph rather than failing when a field drifts, so it is asserted by shape: schemaVersion, run state, and every resource carrying both the raw provisioningState and a normalized status. Unknown provisioning states must normalize to in_progress, never failed. The upload step is a uses: step and cannot be executed here, so the expressions wiring it to the generate step's outputs are checked structurally against the parsed YAML instead. No Docker, network or artifact upload, so it runs in the unit-test job alongside the Go tests via make test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .../publish-deploy-status_test.sh | 706 ++++++++++++++++++ build/test.mk | 6 +- 2 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 .github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh diff --git a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh new file mode 100644 index 00000000000..cc78578b104 --- /dev/null +++ b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh @@ -0,0 +1,706 @@ +#!/bin/bash + +# ============================================================================ +# Unit tests for the publish-deploy-status composite action. +# +# The generating logic lives inline in the `run:` block of action.yml, so the +# test extracts that block and executes it directly. Testing the extracted real +# body rather than a copy means the test cannot drift from the action. +# +# The action is two composite steps: a `run:` step that writes the status files +# and sets step outputs, and an actions/upload-artifact step gated on those +# outputs. Only the first is executable here, so the expressions wiring the two +# together are checked structurally against the parsed YAML instead - nothing +# else covers that seam. +# +# `rad` is stubbed. There is no runner, no network and no artifact upload: +# RUNNER_TEMP, GITHUB_OUTPUT and TMPDIR are all redirected into a sandbox. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly ACTION_FILE="${SCRIPT_DIR}/action.yml" + +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT +# Where the action lands when RUNNER_TEMP is unset. Fixed by the action, so it +# cannot be sandboxed; it is the action's own scratch directory and is removed +# on exit. +readonly FALLBACK_STATUS_DIR="/tmp/radius-deploy-status" +trap 'rm -rf "${TEST_ROOT}" "${FALLBACK_STATUS_DIR}"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +if ! command -v jq >/dev/null 2>&1; then + fail "jq is required; run 'make install-jq'" +fi +if ! command -v python3 >/dev/null 2>&1; then + fail "python3 is required to parse action.yml" +fi + +readonly BODY_SCRIPT="${TEST_ROOT}/publisher-body.sh" +readonly STEP_FACTS="${TEST_ROOT}/step-facts.txt" +readonly STUB_BIN="${TEST_ROOT}/bin" +readonly PUBLISHER_LOG="${TEST_ROOT}/publisher.log" +readonly PUBLISHER_TMP="${TEST_ROOT}/tmp" +readonly RUNNER_TEMP="${TEST_ROOT}/runner-temp" +readonly GITHUB_OUTPUT="${TEST_ROOT}/github-output.txt" +readonly STATUS_DIR="${RUNNER_TEMP}/radius-deploy-status" +readonly RESULT_FILE="${TEST_ROOT}/rad-commands-result.json" +readonly LITERAL_APP_FILE="${TEST_ROOT}/app.bicep" +readonly NO_LITERAL_APP_FILE="${TEST_ROOT}/no-literal.bicep" +readonly UNICODE_APP_FILE="${TEST_ROOT}/unicode.bicep" +readonly MESSY_APP_FILE="${TEST_ROOT}/messy-name.bicep" + +# --------------------------------------------------------------------------- +# Extract the run: block from action.yml. +# +# Deliberately stdlib-only: CI provisions a bare interpreter via +# actions/setup-python, where PyYAML is not guaranteed to be present. The block +# is a literal scalar, so dedenting its lines reproduces it exactly. +# --------------------------------------------------------------------------- +extract_action_body() { + python3 - "${ACTION_FILE}" "${BODY_SCRIPT}" <<'PYTHON' +import re +import sys + +action_file, out_file = sys.argv[1], sys.argv[2] +lines = open(action_file, encoding="utf-8").read().splitlines() + +starts = [i for i, l in enumerate(lines) if re.match(r"^\s*run:\s*\|\s*$", l)] +if len(starts) != 1: + sys.exit("expected exactly one 'run: |' block, found %d" % len(starts)) + +body, base = [], None +for line in lines[starts[0] + 1:]: + if not line.strip(): + body.append("") + continue + indent = len(line) - len(line.lstrip(" ")) + if base is None: + base = indent + if indent < base: + break + body.append(line[base:]) + +while body and not body[-1]: + body.pop() +if not body: + sys.exit("extracted an empty run: block") + +open(out_file, "w", encoding="utf-8").write("\n".join(body) + "\n") +PYTHON +} + +# --------------------------------------------------------------------------- +# Flatten the parts of action.yml that the executable body cannot cover: the +# step ids and the three `${{ steps.generate.outputs.* }}` expressions that wire +# the generate step to the upload step. Emitted as `key=value` lines. +# --------------------------------------------------------------------------- +extract_step_facts() { + python3 - "${ACTION_FILE}" "${STEP_FACTS}" <<'PYTHON' +import re +import sys + +action_file, out_file = sys.argv[1], sys.argv[2] +lines = open(action_file, encoding="utf-8").read().splitlines() + +KEY = re.compile(r"^\s*([A-Za-z0-9_.-]+):\s*(.*)$") + + +def flatten(block): + """Map a YAML mapping's scalar leaves to dotted key paths.""" + base = min((len(l) - len(l.lstrip(" ")) for l in block if l.strip()), + default=0) + result, stack, block_scalar_at = {}, [], None + for line in block: + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) - base + # Skip the contents of a literal block scalar: the shell inside `run: |` + # contains `key: value` lines (jq filters, for one) that are not YAML. + if block_scalar_at is not None: + if indent > block_scalar_at: + continue + block_scalar_at = None + matched = KEY.match(line) + if not matched: + continue + key, value = matched.group(1), matched.group(2).strip() + while stack and stack[-1][0] >= indent: + stack.pop() + path = ".".join([k for _, k in stack] + [key]) + if value in ("|", ">", "|-", ">-", "|+", ">+"): + result[path] = value + block_scalar_at = indent + elif value: + result[path] = value + else: + stack.append((indent, key)) + return result + + +steps_at = [i for i, l in enumerate(lines) if re.match(r"^\s*steps:\s*$", l)] +if len(steps_at) != 1: + sys.exit("expected exactly one 'steps:' block") + +blocks, item_indent = [], None +for line in lines[steps_at[0] + 1:]: + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) + if re.match(r"^\s*-\s", line) and indent == (item_indent or indent): + item_indent = indent + blocks.append([re.sub(r"^(\s*)-\s", r"\1 ", line)]) + continue + if item_indent is None or indent <= item_indent: + break + blocks[-1].append(line) + +steps = [flatten(b) for b in blocks] +if len(steps) != 2: + sys.exit("expected exactly two composite steps, found %d" % len(steps)) + +generate, upload = steps[0], steps[1] +if not upload.get("uses", "").startswith("actions/upload-artifact@"): + sys.exit("expected the second step to use actions/upload-artifact") + +facts = { + "generate.id": generate.get("id", ""), + "generate.shell": generate.get("shell", ""), + "upload.if": upload.get("if", ""), + "upload.uses": upload.get("uses", ""), + "upload.with.name": upload.get("with.name", ""), + "upload.with.path": upload.get("with.path", ""), + "outputs.artifact-name.value": flatten(lines).get( + "outputs.artifact-name.value", ""), +} +with open(out_file, "w", encoding="utf-8") as handle: + for key in sorted(facts): + handle.write("%s=%s\n" % (key, facts[key])) +PYTHON +} + +# The action hardcodes RESULT_FILE under /tmp. Redirect it into the test's +# sandbox so the test neither reads nor clobbers a real deploy's output. The +# substitution is asserted so a rename in action.yml fails loudly here instead +# of silently testing a path that no longer exists. +redirect_result_file() { + local before after + before="$(grep -c '^RESULT_FILE=' "${BODY_SCRIPT}")" + [[ "${before}" == "1" ]] || + fail "expected one RESULT_FILE assignment, found ${before}" + + sed -i -E "s|^RESULT_FILE=.*|RESULT_FILE=${RESULT_FILE}|" "${BODY_SCRIPT}" + + after="$(grep -c "^RESULT_FILE=${RESULT_FILE}\$" "${BODY_SCRIPT}")" + [[ "${after}" == "1" ]] || + fail "failed to redirect RESULT_FILE into the test sandbox" +} + +write_rad_stub() { + mkdir -p "${STUB_BIN}" + cat >"${STUB_BIN}/rad" <<'EOF' +#!/bin/bash +set -uo pipefail + +case "${1:-} ${2:-}" in + "app graph") + if [[ "${RAD_GRAPH_SHOULD_FAIL:-false}" == "true" ]]; then + echo "rad: application not found in environment" >&2 + exit 1 + fi + printf '%s\n' \ + '{"resources":[{"name":"web","type":"Radius.Compute/containers"}]}' + ;; + "app list") + printf '[{"name":"%s"}]\n' "${RAD_APP_LIST_NAME-fallback-app}" + ;; + "resource list") + if [[ "${RAD_RESOURCE_LIST_SHOULD_FAIL:-false}" == "true" ]]; then + echo "rad: could not reach the control plane" >&2 + exit 1 + fi + # Shaped like []generated.GenericResource, which is what + # `rad resource list -o json` marshals. The three provisioning states + # cover each branch of the status normalization in the action. + printf '%s\n' '[ + {"id":"/planes/radius/local/rg/web","name":"web", + "type":"Radius.Compute/containers", + "properties":{"provisioningState":"Succeeded", + "status":{"message":"container ready"}}}, + {"id":"/planes/radius/local/rg/db","name":"db", + "type":"Radius.Data/postgres", + "properties":{"provisioningState":"Failed", + "status":{"message":"recipe execution failed"}}}, + {"id":"/planes/radius/local/rg/queue","name":"queue", + "type":"Radius.Messaging/rabbitMQQueues", + "properties":{"provisioningState":"Updating"}} + ]' + ;; + "version "*) + echo "RELEASE VERSION 0.99.0-test" + ;; + "env list") + printf '%s\n' '[{"name":"aks-dev"}]' + ;; + *) + printf '{}\n' + ;; +esac +EOF + chmod +x "${STUB_BIN}/rad" +} + +# --------------------------------------------------------------------------- +# Harness +# --------------------------------------------------------------------------- +PUBLISHER_EXIT=0 +PUBLISHER_LOCALE="" +PUBLISHER_WITHOUT_RUNNER_TEMP=false + +run_publisher() { + rm -rf "${PUBLISHER_TMP}" + # "preserve" leaves an existing STATUS_DIR in place to prove the action + # rebuilds it rather than republishing a previous run's files. + [[ "${1:-}" == "preserve" ]] || rm -rf "${RUNNER_TEMP}" + mkdir -p "${RUNNER_TEMP}" "${PUBLISHER_TMP}" + : >"${GITHUB_OUTPUT}" + + set +e + ( + PATH="${STUB_BIN}:${PATH}" + # Keep the action's `mktemp` inside the sandbox. + TMPDIR="${PUBLISHER_TMP}" + export PATH TMPDIR GITHUB_OUTPUT + # RUNNER_TEMP is a plain variable here, so leaving it unexported is + # enough to hide it from the step. + if [[ "${PUBLISHER_WITHOUT_RUNNER_TEMP}" != "true" ]]; then + export RUNNER_TEMP + fi + if [[ -n "${PUBLISHER_LOCALE}" ]]; then + LC_ALL="${PUBLISHER_LOCALE}" + LANG="${PUBLISHER_LOCALE}" + export LC_ALL LANG + fi + bash "${BODY_SCRIPT}" + ) >"${PUBLISHER_LOG}" 2>&1 + PUBLISHER_EXIT=$? + set -e +} + +assert_exit_zero() { + ((PUBLISHER_EXIT == 0)) || + fail "$1: expected exit 0, got ${PUBLISHER_EXIT} +$(cat "${PUBLISHER_LOG}")" +} + +assert_output_contains() { + grep -qF -- "$1" "${PUBLISHER_LOG}" || + fail "expected publisher output to contain '$1' +$(cat "${PUBLISHER_LOG}")" +} + +assert_output_lacks() { + if grep -qF -- "$1" "${PUBLISHER_LOG}"; then + fail "did not expect publisher output to contain '$1' +$(cat "${PUBLISHER_LOG}")" + fi +} + +assert_equals() { + [[ "$2" == "$3" ]] || fail "$1: expected '$3', got '$2'" +} + +step_output() { + sed -n "s/^$1=//p" "${GITHUB_OUTPUT}" +} + +# A duplicate key in $GITHUB_OUTPUT leaves which value wins undefined, and the +# failure is silent: the upload step would be skipped after a good deploy, or +# run with an empty artifact name. Every exit path must emit each key once. +assert_step_outputs() { + local expected_name="$1" expected_published="$2" key count + local expected_dir="${3:-${STATUS_DIR}}" + local lines + lines="$(grep -c . "${GITHUB_OUTPUT}" || true)" + [[ "${lines}" == "3" ]] || + fail "expected 3 step outputs, got ${lines}: +$(cat "${GITHUB_OUTPUT}")" + + for key in artifact-name status-dir published; do + count="$(grep -c "^${key}=" "${GITHUB_OUTPUT}" || true)" + [[ "${count}" == "1" ]] || + fail "expected '${key}' in \$GITHUB_OUTPUT exactly once, got \ +${count}: +$(cat "${GITHUB_OUTPUT}")" + done + + assert_equals "artifact-name output" \ + "$(step_output artifact-name)" "${expected_name}" + assert_equals "published output" \ + "$(step_output published)" "${expected_published}" + assert_equals "status-dir output" \ + "$(step_output status-dir)" "${expected_dir}" +} + +# The whole directory ships inside the artifact, so anything stray in it becomes +# part of the published payload that users download. +assert_status_dir_contains_exactly() { + local dir="${2:-${STATUS_DIR}}" + local actual + actual="$(cd "${dir}" && find . -mindepth 1 | + sed 's|^\./||' | LC_ALL=C sort)" + [[ "${actual}" == "$1" ]] || + fail "unexpected contents in ${dir}; expected: +$1 +got: +${actual}" +} + +assert_status_file_contains() { + local file="${STATUS_DIR}/$1" + [[ -f "${file}" ]] || fail "expected '$1' in STATUS_DIR" + grep -qF -- "$2" "${file}" || + fail "expected '$1' to contain '$2', got: +$(cat "${file}")" +} + +assert_status_files_differ() { + if cmp -s "${STATUS_DIR}/$1" "${STATUS_DIR}/$2"; then + fail "'$1' and '$2' must carry distinct content" + fi +} + +# deploy-progress.json is the cross-repo interface the canvas renders the graph +# from. A substring check would not notice a renamed field, and the canvas fails +# silently on one - it renders an empty graph - so assert the shape instead. +readonly PROGRESS_JSON_FILE_NAME="deploy-progress.json" + +progress_jq() { + local filter="$1" + shift + jq -e "${filter}" "$@" "${STATUS_DIR}/${PROGRESS_JSON_FILE_NAME}" \ + >/dev/null +} + +assert_progress_contract() { + local file="${STATUS_DIR}/${PROGRESS_JSON_FILE_NAME}" + [[ -f "${file}" ]] || fail "expected ${PROGRESS_JSON_FILE_NAME} to be written" + jq -e . "${file}" >/dev/null 2>&1 || + fail "${PROGRESS_JSON_FILE_NAME} is not valid JSON: +$(cat "${file}")" + + progress_jq '.schemaVersion == 1' || + fail "expected .schemaVersion == 1, got: $(jq -c '.schemaVersion' \ +"${file}")" + progress_jq '.state | type == "string" and length > 0' || + fail "expected a non-empty .state" + progress_jq '.resources | type == "array"' || + fail "expected .resources to be an array" + progress_jq '[.application, .environment, .updatedAt] | + all(type == "string" and length > 0)' || + fail "expected non-empty .application, .environment and .updatedAt" + progress_jq '.runId | type == "number"' || + fail "expected a numeric .runId" + + # Every resource must carry both the raw provisioningState and the + # normalized status: the consumer needs the normalized value to paint the + # graph, and the raw one to recover if the mapping goes stale. + progress_jq 'all(.resources[]; has("id") and has("name") and has("type") + and has("provisioningState") and has("status") and has("message"))' || + fail "every .resources[] entry needs id, name, type, provisioningState,\ + status and message; got: $(jq -c '.resources' "${file}")" + progress_jq 'all(.resources[]; + .status == "success" or .status == "failed" + or .status == "in_progress")' || + fail "unexpected .resources[].status value: $(jq -c \ +'[.resources[].status]' "${file}")" +} + +assert_resource_status() { + # $name/$want are jq variables, not shell expansions. + # shellcheck disable=SC2016 + progress_jq --arg name "$1" --arg want "$2" \ + 'any(.resources[]; .name == $name and .status == $want)' || + fail "expected resource '$1' to normalize to status '$2', got: \ +$(jq -c --arg n "$1" '.resources[] | select(.name == $n)' \ +"${STATUS_DIR}/${PROGRESS_JSON_FILE_NAME}")" +} + +assert_run_state() { + # shellcheck disable=SC2016 + progress_jq --arg want "$1" '.state == $want' || + fail "expected run .state '$1', got: $(jq -c '.state' \ +"${STATUS_DIR}/${PROGRESS_JSON_FILE_NAME}")" +} + +assert_fact() { + local actual + actual="$(sed -n "s|^$1=||p" "${STEP_FACTS}")" + [[ "${actual}" == "$2" ]] || + fail "action.yml: expected $1 to be '$2', got '${actual}'" +} + +write_app_file() { + printf 'resource app %s = {\n name: %s\n}\n' \ + "'Radius.Core/applications'" "'$2'" >"$1" +} + +reset_environment() { + PUBLISHER_LOCALE="" + PUBLISHER_WITHOUT_RUNNER_TEMP=false + export ENVIRONMENT="aks-dev" + export GITHUB_SHA="deadbeefcafe" + export GITHUB_RUN_ID="4242" + export APP_FILE="${LITERAL_APP_FILE}" + unset RAD_GRAPH_SHOULD_FAIL RAD_APP_LIST_NAME + unset RAD_RESOURCE_LIST_SHOULD_FAIL + printf '{"outcome":"success","exitCode":0}\n' >"${RESULT_FILE}" +} + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- +extract_action_body +extract_step_facts +redirect_result_file +write_rad_stub + +write_app_file "${LITERAL_APP_FILE}" "todolist" +write_app_file "${UNICODE_APP_FILE}" "$(printf 'caf\303\251')" +write_app_file "${MESSY_APP_FILE}" "My App/Prod" +printf 'param appName string\nresource app %s = {\n name: appName\n}\n' \ + "'Radius.Core/applications'" >"${NO_LITERAL_APP_FILE}" + +readonly EXPECTED_STATUS_FILES="deploy-activity.log +deploy-controlplane.log +deploy-graph.json +deploy-progress.json +deploy-state.txt" + +# --------------------------------------------------------------------------- +# Happy path. +# --------------------------------------------------------------------------- +reset_environment +run_publisher +assert_exit_zero "happy path" +assert_step_outputs "radius-deploy-status-aks-dev-todolist" "true" +assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" + +# The sibling status files must carry distinct signals. An earlier version +# copied rad-commands-result.json into all of them, publishing identical files +# that told the reader nothing about what actually happened. +assert_progress_contract +assert_run_state "succeeded" +progress_jq '.runId == 4242' || fail "expected .runId to come from GITHUB_RUN_ID" +# shellcheck disable=SC2016 +progress_jq --arg app "todolist" '.application == $app' || + fail "expected .application to be the resolved app name" +progress_jq '.resources | length == 3' || + fail "expected all three resources in deploy-progress.json" +# Succeeded/Failed map to terminal statuses; anything else must normalize to +# in_progress so a Radius state this action has never seen cannot paint a node +# red in the canvas. +assert_resource_status "web" "success" +assert_resource_status "db" "failed" +assert_resource_status "queue" "in_progress" +progress_jq 'any(.resources[]; + .name == "queue" and .provisioningState == "Updating")' || + fail "expected the raw provisioningState to be preserved alongside status" +progress_jq 'any(.resources[]; + .name == "web" and .message == "container ready")' || + fail "expected .message to carry properties.status.message" + +assert_status_file_contains "deploy-activity.log" '"outcome":"success"' +assert_status_file_contains "deploy-controlplane.log" "# rad version" +assert_status_file_contains "deploy-controlplane.log" "# rad env list" +assert_status_file_contains "deploy-state.txt" "state=success" +assert_status_file_contains "deploy-state.txt" "application=todolist" +assert_status_file_contains "deploy-state.txt" "sha=deadbeefcafe" +assert_status_file_contains "deploy-graph.json" "Radius.Compute/containers" +assert_status_files_differ "deploy-progress.json" "deploy-activity.log" +assert_status_files_differ "deploy-progress.json" "deploy-controlplane.log" +assert_status_files_differ "deploy-activity.log" "deploy-controlplane.log" + +# --------------------------------------------------------------------------- +# A failed deploy must be reported as such at the run level. +# --------------------------------------------------------------------------- +reset_environment +printf '{"outcome":"failed","exitCode":1}\n' >"${RESULT_FILE}" +run_publisher +assert_exit_zero "failed deploy" +assert_step_outputs "radius-deploy-status-aks-dev-todolist" "true" +assert_progress_contract +assert_run_state "failed" +assert_status_file_contains "deploy-state.txt" "state=failed" +assert_status_file_contains "deploy-state.txt" "exitCode=1" + +# --------------------------------------------------------------------------- +# An unreachable control plane must still produce a well-formed progress file +# rather than a malformed one the canvas cannot parse. +# --------------------------------------------------------------------------- +reset_environment +export RAD_RESOURCE_LIST_SHOULD_FAIL=true +run_publisher +assert_exit_zero "resource list failure" +assert_step_outputs "radius-deploy-status-aks-dev-todolist" "true" +assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" +assert_progress_contract +progress_jq '.resources == []' || + fail "expected an empty .resources array when rad resource list fails" + +# --------------------------------------------------------------------------- +# Non-ASCII application names must sanitize to a valid artifact name. +# +# Sanitization runs under LC_ALL=C so the [^a-z0-9._-] class is evaluated +# bytewise. In a UTF-8 locale GNU sed leaves multi-byte characters intact, which +# yields "radius-deploy-status-aks-dev-café" - not a valid artifact name, and a +# name the canvas would never derive. C.UTF-8 does not reproduce that, so the +# scenario needs a full UTF-8 locale to be a real regression test. +# --------------------------------------------------------------------------- +reset_environment +export APP_FILE="${UNICODE_APP_FILE}" +PUBLISHER_LOCALE="$(locale -a 2>/dev/null | + grep -iE '\.utf-?8$' | grep -viE '^(C|POSIX)\.' | head -1 || true)" +if [[ -z "${PUBLISHER_LOCALE}" ]]; then + echo "note: no full UTF-8 locale installed; name test runs byte-wise only" +fi +run_publisher +assert_exit_zero "non-ASCII app name" +assert_step_outputs "radius-deploy-status-aks-dev-caf" "true" + +# Uppercase, spaces and slashes must collapse to single separators. +reset_environment +export APP_FILE="${MESSY_APP_FILE}" +run_publisher +assert_exit_zero "app name needing sanitization" +assert_step_outputs "radius-deploy-status-aks-dev-my-app-prod" "true" + +# --------------------------------------------------------------------------- +# An app name resolved from `rad app list` must warn: the canvas derives the +# artifact name only from the literal name in the app bicep and has no fallback, +# so it would never request the artifact this run publishes. +# --------------------------------------------------------------------------- +reset_environment +export APP_FILE="${NO_LITERAL_APP_FILE}" +export RAD_APP_LIST_NAME="fallback-app" +run_publisher +assert_exit_zero "rad app list fallback" +assert_step_outputs "radius-deploy-status-aks-dev-fallback-app" "true" +assert_output_contains "::warning::Application name 'fallback-app' came from" + +reset_environment +run_publisher +assert_output_lacks "came from 'rad app list'" + +# --------------------------------------------------------------------------- +# Publishing status is best-effort reporting: a failure warns, exits 0 and +# reports published=false so the upload step is skipped rather than failing an +# otherwise successful deployment. +# --------------------------------------------------------------------------- +reset_environment +export RAD_GRAPH_SHOULD_FAIL=true +run_publisher +assert_exit_zero "graph generation failure" +assert_step_outputs "" "false" +assert_output_contains "::warning::Failed to generate deployed graph" +# The graph command's stderr must be surfaced in the log but must not be left +# inside STATUS_DIR, where it would ship to users inside the artifact. +assert_output_contains "rad: application not found in environment" +assert_status_dir_contains_exactly "deploy-graph.json" + +reset_environment +export APP_FILE="${NO_LITERAL_APP_FILE}" +export RAD_APP_LIST_NAME="" +run_publisher +assert_exit_zero "unresolvable app name" +assert_step_outputs "" "false" +assert_output_contains "::warning::Could not determine application name" +assert_status_dir_contains_exactly "" + +# --------------------------------------------------------------------------- +# A missing rad-commands-result.json must not break the publish. +# --------------------------------------------------------------------------- +reset_environment +rm -f "${RESULT_FILE}" +run_publisher +assert_exit_zero "missing rad-commands-result.json" +assert_step_outputs "radius-deploy-status-aks-dev-todolist" "true" +assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" +assert_status_file_contains "deploy-activity.log" \ + "rad-commands-result.json not found" +assert_status_file_contains "deploy-state.txt" "state=unknown" + +# --------------------------------------------------------------------------- +# STATUS_DIR must be rebuilt from scratch so a previous run's files cannot be +# republished as if they belonged to this deploy. +# --------------------------------------------------------------------------- +reset_environment +mkdir -p "${STATUS_DIR}" +printf 'stale\n' >"${STATUS_DIR}/stale-from-previous-run.log" +run_publisher preserve +assert_exit_zero "stale status directory" +assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" + +# --------------------------------------------------------------------------- +# The step must run outside an Actions runner, where RUNNER_TEMP is unset. That +# is the only reason the fallback exists, and without it `set -u` aborts the +# step with an unbound variable before anything is generated. +# +# This scenario deliberately writes to the real /tmp/radius-deploy-status and +# cannot be sandboxed: the fallback path is fixed by the action, so pointing +# RUNNER_TEMP anywhere would stop exercising the fallback and make this control +# vacuous - it would then pass even against a step that had lost the `:-` +# default. A static grep for the default has the same flaw. Leave it as is; the +# directory is the action's own scratch space, the action `rm -rf`s it at the +# start of every run, and the trap above removes it here. +# --------------------------------------------------------------------------- +reset_environment +rm -rf "${FALLBACK_STATUS_DIR}" +PUBLISHER_WITHOUT_RUNNER_TEMP=true +run_publisher +assert_exit_zero "RUNNER_TEMP unset" +assert_output_lacks "RUNNER_TEMP: unbound variable" +assert_step_outputs "radius-deploy-status-aks-dev-todolist" "true" \ + "${FALLBACK_STATUS_DIR}" +assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" \ + "${FALLBACK_STATUS_DIR}" +rm -rf "${FALLBACK_STATUS_DIR}" + +# --------------------------------------------------------------------------- +# The seam between the two composite steps. These three expressions are the only +# thing connecting the generate step's outputs to the upload, and no runtime +# scenario here can exercise them. +# --------------------------------------------------------------------------- +assert_fact "generate.id" "generate" +assert_fact "generate.shell" "bash" +assert_fact "upload.if" "steps.generate.outputs.published == 'true'" +# GitHub Actions expressions are literal text here, not shell expansions. +# shellcheck disable=SC2016 +{ + assert_fact "upload.with.name" \ + '${{ steps.generate.outputs.artifact-name }}' + assert_fact "upload.with.path" \ + '${{ steps.generate.outputs.status-dir }}' + assert_fact "outputs.artifact-name.value" \ + '${{ steps.generate.outputs.artifact-name }}' +} + +# --------------------------------------------------------------------------- +# Static guard for the one behaviour the runtime scenarios cannot always reach: +# where no full UTF-8 locale is installed the name test cannot distinguish +# byte-wise sanitization from the buggy character-wise form, so require the +# LC_ALL=C prefixes to stay on the sanitization pipeline. +# --------------------------------------------------------------------------- +grep -q "LC_ALL=C tr" "${BODY_SCRIPT}" || + fail "artifact name sanitization must lowercase under LC_ALL=C" +grep -q "LC_ALL=C sed" "${BODY_SCRIPT}" || + fail "artifact name sanitization must strip invalid bytes under LC_ALL=C" + +echo "publish-deploy-status tests passed" diff --git a/build/test.mk b/build/test.mk index 3537e29bc5d..8752217e067 100644 --- a/build/test.mk +++ b/build/test.mk @@ -53,7 +53,7 @@ GOTEST_OPTS ?= GOTEST_TOOL ?= go tool gotestsum $(GOTESTSUM_OPTS) -- .PHONY: test -test: test-get-envtools test-helm test-manage-radius-installation test-update-tools-pr test-run-rad-commands-action ## Runs unit tests, excluding kubernetes controller tests +test: test-get-envtools test-helm test-manage-radius-installation test-update-tools-pr test-run-rad-commands-action test-publish-deploy-status ## Runs unit tests, excluding kubernetes controller tests KUBEBUILDER_ASSETS="$(shell $(ENV_SETUP) use -p path ${K8S_VERSION} --arch amd64)" CGO_ENABLED=1 $(GOTEST_TOOL) ./pkg/... $(GOTEST_OPTS) .PHONY: test-manage-radius-installation @@ -68,6 +68,10 @@ test-update-tools-pr: ## Tests the automated tool-update pull request workflow test-run-rad-commands-action: ## Tests application deploy parameter filtering in the run-rad-commands action @bash ./.github/extension/actions/run-rad-commands/deploy-parameters_test.sh +.PHONY: test-publish-deploy-status +test-publish-deploy-status: ## Tests deploy status publishing in the publish-deploy-status action + @bash ./.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh + .PHONY: test-compile test-compile: test-get-envtools ## Compiles all tests without running them @echo "$(ARROW) Compiling unit tests..." From a1915d25abff1c1fbdecd0e803bcb53beff5a927 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:18:31 -0400 Subject: [PATCH 4/7] Address Copilot review feedback README: step 14 still named deploy-progress.log; the action writes deploy-progress.json. Run state: OUTCOME=unknown is the sentinel for a missing or unreadable result file and was being reported as a failed run. It now maps to in_progress. Real non-success outcomes from run-rad-commands (command_failed, disallowed_command) still map to failed, so a broken deploy is not softened into merely unfinished. Escaping: the fallback-app-name ::warning:: interpolated APP_NAME/APP_FILE/ARTIFACT_NAME directly. '%' and CR/LF are workflow-command metacharacters and can corrupt or truncate the annotation, so they are now escaped the same way deploy-parameters.sh does it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52550778-eb0a-45eb-bfdf-90374a382b5f Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .github/extension/README.md | 2 +- .../actions/publish-deploy-status/action.yml | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index 92c2d6d63ad..b8961d509fc 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -105,7 +105,7 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 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. **Register custom types and apply custom recipe pack.** When the app's `.radius/` folder carries a `custom-types.yaml` file, the shared `apply-custom-recipe-packs` action registers those resource types with `rad resource-type create --from-file` (skipped when absent). When it carries a `custom-recipe-pack.bicep` file, the action snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 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. Before deploying the app, the shared action compiles its Bicep file once and reads the declared ARM parameters. It passes each extension-generated parameter only when the template declares it: `image` (the workflow input, defaulting to `github.sha`), `registryUsername` (`github.actor`), and `registryPassword` (the built-in `GITHUB_TOKEN`). Caller-configured application parameters from the `RADIUS_DEPLOY_PARAMS` secret remain strict and are passed unchanged. The registry parameters feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), when present, so the containerImages recipe's in-pod BuildKit can push the application image. Secret values are passed via an argv array and never written into the recorded command string. -14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.log` — per-resource provisioning state, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt`) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`). It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort: if graph generation fails the action warns and leaves the deployment result unchanged. +14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.json` — per-resource status the canvas paints the graph from, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt`) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`). It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort: if graph generation fails the action warns and leaves the deployment result unchanged. Workflow artifacts are the transport because the REST API can read them **while the run is still in progress** (`GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`), which is what lets the canvas show deployment state as it happens; `GET /repos/{owner}/{repo}/actions/artifacts?name=` finds the newest one later without knowing the run. They also require no extra registry, no `packages: write` permission, and no name derivation duplicated between this action and the canvas reader. 15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the state archive — the OCI-backed archive by default (pushed to GHCR, selected by the `RADIUS_STATE_*` variables), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. diff --git a/.github/extension/actions/publish-deploy-status/action.yml b/.github/extension/actions/publish-deploy-status/action.yml index e182684d837..c0b6e620d85 100644 --- a/.github/extension/actions/publish-deploy-status/action.yml +++ b/.github/extension/actions/publish-deploy-status/action.yml @@ -60,6 +60,18 @@ runs: } >> "$GITHUB_OUTPUT" } + # Escape workflow-command metacharacters before interpolating a value into + # a ::warning::/::error:: annotation: '%' is the escape character and CR/LF + # terminate the command, so an unescaped value can corrupt or truncate the + # annotation. Same treatment as deploy-parameters.sh in run-rad-commands. + escape_workflow_command() { + local escaped="$1" + escaped=${escaped//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + printf '%s' "$escaped" + } + # Resolve the app name from the app bicep file first (same pattern used # elsewhere in the run-rad-commands action), with a fallback to `rad app # list` when the file doesn't contain a simple literal name. @@ -101,9 +113,17 @@ runs: # Run-level state, using the vocabulary the canvas expects. `in_progress` # is only reachable once mid-run uploads exist; today this step runs after - # `rad deploy` has already returned, so the state is always terminal. + # `rad deploy` has already returned, so the state is normally terminal. + # + # "unknown" is the sentinel for a missing or unreadable result file, and is + # the one case where we genuinely do not know the outcome - report it as + # neutral rather than claiming a failure the deploy may not have had. Real + # non-success outcomes from run-rad-commands (command_failed, + # disallowed_command) must still map to failed, or a broken deploy would be + # reported as merely unfinished. case "$OUTCOME" in - success|succeeded) RUN_STATE="succeeded" ;; + succeeded|success) RUN_STATE="succeeded" ;; + unknown) RUN_STATE="in_progress" ;; *) RUN_STATE="failed" ;; esac @@ -194,7 +214,7 @@ runs: # name resolved that way publishes under a name it will never request. # Say so explicitly instead of leaving an empty Deployed tab to debug. if [ "$APP_NAME_FROM_LITERAL" -eq 0 ]; then - echo "::warning::Application name '${APP_NAME}' came from 'rad app list', not a literal name in ${APP_FILE}. Publishing as '${ARTIFACT_NAME}', but the canvas derives the name from the bicep literal and will not find this artifact. Set a literal name in the app bicep." + echo "::warning::Application name '$(escape_workflow_command "$APP_NAME")' came from 'rad app list', not a literal name in $(escape_workflow_command "$APP_FILE"). Publishing as '$(escape_workflow_command "$ARTIFACT_NAME")', but the canvas derives the name from the bicep literal and will not find this artifact. Set a literal name in the app bicep." fi emit_outputs "$ARTIFACT_NAME" true From 9af9bded6812537165075a83bd97ba0bf4de924a Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:48:57 -0400 Subject: [PATCH 5/7] Fix RUNNER_TEMP-unset scenario failing on a real runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario that proves the \\\ fallback relied on leaving RUNNER_TEMP unexported. That is not enough: on a GitHub runner RUNNER_TEMP is already an exported environment variable, and assigning to an exported name keeps the export attribute, so the sandbox value reached the step and the fallback was never exercised. It passed locally only because RUNNER_TEMP is absent from a developer shell. CI failed with: expected '/tmp/radius-deploy-status', got '/tmp/tmp.XXXX/runner-temp/radius-deploy-status'. Use \nv -u RUNNER_TEMP\ so the variable is removed from the child's environment regardless of the parent's. Verified both ways locally: with RUNNER_TEMP exported (reproducing CI) and without. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52550778-eb0a-45eb-bfdf-90374a382b5f Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .../publish-deploy-status_test.sh | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh index cc78578b104..d5d514225fc 100644 --- a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh +++ b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh @@ -278,17 +278,23 @@ run_publisher() { # Keep the action's `mktemp` inside the sandbox. TMPDIR="${PUBLISHER_TMP}" export PATH TMPDIR GITHUB_OUTPUT - # RUNNER_TEMP is a plain variable here, so leaving it unexported is - # enough to hide it from the step. - if [[ "${PUBLISHER_WITHOUT_RUNNER_TEMP}" != "true" ]]; then - export RUNNER_TEMP - fi if [[ -n "${PUBLISHER_LOCALE}" ]]; then LC_ALL="${PUBLISHER_LOCALE}" LANG="${PUBLISHER_LOCALE}" export LC_ALL LANG fi - bash "${BODY_SCRIPT}" + if [[ "${PUBLISHER_WITHOUT_RUNNER_TEMP}" == "true" ]]; then + # Not exporting RUNNER_TEMP is NOT enough to hide it. On a GitHub + # runner RUNNER_TEMP is already an exported environment variable, and + # assigning to an exported name keeps the export attribute - so the + # sandbox value would reach the step anyway and the fallback would + # never be exercised. This passed locally and failed in CI for exactly + # that reason. Remove it from the child's environment explicitly. + env -u RUNNER_TEMP bash "${BODY_SCRIPT}" + else + export RUNNER_TEMP + bash "${BODY_SCRIPT}" + fi ) >"${PUBLISHER_LOG}" 2>&1 PUBLISHER_EXIT=$? set -e From c265c88e41a289b6c19750f6485b7ebc3f0f43b2 Mon Sep 17 00:00:00 2001 From: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:04:54 -0400 Subject: [PATCH 6/7] Correct in_progress semantics and document the artifact payload The `unknown` outcome branch maps to `in_progress`, which contradicted the comment above it claiming that state was unreachable until mid-run uploads exist. A finished run with a missing or unreadable rad-commands-result.json reports `in_progress` today. Describe it as "no verdict" rather than "still running", which is what the branch actually means. README step 14 documented only the graph-failure path as best-effort, but a `rad resource list` failure still publishes, with run-level state and an empty resources array. That is the case most likely to produce a plausible-looking but empty graph, so state it explicitly. Also gloss `deploy-state.txt` (the only sibling without one), record the 30-day retention that bounds how long the Deployed tab keeps working, and explain why `deploy-state.txt` says `state=success` while `deploy-progress.json` says `succeeded`. Rename two uses of "seam" in the test header comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9a4a03ec-7d57-478c-9a96-3a2f56078546 Signed-off-by: Brooke Hamilton <45323234+brooke-hamilton@users.noreply.github.com> --- .github/extension/README.md | 6 +++++- .../extension/actions/publish-deploy-status/action.yml | 10 ++++++---- .../publish-deploy-status_test.sh | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/extension/README.md b/.github/extension/README.md index b8961d509fc..8fb738b0bfc 100644 --- a/.github/extension/README.md +++ b/.github/extension/README.md @@ -105,7 +105,11 @@ The dispatcher routes to the matching provider workflow, which runs on `ubuntu-l 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. **Register custom types and apply custom recipe pack.** When the app's `.radius/` folder carries a `custom-types.yaml` file, the shared `apply-custom-recipe-packs` action registers those resource types with `rad resource-type create --from-file` (skipped when absent). When it carries a `custom-recipe-pack.bicep` file, the action snapshots the recipe-pack IDs before and after `rad deploy`ing that pack to identify the newly-created pack(s), reads the environment's existing `recipePacks` with `rad env show --preview`, and runs `rad env update --recipe-packs --preview` so the environment keeps the default provider pack and gains the custom pack — without pulling in unrelated packs the control plane may know about (skipped when absent). When neither file exists this step is a no-op and the default pack stays in place. 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. Before deploying the app, the shared action compiles its Bicep file once and reads the declared ARM parameters. It passes each extension-generated parameter only when the template declares it: `image` (the workflow input, defaulting to `github.sha`), `registryUsername` (`github.actor`), and `registryPassword` (the built-in `GITHUB_TOKEN`). Caller-configured application parameters from the `RADIUS_DEPLOY_PARAMS` secret remain strict and are passed unchanged. The registry parameters feed the app's `Radius.Security/secrets` resource (`radius-ghcr-registry-creds`), when present, so the containerImages recipe's in-pod BuildKit can push the application image. Secret values are passed via an argv array and never written into the recorded command string. -14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.json` — per-resource status the canvas paints the graph from, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt`) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`). It publishes on failed deploys too, since that is when the Deployed graph is most useful. Publishing is best-effort: if graph generation fails the action warns and leaves the deployment result unchanged. +14. **Publish deployed graph/status artifact.** Whenever the run is not cancelled (`if: !cancelled()`), the shared `publish-deploy-status` action runs `rad app graph --application --preview --include-icons --output json` against the live control plane and publishes `deploy-graph.json` plus sibling status files (`deploy-progress.json` — per-resource status the canvas paints the graph from, `deploy-activity.log` — the rad command result envelope, `deploy-controlplane.log` — control-plane health, `deploy-state.txt` — a flat `key=value` summary of the run, not read by the canvas) as a **workflow artifact** named `radius-deploy-status--` (lowercased, with characters outside `[a-z0-9._-]` collapsed to `-`), retained for 30 days. It publishes on failed deploys too, since that is when the Deployed graph is most useful. + + Publishing is best-effort and never changes the deployment result. When the application name cannot be resolved, or `rad app graph` fails, the action warns, publishes nothing, and exits 0. When only `rad resource list` fails, it still publishes: `deploy-progress.json` carries the run-level state with an empty `resources` array, so the Deployed tab shows the run rather than nothing. + + `deploy-progress.json` is the authority on deploy state and the only status file the canvas reads. Its `state` uses `succeeded`/`failed`/`in_progress`, where `in_progress` also covers "no verdict" — it is what a finished run reports when `rad-commands-result.json` is missing or unreadable, since claiming a failure the deploy may not have had is worse than reporting no outcome. Each entry in `resources[]` carries both the raw `provisioningState` and a normalized `status`; an unrecognized provisioning state normalizes to `in_progress`, never `failed`, so a Radius state this action has not seen cannot paint a node red. `deploy-state.txt` predates `deploy-progress.json` and uses its own older vocabulary (`state=success`, mirroring the rad command outcome), so the two files can describe the same successful run with different words. That is deliberate; `deploy-progress.json` is the one to trust. Workflow artifacts are the transport because the REST API can read them **while the run is still in progress** (`GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`), which is what lets the canvas show deployment state as it happens; `GET /repos/{owner}/{repo}/actions/artifacts?name=` finds the newest one later without knowing the run. They also require no extra registry, no `packages: write` permission, and no name derivation duplicated between this action and the canvas reader. 15. **Persist state (`rad shutdown`).** Backs the control-plane databases and Terraform recipe-state Secrets up to the state archive — the OCI-backed archive by default (pushed to GHCR, selected by the `RADIUS_STATE_*` variables), or the `radius-state` git orphan branch when `RADIUS_STATE_BACKEND=git`. This runs even when the deploy fails (`if: always()`), so a partially-applied Terraform run is not lost. diff --git a/.github/extension/actions/publish-deploy-status/action.yml b/.github/extension/actions/publish-deploy-status/action.yml index c0b6e620d85..0aa101a202c 100644 --- a/.github/extension/actions/publish-deploy-status/action.yml +++ b/.github/extension/actions/publish-deploy-status/action.yml @@ -111,13 +111,15 @@ runs: EXIT_CODE=$(jq -r '.exitCode // 0' "$RESULT_FILE" 2>/dev/null || echo "0") fi - # Run-level state, using the vocabulary the canvas expects. `in_progress` - # is only reachable once mid-run uploads exist; today this step runs after - # `rad deploy` has already returned, so the state is normally terminal. + # Run-level state, using the vocabulary the canvas expects. This step runs + # after `rad deploy` has already returned, so the state is terminal + # whenever the result file is readable. # # "unknown" is the sentinel for a missing or unreadable result file, and is # the one case where we genuinely do not know the outcome - report it as - # neutral rather than claiming a failure the deploy may not have had. Real + # neutral rather than claiming a failure the deploy may not have had. That + # makes `in_progress` reachable today, on a run that has already finished: + # it means "no verdict", not "still running". Real # non-success outcomes from run-rad-commands (command_failed, # disallowed_command) must still map to failed, or a broken deploy would be # reported as merely unfinished. diff --git a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh index d5d514225fc..4e9e40d24cc 100644 --- a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh +++ b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh @@ -11,7 +11,7 @@ # and sets step outputs, and an actions/upload-artifact step gated on those # outputs. Only the first is executable here, so the expressions wiring the two # together are checked structurally against the parsed YAML instead - nothing -# else covers that seam. +# else covers them. # # `rad` is stubbed. There is no runner, no network and no artifact upload: # RUNNER_TEMP, GITHUB_OUTPUT and TMPDIR are all redirected into a sandbox. @@ -680,8 +680,8 @@ assert_status_dir_contains_exactly "${EXPECTED_STATUS_FILES}" \ rm -rf "${FALLBACK_STATUS_DIR}" # --------------------------------------------------------------------------- -# The seam between the two composite steps. These three expressions are the only -# thing connecting the generate step's outputs to the upload, and no runtime +# The wiring between the two composite steps. These three expressions are the +# only thing connecting the generate step's outputs to the upload, and no runtime # scenario here can exercise them. # --------------------------------------------------------------------------- assert_fact "generate.id" "generate" From 5fe228ad14f10fbb73ef7a9c25f0bcc158b38780 Mon Sep 17 00:00:00 2001 From: Nicole James Date: Tue, 11 Aug 2026 15:16:27 -0700 Subject: [PATCH 7/7] Keep deploy status upload best effort Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Nicole James --- .github/extension/actions/publish-deploy-status/action.yml | 1 + .../actions/publish-deploy-status/publish-deploy-status_test.sh | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/extension/actions/publish-deploy-status/action.yml b/.github/extension/actions/publish-deploy-status/action.yml index 0aa101a202c..9088961cba6 100644 --- a/.github/extension/actions/publish-deploy-status/action.yml +++ b/.github/extension/actions/publish-deploy-status/action.yml @@ -228,6 +228,7 @@ runs: # upload when nothing was generated keeps that property here. - name: Upload deploy status artifact if: steps.generate.outputs.published == 'true' + continue-on-error: true uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ steps.generate.outputs.artifact-name }} diff --git a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh index 4e9e40d24cc..8d592e97c9d 100644 --- a/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh +++ b/.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh @@ -173,6 +173,7 @@ if not upload.get("uses", "").startswith("actions/upload-artifact@"): facts = { "generate.id": generate.get("id", ""), "generate.shell": generate.get("shell", ""), + "upload.continue-on-error": upload.get("continue-on-error", ""), "upload.if": upload.get("if", ""), "upload.uses": upload.get("uses", ""), "upload.with.name": upload.get("with.name", ""), @@ -686,6 +687,7 @@ rm -rf "${FALLBACK_STATUS_DIR}" # --------------------------------------------------------------------------- assert_fact "generate.id" "generate" assert_fact "generate.shell" "bash" +assert_fact "upload.continue-on-error" "true" assert_fact "upload.if" "steps.generate.outputs.published == 'true'" # GitHub Actions expressions are literal text here, not shell expansions. # shellcheck disable=SC2016