Publish deploy status as a workflow artifact - #12628
Conversation
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-<environment>-<app>
The canvas reads it two ways - `GET /actions/runs/{run_id}/artifacts` during a
run, and `GET /actions/artifacts?name=<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>
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>
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>
There was a problem hiding this comment.
Pull request overview
This PR changes the deploy-status producer used by the GitHub Actions “run-rad-commands” workflows: it switches transport from GHCR/OCI to GitHub Actions workflow artifacts and introduces a CI-enforced unit test harness to keep the cross-repo deploy-status contract stable.
Changes:
- Update
publish-deploy-statusto generatedeploy-progress.jsonand upload the status bundle viaactions/upload-artifactunder a deterministic artifact name. - Add a bash-based unit test harness for the composite action and wire it into
make testvia a newtest-publish-deploy-statustarget. - Remove
RADIUS_GRAPH_TAGfrom the provider workflows and update extension documentation to describe the new transport.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
.github/extension/actions/publish-deploy-status/action.yml |
Switch deploy-status publishing to workflow artifacts; generate deploy-progress.json; emit step outputs for upload gating. |
.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh |
Add unit test harness that executes the action’s real run: block and asserts the interface/behavior. |
build/test.mk |
Add test-publish-deploy-status target and include it in the main test prerequisite list. |
.github/extension/run-rad-commands-azure.yml |
Stop passing RADIUS_GRAPH_TAG into the workflow environment. |
.github/extension/run-rad-commands-aws.yml |
Stop passing RADIUS_GRAPH_TAG into the workflow environment. |
.github/extension/README.md |
Document workflow-artifact transport and update environment-variable guidance. |
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>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/extension/actions/publish-deploy-status/action.yml:98
- The mktemp file used to capture
rad app graphstderr is never removed. On a long-lived/self-hosted runner this can leak temp files over time. Remove the file after printing it (both success and failure paths).
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 "$GRAPH_STDERR" || true
emit_outputs "" false
.github/extension/actions/publish-deploy-status/action.yml:177
--argjson resources "$RESOURCES_JSON"embeds the full resources payload into the jq command line. For apps with many resources this can hit OS argv length limits and make jq fail, which would fail the whole step underset -e(turning a reporting concern into a deploy failure). Pass the JSON via a file/stream instead (e.g., jq--slurpfile).
jq -n \
--argjson resources "$RESOURCES_JSON" \
--arg application "$APP_NAME" \
--arg environment "$ENVIRONMENT" \
--argjson runId "${GITHUB_RUN_ID:-0}" \
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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/extension/actions/publish-deploy-status/action.yml:177
- The action is intended to be best-effort reporting (warn + exit 0 on failures), but the unguarded
jq -n ... > deploy-progress.jsonwill terminate the step underset -eif jq is missing or errors, which can fail an otherwise successful deployment. Wrap this write in a failure check that emitspublished=falseand exits 0.
jq -n \
--argjson resources "$RESOURCES_JSON" \
--arg application "$APP_NAME" \
--arg environment "$ENVIRONMENT" \
--argjson runId "${GITHUB_RUN_ID:-0}" \
.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh:200
sed -i -Eis GNU-sed specific and will fail on BSD sed (common on macOS), which conflicts with the goal of running this test on a developer machine. Use a portable-iform (backup suffix) and delete the backup file.
sed -i -E "s|^RESULT_FILE=.*|RESULT_FILE=${RESULT_FILE}|" "${BODY_SCRIPT}"
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #12628 +/- ##
==========================================
- Coverage 54.18% 54.18% -0.01%
==========================================
Files 770 770
Lines 51061 51061
==========================================
- Hits 27668 27666 -2
- Misses 20789 20790 +1
- Partials 2604 2605 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/extension/actions/publish-deploy-status/action.yml:158
- The jq mapping can error if
.properties.statusis not an object (GenericResourcepropertiesis untyped), which would cause the whole resource normalization to fall back to an emptyresourcesarray. Use jq’s optional operator so a non-objectstatusdoesn’t break the entire payload.
message: (.properties.status.message // "")
.github/extension/actions/publish-deploy-status/action.yml:95
GRAPH_STDERRis created withmktempbut never removed. Even though it’s outsideSTATUS_DIR(so it won’t be uploaded), it will leak a temp file on every run; add a trap to clean it up on all exit paths.
GRAPH_STDERR=$(mktemp)
if ! rad app graph --application "$APP_NAME" --preview --include-icons --output json > "$STATUS_DIR/deploy-graph.json" 2> "$GRAPH_STDERR"; then
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Nicole James <nicolej@microsoft.com>
b1db521 to
5fe228a
Compare
Radius functional test overviewClick here to see the test run details
Test Status⌛ Building Radius and pushing container images for functional tests... |
Description
Publishes deploy status as a GitHub Actions workflow artifact instead of an OCI artifact in GHCR, and adds an automated test for the
publish-deploy-statusaction.The canvas needs deployment state it can read while a deploy is running. GHCR can't provide that — the artifact is pushed once, at the end. Workflow artifacts can:
GET /repos/{o}/{r}/actions/runs/{run_id}/artifactsis readable mid-run, andGET /repos/{o}/{r}/actions/artifacts?name=<name>returns newest-first repo-wide so a fresh canvas session finds the last deploy without knowing a run id.Scope
Ships: correct deploy status after every deploy, including failed ones; the
deploy-progress.jsoncontract; the test.Does not ship: live per-resource progress. This step runs after
rad deployreturns, andactions/upload-artifactis auses:step, so a composite step cannot invoke it mid-execution. A background poller inside the deploy step would have to write directly to the artifact REST service usingACTIONS_RUNTIME_TOKEN/ACTIONS_RESULTS_URL. That's follow-up work.sequenceis in the contract for that work and is always1today, since there is exactly one upload per run — the consumer's merge logic won't need to change when mid-run uploads land.state: in_progressis not a placeholder: it is reachable today, with the "no verdict" meaning described under What changed rather than as a live-progress signal.What gets published
One artifact named
radius-deploy-status-<environment>-<app>(lowercased, characters outside[a-z0-9._-]collapsed to-), retained 30 days, containing five files:deploy-graph.jsonrad app graphoutput — what the Deployed tab rendersdeploy-progress.jsondeploy-progress.log. The authority on deploy state, and the only status file the canvas readsdeploy-activity.logdeploy-controlplane.lograd versionandrad env listat publish timedeploy-state.txtkey=valuerun summary. Predates this work; not read by the canvasWhat changed
1. Transport: GHCR → workflow artifacts (
publish-deploy-status/action.yml)oras pushis replaced byactions/upload-artifact. This removes three problems rather than working around them:packages: writeneeded, so the pre-flight check that validated a different package than the one written to no longer matters.RADIUS_GRAPH_TAGis removed.RADIUS_GRAPH_REGISTRYis kept: despite the similar name it is read by theradCLI itself (cmd/rad/cmd/root.go:262) to select the modeled graph archive backend, and removing it would breakrad startup. With deploy status no longer using it, that variable has exactly one meaning again.2.
deploy-progress.jsonschemaVersion,application,environment,runId,sequence,updatedAt,state, andresources[]. Each resource carries both the rawprovisioningStateand a normalizedstatus: the producer owns the mapping because it knows the Radius version, while emitting the raw value lets the consumer recover if that mapping goes stale.Two normalization rules matter to the reader:
in_progress, neverfailed. A Radius state this action hasn't seen must not paint a node red.stateissucceeded/failed/in_progress, wherein_progressalso means no verdict — it is what a finished run reports whenrad-commands-result.jsonis missing or unreadable. Reporting a failure the deploy may not have had is worse than reporting no outcome. This is reachable today, not only once mid-run uploads exist.3. Publishing is best-effort and never fails a deploy
Every path exits 0. Which paths publish differs, and the distinction matters to anyone debugging an empty tab:
rad app graphfails → warn, publish nothing,published=false.rad resource listfails → still publishes, with run-level state and an emptyresourcesarray. The tab shows the run rather than nothing.4. Structural changes to support the above
The action is now two composite steps:
generate(writes files, sets outputs) and auses:upload gated onsteps.generate.outputs.published == 'true'.STATUS_DIRis a deterministic${RUNNER_TEMP:-/tmp}/radius-deploy-statusrather thanmktemp -d, because the upload step must reference the path; the:-fallback is also what makes the step runnable outside a runner, and therefore testable.rad app graphstderr goes to a temp file outsideSTATUS_DIR, since the whole directory ships in the artifact. Step outputs are emitted exactly once per exit path rather than written up front and overwritten — duplicate keys in$GITHUB_OUTPUTmake the winning value an implementation detail, and guessing wrong fails silently in both directions.Tests
Adds a unit test for the action. It stubs
rad, pointsRUNNER_TEMP/GITHUB_OUTPUTat a temp sandbox, and asserts on the files and step outputs the action produces. It executes the realrun:block extracted fromaction.ymlrather than a copy, so it cannot drift from the action. No Docker, network, registry, cluster, credentials, orradbinary; runs in a couple of seconds.In CI it runs on every pull request to
mainthrough the existing Unit Tests workflow →make test, and a failure surfaces as a failed Run Unit Tests check. Same pattern asdeploy-parameters_test.shinrun-rad-commands/.Each of the 11 guarded behaviours was individually reverted in
action.ymland the test confirmed to fail before the action was restored. Three of those controls guard the cross-repo interface: a renamedresources[]field, an unknown state normalizing tofailed, and a bumpedschemaVersion.shellcheck -s bashis clean.The transport itself was exercised separately in a throwaway repo running the real
actions/upload-artifact: an artifact was readable over the REST API while the run was still in progress, an environment namedMy Env/Prodproduced a name GitHub accepted, both consumer lookups worked, anddeploy-progress.jsonsurvived upload and download unchanged.Merge ordering
This must land close to the matching
ai-extensionschange. The canvas reader currently pulls from GHCR; once this merges, the producer stops writing there. The reader change (workflow-artifact transport, removal of the deadradius-deploy-statusorphan-branch reads) is in progress onbrooke-hamilton-deployed-graph-artifacts.Known gaps
radhas been verified end to end. No real deploy has run, sorad app graphandrad resource listoutput shapes remain assumed, and a real failed deploy has never produced a payload. That is the remaining risk and it is confined to theradinteraction. Ifrad's output surprises us, the action warns and skips publishing rather than failing the deploy, so the worst case is an empty Deployed tab — today's behaviour, not a regression.C.UTF-8, so the test selects a full UTF-8 locale fromlocale -aand prints anote:line if none exists. If that note appears in CI, only the staticLC_ALL=Cguard is live.ubuntu-24.04hasen_US.utf8, so it shouldn't.deploy-progress.jsonis asserted producer-side only. A one-sided change in the consumer still fails silently; a fixture-based test inai-extensionswould close the loop, and the fixture has been handed over.deploy-state.txtreportsstate=successwhiledeploy-progress.jsonreports"state": "succeeded"— two vocabularies in one artifact.deploy-state.txtpredates this work and something may parse its current values, so it is left alone deliberately. Documented in README step 14.File change summary
.github/extension/actions/publish-deploy-status/action.ymldeploy-progress.json, two-step structure, deterministicSTATUS_DIR, single-write step outputs.github/extension/actions/publish-deploy-status/publish-deploy-status_test.shmake testin CIbuild/test.mktest-publish-deploy-statustarget, added totest:prerequisites.github/extension/run-rad-commands-azure.ymlRADIUS_GRAPH_TAG.github/extension/run-rad-commands-aws.ymlRADIUS_GRAPH_TAG.github/extension/README.md