Skip to content

Bound container image builds server-side, and the deploy step in CI - #12746

Draft
nellshamrell wants to merge 8 commits into
mainfrom
nellshamrell-special-potato
Draft

Bound container image builds server-side, and the deploy step in CI#12746
nellshamrell wants to merge 8 commits into
mainfrom
nellshamrell-special-potato

Conversation

@nellshamrell

@nellshamrell nellshamrell commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #12595

Problem

Deploy workflow runs could consume up to GitHub's 360-minute job default before failing. There was no timeout-minutes anywhere in .github/extension/, so a pathologically slow build burned hours of runner time with no bound. Observed runs hit 80–120 minutes; a healthy 9-image deploy takes ~5.

The underlying cause is established and lives outside this repo — see @sylvainsf's RCA and @nicolejms's arm build.md on the issue. The Radius.Compute/containerImages recipe defaults to dual-arch, so the linux/arm64 half builds under QEMU emulation on an amd64 runner, on a BuildKit sidecar pinned to max-parallelism = 1.

#12640 (architecture-aware builds) is the cause-side fix and is already merged. It is opt-in — an empty MODE or an unsubstituted placeholder disables it — and it cannot help a genuinely mixed-arch cluster. It also bounds nothing. This PR is the backstop for every path it doesn't cover.

Change

Two timeouts, at two layers.

In the Radius deployment (pkg/recipes/driver/bicep): a 3-hour budget on a Radius.Compute/containerImages build. This is the layer @sk593 asked for — see the discussion below.

In CI (.github/extension/): timeout-minutes on the Run rad commands step in both provider workflow templates, defaulting to 30 and overridable per environment with RADIUS_DEPLOY_TIMEOUT_MINUTES. This stays as the outer failsafe.

Workflow: timeout-minutes on the deploy step

Why the step and not the job

A job-level timeout cancels the job. Cancellation skips steps guarded by if: ${{ !cancelled() }}, which means Publish deployed graph and status is lost — removing the Deployed tab exactly when a deploy hung and you most need to see which resource broke. A job-level bound would also fold the setup steps into the same budget, so the time actually available to the deploy would vary with how long setup took.

A step-level timeout produces a failed step instead, so publish and teardown both run normally on the job's remaining budget.

Correction. An earlier version of this description claimed a job-level timeout also skips the if: always() teardown. Measured on a runner, that is false: a 240-second teardown ran to completion under a job-level timeout, so rad shutdown would still persist state. Step-level placement still stands, but on the narrower ground above.

Why the override is validated

A Resolve deploy timeout step validates RADIUS_DEPLOY_TIMEOUT_MINUTES before it reaches timeout-minutes. This is not defensive boilerplate — per StepsRunner.cs:

var timeoutMinutes = 0;
try   { timeoutMinutes = EvaluateStepTimeout(...); }
catch { ...Error("An error occurred..."); }   // recorded, NOT fatal
if (timeoutMinutes > 0) { SetTimeout(...); }  // else: no bound at all

An unchecked 0, negative, or malformed value would therefore silently restore the unbounded behavior this PR exists to prevent. Values are constrained to 1–330 and normalized to base 10 (so 030 isn't read as octal and then rejected by fromJSON). The ceiling is exactly that — a ceiling, not a guarantee: setup steps draw on the same 360-minute budget, so a value near 330 can still leave the job short for teardown. That specific number is a judgment call, happy to adjust.

Server-side: a timeout on the containerImages build

@sk593 asked that the Radius deployment itself be bounded, not just the workflow. That was right, and the layering turned out to be worse than "no timeout".

Timeouts that exist today, innermost to outermost:

Layer Timeout Fires first?
containerImages build script none
dynamic-rp async operation (pkg/dynamicrp/frontend/routes.go:68) 24h no
Deployment engine, extensible-resource job 4h yes, today
Deployment engine, deployment job 7d no
rad deploy client wait none
Workflow step (this PR) 30m in CI only

The layers are inverted: dynamic-rp's 24h sits outside the deployment engine's 4h, so no Radius-owned timeout can ever fire. Every hang is bounded by the deployment engine, in a different repo, at 4 hours. (Both DE values are get-only expression-bodied C# properties, so neither is configurable.)

This adds the innermost bound: a 3-hour budget derived at the top of executeImageBuild, so it covers retrieving registry credentials from the target cluster as well as the build script itself. 3h rather than something aggressive because the observed pathological build ran 1–2 hours — a 30-minute server-side default would fail it outright. The workflow's 30m already handles the CI-cost question; this one exists to stop a build occupying a deployment indefinitely, and sits under the DE's 4h so the failure is reported by Radius.

It is hardcoded, not configurable. deploy/Chart/templates/dynamic-rp/configmaps.yaml:64-66 hardcodes the bicep: block, so a Go-only BicepOptions field would be a knob no Helm user could actually set.

A deadline alone would have failed open

runScript drains stdout and stderr before calling cmd.Wait(), and those drains end only at EOF. EOF needs every write end of the pipe closed. Killing the process group cannot reach a descendant that left it via setsid while holding the inherited pipes — so the drains never return, and the deadline never gets to do anything. cmd.WaitDelay does not help, because it only takes effect inside Wait.

This is the same fail-open class this PR already hit once: a timeout-minutes that evaluates to empty is silently ignored by the runner. So it was measured rather than assumed. With the forced close disabled, the new regression test blocks for the script's full 60 seconds; with it, 0.7s.

The fix closes the pipe read ends after a grace period once the context is done. Closing an *os.File while another goroutine is blocked reading it is safe (poll.FD refcounting) — the pending read returns fs.ErrClosed, which is filtered per-stream so a genuine failure on the other stream is still reported.

Error attribution

The async worker cancels operations via opCancel() rather than letting a deadline expire, so it surfaces as context.Canceled, not DeadlineExceeded — meaning DeadlineExceeded alone cannot distinguish "we stopped this build" from "the caller went away". The budget therefore uses context.WithTimeoutCause with a private sentinel, and the message reports elapsed time and the limit. Note the sentinel is internal: recipes.NewRecipeError flattens to err.Error() and defines Is as type identity, so what reaches the user is the message, not a typed error. Deliberately not changing that shared type here.

Scope of the server-side timeout

Bounds the containerImages post-deployment build hook only. Not covered: recipe OCI retrieval (bicep.go:99-115) and the nested Bicep deployment poll (bicep.go:170-190). On Windows containerimages_windows.go is a no-op stub, so a timeout there kills only the shell, not the process tree — pre-existing, not addressed here.

Tests

go test -race on Linux, run 3× for flakiness. New cases: forced close unblocks a drain held by an escaped descendant (verified to hang without the fix); the build timeout fires; an inherited cancellation is not misreported as the build timeout; a build inside the budget still succeeds. The regression test waits for the detached descendant to actually be holding the pipes before cancelling, so it cannot pass vacuously, and reaps that PID on cleanup. POSIX-only paths skip on Windows and where setsid is unavailable.

Scope and limits (workflow layer)

  • A mitigation, not a cure. It converts a multi-hour hang into a bounded failure. It does not make builds faster. The workflow timeout itself does not cancel server-side work — the build timeout above is what does that.
  • Bounds the deploy phase, not the workflow. Publish and teardown remain on the 360-minute job default.
  • Behavior change: a deploy legitimately exceeding 30 minutes now fails. RADIUS_DEPLOY_TIMEOUT_MINUTES is the escape hatch. If you reach for it, first check whether the time is going into emulated cross-arch builds — raising the timeout hides that cost rather than removing it.
  • The rad-commands-result artifact is not guaranteed on timeout, since the composite action's own if: always() upload shares the deadline. Teardown still collects and uploads Radius logs outside the timed step.
  • A timed-out deploy is currently published as succeeded. Measured on a real runner: the composite's trap write_result EXIT does fire when the step is killed, and writes the optimistic OVERALL_OUTCOME="succeeded" seed that is never re-affirmed on success. This is pre-existing, but this PR makes the path routine. The fix belongs in the shared composite action (where it also reaches already-generated repos via the pinned ref), so it is tracked separately in Deploy workflow publishes succeeded when rad deploy is killed by a step timeout #12756 rather than widening this PR.
  • delete-azure.yml / delete-aws.yml have their own long-running steps and remain unbounded — deliberately out of scope.

Testing (workflow layer)

New deploy-timeout_test.sh, wired in as make test-deploy-timeout and included in the aggregate test target that CI runs.

The assertions are structural rather than whole-file greps, because timeout-minutes nested under with: is valid YAML that GitHub silently treats as an undeclared action input — a grep for the key would pass on exactly the mistake worth catching. Indentation is derived from the file, and step ordering plus the teardown's if: always() are pinned.

Verified by mutation testing — 12 ways of breaking the wiring, each caught:

Mutation Caught
timeout-minutes nested under with:
key deleted
validation bypassed (raw vars)
wrong step id referenced
upper / lower range check dropped
base-10 normalization dropped
numeric regex loosened
default changed to 0
teardown loses if: always()
steps reordered
id: removed
$GITHUB_OUTPUT write deleted

Also: validation logic exercised across "", 0, -5, 030, abc, 45m, 330, 331, 9999; both templates parsed structurally to confirm placement; shellcheck and markdownlint clean.

Verified on a real runner

The static analysis above proves the wiring is present; it does not prove the runner honors it. So the behavior was measured on ubuntu-latest with RADIUS_DEPLOY_TIMEOUT_MINUTES set as a real repository variable, using the Resolve deploy timeout step copied verbatim from this PR.

Scenario Result
Override set to 1, deploy sleeps 300s Step failed at ~72s; publish and teardown both ran ✅
Value 0 (validation active) Resolve step failed, deploy step skipped, teardown ran ✅
timeout-minutes: 0 (validation bypassed) Ran the full 90s unbounded ✅ confirms fail-open
Output write deleted, so fromJSON('') Ran the full 90s unbounded, job concluded success ✅ silent
Job-level timeout instead of step-level Job cancelled, !cancelled() publish skipped, always() teardown ran fully (240s)

Two things this changed in the PR. The fail-open behavior is real and silent — a deleted output write yields a green build with an unbounded step, which is why validation exists and why the test now asserts the exact GITHUB_OUTPUT write. And the last row corrected the rationale above: the always() teardown survives a job-level timeout, contrary to what this description originally claimed.

Follow-ups not included here

An earlier version of this description said scope was kept to the workflow. That is now superseded — the server-side build timeout above is in this PR. Still outstanding, verified in code and not addressed by either timeout:

  • pkg/cli/deployment/deploy.go:198 calls PollUntilDone with no deadline, and rad deploy has no --timeout flag. A future flag would need registering on both rad deploy and rad run.
  • pkg/dynamicrp/frontend/routes.go:68 hardcodes AsyncOperationTimeout: 24h, versus 20m in corerp and a 120s armrpc default. Lowering it is what would fix the inversion in the table above for every dynamic-rp operation, not just container builds — worth doing, but a broader blast radius than this PR.

Happy to open issues for these if useful.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Unit Tests

    2 files  ±  0    459 suites  ±0   8m 23s ⏱️ -21s
6 522 tests + 84  6 520 ✅ + 84  2 💤 ±0  0 ❌ ±0 
7 798 runs  +100  7 796 ✅ +100  2 💤 ±0  0 ❌ ±0 

Results for commit f8263fe. ± Comparison against base commit 921e55c.

This pull request removes 11 and adds 95 tests. Note that renamed tests count towards both.
github.com/radius-project/radius/pkg/cli/cmd/app/delete/preview ‑ Test_Run/Failure:_ListAllResourceTypesNames_failure_surfaces_error
github.com/radius-project/radius/pkg/cli/cmd/app/delete/preview ‑ Test_Run/Success:_case-insensitive_ownership_match
github.com/radius-project/radius/pkg/cli/cmd/app/delete/preview ‑ Test_Run/Success:_resources_owned_by_other_applications_are_filtered_out
github.com/radius-project/radius/pkg/cli/cmd/env/delete/preview ‑ Test_Run
github.com/radius-project/radius/pkg/cli/cmd/env/delete/preview ‑ Test_Run/Success:_environment_deleted
github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes ‑ Test_Run/Failure:_default_environment_creation_fails
github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes ‑ Test_Run/Failure:_default_resource_group_creation_fails
github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes ‑ Test_Run/Success:_Install_with_--preview_creates_a_Radius.Core_environment
github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes ‑ Test_Run/Success:_Install_with_no_--kubecontext_flag_passes_empty_context_through
github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes ‑ Test_Run/Success:_Reinstall_with_--preview_leaves_an_existing_Radius.Core_environment_unchanged
…
github.com/radius-project/radius/cmd/rad/cmd ‑ Test_EnvDelete_ExposesFlagsLegacyRunnerReads
github.com/radius-project/radius/cmd/rad/cmd ‑ Test_EnvDelete_ExposesPreviewAndForceFlags
github.com/radius-project/radius/cmd/rad/cmd ‑ Test_EnvPreviewOnlyFlagsRejectedWithoutPreview/delete_--force
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_DeprecatedTypeReplacements_ExistInManifests
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_FormatDeprecationWarning
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_FormatDeprecationWarning/No_deprecated_resources_returns_an_empty_string
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_FormatDeprecationWarning/Resource_with_a_replacement_names_the_replacement_type
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_FormatDeprecationWarning/Resource_without_a_replacement_falls_back_to_recipe_pack_guidance
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_GetEnvironmentResources
github.com/radius-project/radius/pkg/cli/bicep ‑ Test_GetEnvironmentResources/Environment_resources_inside_modules_are_intentionally_not_returned
…

♻️ This comment has been updated with latest results.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.00%. Comparing base (921e55c) to head (f8263fe).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
pkg/recipes/driver/bicep/containerimages.go 94.44% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12746      +/-   ##
==========================================
+ Coverage   59.91%   60.00%   +0.09%     
==========================================
  Files         775      776       +1     
  Lines       46305    46444     +139     
==========================================
+ Hits        27745    27871     +126     
- Misses      18560    18573      +13     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Functional Tests - upgrade-noncloud

3 tests  ±0   3 ✅ ±0   3m 41s ⏱️ +9s
1 suites ±0   0 💤 ±0 
1 files   ±0   0 ❌ ±0 

Results for commit de96aab. ± Comparison against base commit b0875a4.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a validated, environment-overridable step timeout to the deploy phase in the extension’s Azure/AWS workflow templates to prevent pathological rad deploy runs from consuming the full GitHub Actions job budget, and introduces a structural test to keep the wiring correct.

Changes:

  • Add a Resolve deploy timeout validation step and apply timeout-minutes to the Run rad commands step in both Azure and AWS workflow templates.
  • Document the new RADIUS_DEPLOY_TIMEOUT_MINUTES variable and rationale in .github/extension/README.md.
  • Add deploy-timeout_test.sh plus make test-deploy-timeout, and include it in the aggregate test target.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
build/test.mk Adds test-deploy-timeout to the main test target and defines the new test target.
.github/extension/run-rad-commands-azure.yml Validates RADIUS_DEPLOY_TIMEOUT_MINUTES and applies a step-level timeout-minutes to Run rad commands.
.github/extension/run-rad-commands-aws.yml Same timeout validation + step-level timeout wiring for the AWS template.
.github/extension/README.md Documents the new deploy step timeout variable and the step-vs-job rationale/limitations.
.github/extension/deploy-timeout_test.sh Adds structural tests to ensure correct timeout placement, validation, and step ordering.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/extension/deploy-timeout_test.sh Outdated
nellshamrell added a commit that referenced this pull request Aug 20, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@nellshamrell

Copy link
Copy Markdown
Contributor Author

Approver review

Conflict: I authored this PR. This self-review cannot replace an independent approver.

Orientation

  • Purpose: Convert potentially multi-hour deploy hangs into bounded failures.
  • Placement: Generated Azure and AWS workflow templates under .github/extension/.
  • Mechanism: Validate an environment-scoped timeout, then apply it to the composite Run rad commands step.
  • Failure surface: Invalid configuration fails before deployment; slow deployment fails after 30 minutes by default; publish and teardown continue.
  • Review focus: Fail-open runner semantics, step-versus-job placement, downstream rollout, and the known incorrect published status on timeout.

What the code changes

Five files, +277/-2:

  • Both provider templates add identical validation and timeout wiring:
    • Azure: .github/extension/run-rad-commands-azure.yml:351-380
    • AWS: .github/extension/run-rad-commands-aws.yml:396-425
  • .github/extension/deploy-timeout_test.sh structurally tests both templates.
  • build/test.mk:79-81 adds the test to make test.
  • .github/extension/README.md:157-169 documents behavior, rollout, and known limitations.

The diff is coherent and contains no unrelated runtime changes.

Data and control flow

RADIUS_DEPLOY_TIMEOUT_MINUTES
            |
            v
Resolve deploy timeout
  default: 30
  validate: decimal integer, 1-330
  normalize: base 10
            | $GITHUB_OUTPUT
            v
Run rad commands
  timeout-minutes: fromJSON(...)
  executes rad deploy and container builds
            | timeout = failed step
            v
Publish graph/status       if: !cancelled()
            v
Teardown                   if: always()

The pathological QEMU container build occurs inside Run rad commands, so the bound covers the reported failure path.

Key implementation decisions

Should this be a step or job timeout?

Code's answer: Step timeout at Azure :380 and AWS :425.

Why it matters: A job timeout produces cancellation, skipping the !cancelled() publish step. A step timeout produces failure, preserving publish and teardown on the remaining job budget.

Assessment: Sound. Empirical testing corrected an earlier false rationale: always() teardown does survive job cancellation. Current code and documentation state the narrower, verified rationale.

Why validate the override separately?

Code's answer: Reject malformed, zero, negative, and greater-than-330 values before calling fromJSON.

Why it matters: GitHub's runner applies a timeout only when evaluation produces a positive value. Evaluation errors do not fail the step; they silently leave it unbounded.

Assessment: Necessary. Base-10 normalization also prevents 030 from becoming bash octal while remaining invalid JSON.

How does this reach existing repositories?

It does not until their workflows are regenerated. The workflow templates are copied into downstream repositories (README.md:3-5). Native timeout-minutes cannot move inside the shared composite action, although a shell-level timeout is possible with weaker runner integration. This rollout limitation is clearly documented at README.md:167.

Failure handling and operations

  • Invalid configuration fails immediately and visibly.
  • Timeout marks the GitHub workflow step and run as failed.
  • rad shutdown still runs through if: always().
  • Server-side work is not cancelled directly; deleting the ephemeral control plane ends it operationally.
  • The composite's trailing result upload shares the timeout deadline and is not guaranteed.

The important known defect is that a timed-out deployment is currently published as succeeded. The timeout fires the EXIT trap in run-rad-commands/action.yml:170-181, which writes the optimistic seed from :165. publish-deploy-status/action.yml:126-129 then maps it to succeeded.

This is pre-existing, but the PR makes the path routine. It is disclosed at README.md:165 and tracked in #12756. The GitHub run itself remains correctly failed.

Security and compatibility

Security: No material concerns. The variable enters bash through env, not source interpolation, and is strictly validated. No permission, secret, or dependency changes.

Compatibility changes:

  • Legitimate deployments exceeding 30 minutes now fail unless the variable is raised.
  • Malformed optional configuration fails the deployment rather than falling back.
  • Existing generated workflows require regeneration.

All three are documented.

Test evidence and gaps

Automated evidence:

  • Structural test passes.
  • README markdown lint passes.
  • Unit Tests passed on the current head.
  • The test verifies the exact timeout expression, output wiring, ordering, bounds, base-10 normalization, and sibling placement rather than nesting under with:.

Manual evidence:

  • Real runner confirmed a one-minute timeout fails at approximately 72 seconds.
  • 0 and missing output confirmed the runner's silent fail-open behavior.
  • Job cancellation confirmed publish is skipped while teardown survives.
  • EXIT-trap testing confirmed the known succeeded-status defect.

Gaps:

  • Validation branches are not executed in CI; the test asserts their source structure.
  • The real-runner experiments are not reproducible from the repository.
  • Current CI: 61 passed, 3 pending, 7 skipped, 0 failed.

Risk register

Risk Likelihood Impact Mitigation
Timeout published as succeeded High on timeout Medium Run remains failed; documented; #12756
Existing workflows remain unbounded High Medium Regenerate workflows; documented
Valid deploy exceeds 30 minutes Medium Low Environment override
Invalid override blocks deployment Low Low Explicit error message
330-minute ceiling leaves insufficient teardown budget Low Medium Documented as ceiling, not guarantee

Findings

Blocking findings: None.

Non-blocking concerns:

  1. A timed-out deploy is published as succeeded due to the optimistic accumulator at run-rad-commands/action.yml:165. Track and fix through Deploy workflow publishes succeeded when rad deploy is killed by a step timeout #12756.
  2. Existing downstream workflow copies remain unbounded until regenerated.
  3. Validation behavior is structurally tested rather than executed.
  4. The 330-minute ceiling is defensible headroom, but remains a judgment call.

Strengths:

  • Covers the actual pathological build path.
  • Explicitly prevents GitHub's silent timeout fail-open behavior.
  • Azure and AWS implementations are identical.
  • Tests target subtle YAML placement and output-wiring failures.
  • Documentation candidly records corrected assumptions and known defects.

Approval recommendation

Approve with follow-up.

The timeout mitigation is correct, bounded, reversible, and substantially improves the reported behavior. Fix #12756 soon because publishing a timed-out deployment as succeeded is materially misleading, but it does not invalidate the runner-level failure or cleanup behavior delivered here. Final merge should wait for the three remaining CI checks.

Coverage and confidence

  • Inspected: Full current diff, both templates, structural test, build wiring, documentation, composite result lifecycle, status publisher, PR body, follow-up issue, and CI.
  • Covered: Timeout, cancellation, cleanup, configuration, rollout, security, compatibility, and tests.
  • Skipped: Persistence, migrations, concurrency, dependencies, and performance--unchanged.
  • Unresolved: Remaining CI checks and timing for Deploy workflow publishes succeeded when rad deploy is killed by a step timeout #12756.
  • Confidence: Medium-high, discounted because this is self-review and the behavioral evidence is external to repository CI.

nellshamrell added a commit that referenced this pull request Aug 21, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
nellshamrell added a commit that referenced this pull request Aug 21, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
nellshamrell added a commit that referenced this pull request Aug 24, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
nellshamrell and others added 6 commits August 24, 2026 17:01
Deploy workflow runs could consume up to GitHub's 360-minute job default
before failing. There was no `timeout-minutes` anywhere in
.github/extension/, so a pathologically slow build - typically an
emulated cross-architecture container build - burned hours of runner time
with no bound.

Add a `timeout-minutes` to the `Run rad commands` step in both provider
workflow templates, defaulting to 30 minutes and overridable per
environment with `RADIUS_DEPLOY_TIMEOUT_MINUTES`.

The bound is on the step rather than the job deliberately. A job-level
timeout ends the job outright, so the `if: always()` teardown would not
run and `rad shutdown` would never persist state, and the deployed-graph
publish step would be skipped exactly when a deploy hung. A failed step
leaves both to run on the job's remaining budget.

A preceding `Resolve deploy timeout` step validates the override. The
runner applies a step timeout only when it evaluates to more than zero and
treats an expression it cannot evaluate as no timeout at all, so an
unchecked 0, negative, or malformed value would silently restore the
unbounded behavior. Values are constrained to 1-330 minutes, which also
keeps the step inside the job budget with room for teardown.

This is a mitigation, not a cure: it converts a multi-hour hang into a
bounded failure. It does not make builds faster and does not cancel
server-side work.

Fixes #12595

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>
`mapfile` requires bash 4, but macOS ships bash 3.2 and the contributing
docs list macOS as a supported contributor OS. CI runs ubuntu so this would
have passed there while breaking `make test` locally for macOS
contributors, since `test` now depends on `test-deploy-timeout`.

Replace the array-based step lookup with a stream-based position lookup
that uses no bash 4 constructs.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>
Two assertions in the deploy timeout test passed for the wrong reason.
The 'minutes=' check matched the local arithmetic assignment, so deleting
the write to GITHUB_OUTPUT still passed even though the timeout expression
would then evaluate to empty and leave the step unbounded. The variable
check matched the name where it appears in the error messages, so dropping
the env mapping still passed while silently ignoring the override. Both
now match the exact wiring.

Also correct an overclaim about the 330-minute ceiling. The setup steps
before the deploy draw on the same 360-minute job budget, so the ceiling
does not guarantee room for teardown.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Running the behavior on a real runner disproved the stated justification.
A job-level timeout does not skip the always() teardown: a 240-second
teardown ran to completion under a job-level timeout, so rad shutdown
would still persist state.

The real difference is narrower. A job-level timeout cancels the job,
which skips the Publish deployed graph and status step because it is
guarded by if: !cancelled(), so the run loses its graph and status. A
step timeout fails only that step and leaves publish and teardown to run
normally. A job-level bound would also fold the setup steps into the same
budget. Step-level placement still stands; the reason it was given for it
did not.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ions

The bound lives in the copied workflow file, so an existing repository keeps
its previous unbounded behavior until its workflows are regenerated. The
shared composite actions are the part that updates itself through the pinned
ref, but GitHub does not support timeout-minutes on composite steps, so they
cannot carry the bound instead. Say so, and name the shell-level 	imeout
alternative and what it costs.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Measured on a runner: the shared action writes its result file from an EXIT
trap that does fire when the step is killed, and the outcome accumulator is
seeded optimistically and never re-affirmed on success. So the timeout this
section documents currently produces a run that reports success. Point at
the tracking issue and tell readers to trust the run conclusion instead.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@nellshamrell
nellshamrell force-pushed the nellshamrell-special-potato branch from de96aab to 7b1dd4d Compare August 25, 2026 00:02
Match the complete shell defaulting expression instead of relying on an opaque substring. This keeps DEFAULT_TIMEOUT as the source of truth while making the assertion's intent obvious to reviewers and maintainers.

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
nellshamrell added a commit that referenced this pull request Aug 25, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@sk593 sk593 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should also add timeouts for Radius deployments in the DE/UCP. The deploy workflows waits for the rad deploy command to finish before returning to the user so it's not the workflow that's taking too long to finish, it's the Radius deployment itself.

We can keep timeouts in the workflow as an extra failsafe but I don't think it should replace timeouts within the Radius deployment itself.

nellshamrell added a commit that referenced this pull request Aug 25, 2026
The run-rad-commands composite action seeded its outcome accumulator to
`succeeded` and wrote the result file from a `trap ... EXIT`. Every other
assignment was a failure value, so success was never earned - only seeded.

A step timeout (the shape PR #12746 makes routine) or a cancelled job kills
bash mid-command, so no failure branch runs, but the EXIT trap still fires and
publishes the stale `succeeded` seed. publish-deploy-status then maps that to
RUN_STATE=succeeded, so a killed deploy is reported as a successful one.

Invert the default: seed `interrupted` with a non-zero exit code and assign
`succeeded` only after the command loop completes. Failure paths exit before
the promotion, so an abnormal termination now reports the seed.
publish-deploy-status already maps unrecognized outcomes to `failed`, so the
consumer needs no change.

Adds command-outcome_test.sh, which extracts the accumulator prologue from
action.yml and asserts a killed run publishes a non-success outcome, plus a
publish-deploy-status case covering the `interrupted` -> failed mapping.

Fixes #12756

Signed-off-by: Nell Shamrell-Harrington <nells@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@nellshamrell

Copy link
Copy Markdown
Contributor Author

@sk593 Thank you — this led me to investigating deeper. Below is what I found, including a correction to part of my own framing.

Where the deploy is actually bounded today

Layer Timeout Source Bounds the deploy?
dynamic-rp async operation 24h pkg/dynamicrp/frontend/routes.go:68 Yes, in principle
DE extensible-resource job 4h ExtensibleResourceDeploymentJobTimeout, RadiusDeploymentSettings.cs Yes
DE deployment job 7d DeploymentJobTimeout, same file Yes
UCP tracked-resource processing 12h pkg/ucp/frontend/controller/radius/proxy.go:47 No — bookkeeping only
rad deploy client wait none pkg/cli/deployment/deploy.go:197-201 No
Workflow step (this PR) 30m .github/extension/run-rad-commands-*.yml Runner cost only

The layers are inverted. dynamic-rp's 24h sits outside the DE's 4h, so Radius's own per-resource cap can never fire — the DE always gets there first. That is why "Radius has a deployment timeout" is effectively untrue today, and I think it is the concrete form of the gap you are describing.

I could not find a path where UCP bounds a user deployment. ProcessOperationTimeout at proxy.go:47 governs the background tracked-resource sync queued at proxy.go:334, not the deployment itself. Its practical failure mode is stale UCP tracking, not a runaway deploy. The runaway is dynamic-rp plus DE. If I have missed a UCP path, please let me know and I will take a look at it and revise my plan.

Proposed target ordering

dynamic-rp resource op  <  DE extensible-resource job  <  DE deployment job  <  rad deploy  <  workflow failsafe

Note that in CI the workflow bound will still usually fire first. That is intended — it is a runner-cost cap. The server-side bounds are what protect a developer running rad deploy at a terminal.

Proposed follow-up work

  1. Bound dynamic-rp below the DE's 4h (routes.go:68). This is the one in-repo change that makes a Radius-owned timeout actually fire. Two possible paths we could pursue.
    • a configurable AsyncOperationTimeout on dynamicrp.Config with a bounded default — simple, but it applies uniformly to every dynamic resource type and to both PUT and DELETE, so a legitimately slow Terraform recipe would begin failing;
    • a containerImages-specific build timeout in pkg/recipes/driver/bicep/containerimages.go, where the build already runs under exec.CommandContext — much smaller blast radius, but it does not bound anything else.

To reduce the blast radius of this change, I'd like to pursue a containerImages-specific build timeout first.
2. rad deploy --timeout, applied as a context deadline over the deployment phase, registered on both deploy and run since run shares the runner.
Open question I have not resolved: whether a client-side timeout should attempt to cancel the server-side deployment or simply abandon it. ResourceDeploymentsClient.Delete may only remove the deployment record, so I do not want to assume it cancels work.

  1. DE-side configurability. ExtensibleResourceDeploymentJobTimeout and DeploymentJobTimeout are hardcoded get-only properties, so exposing them needs a change in radius-project/deployment-engine. Happy to open the issue and send that PR.

@nellshamrell

Copy link
Copy Markdown
Contributor Author

Converting this to a draft while I explore the design

@nellshamrell
nellshamrell marked this pull request as draft August 26, 2026 17:33
The workflow timeout added earlier only bounds CI. A containerImages build
run by dynamic-rp had no timeout of its own, so it was capped only by the
deployment engine's four-hour extensible-resource job timeout. An emulated
cross-architecture build could therefore occupy a deployment for hours.

Give the build hook its own three-hour budget, applied at the top of
executeImageBuild so it also covers retrieving registry credentials. The
budget uses WithTimeoutCause with a private sentinel because the async
worker cancels operations rather than letting a deadline expire, so
DeadlineExceeded alone cannot tell a build we stopped from one the caller
cancelled. The resulting message reports the elapsed time and the limit.

A deadline alone would have failed open. runScript drains stdout and stderr
before calling Wait, and those drains end only at EOF. Killing the process
group cannot reach a descendant that left it via setsid while holding the
inherited pipes, so the drains, and the build, would block forever. Close
the read ends after a grace period once the context is done. cmd.WaitDelay
does not help here because it only applies inside Wait.

The new regression test hangs for the script's full duration without the
forced close, and completes in under a second with it.

Signed-off-by: Nell Shamrell-Harrington <nellshamrell@microsoft.com>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@nellshamrell nellshamrell changed the title Bound the deploy step with a validated timeout Bound container image builds server-side, and the deploy step in CI Aug 26, 2026
@nellshamrell

Copy link
Copy Markdown
Contributor Author

Pushed the server-side timeout — f8263fe. @sk593 you were right, and the layering turned out to be worse than "no timeout".

Innermost to outermost, what exists today:

Layer Timeout Fires first?
containerImages build script none
dynamic-rp async operation (pkg/dynamicrp/frontend/routes.go:68) 24h no
Deployment engine, extensible-resource job 4h yes, today
Deployment engine, deployment job 7d no
rad deploy client wait none
Workflow step (this PR) 30m in CI only

The layers are inverted — dynamic-rp's 24h sits outside the DE's 4h, so no Radius-owned timeout can ever fire. Every hang is bounded by the deployment engine, in a different repo, at 4 hours. (Both DE values are get-only expression-bodied C# properties, so neither is configurable.)

So this adds the innermost bound rather than another outer one: a 3-hour budget on the containerImages build, derived at the top of executeImageBuild so it also covers fetching registry credentials.

Two things worth your attention.

The default is 3h, not something aggressive. The observed pathological build ran 1-2 hours, so a 30-minute server-side default would fail it outright. The workflow's 30m already handles the CI-cost question; this one exists to stop a build occupying a deployment indefinitely, and sits under the DE's 4h so Radius reports the failure. Happy to change the number if you'd rather it be tighter.

A deadline alone would have failed open. runScript drains stdout/stderr before calling Wait, and those drains end only at EOF. Killing the process group can't reach a descendant that left it via setsid while holding the inherited pipes, so the drains never return and the deadline never gets to do anything. cmd.WaitDelay doesn't help — it only applies inside Wait.

This is the same fail-open class this PR already hit once with timeout-minutes, so I measured instead of assuming: with the forced pipe close disabled, the regression test blocks for the script's full 60 seconds; with it, 0.7s. That test is in the PR and fails without the fix.

Scope, precisely. This bounds the containerImages post-deployment build hook. It does not cover recipe OCI retrieval (bicep.go:99-115) or the nested Bicep deployment poll (bicep.go:170-190). The 24h AsyncOperationTimeout is what would fix the inversion for every dynamic-rp operation rather than just container builds — I left it alone as a broader blast radius, but say the word if you want it here.

It's hardcoded rather than configurable because deploy/Chart/templates/dynamic-rp/configmaps.yaml:64-66 hardcodes the bicep: block, so a Go-only BicepOptions field would be a knob no Helm user could set.

The PR description is updated and my earlier "scope was deliberately kept to the workflow" note is superseded. Left in draft until you've had a look at the direction.

@radius-functional-tests

radius-functional-tests Bot commented Aug 26, 2026

Copy link
Copy Markdown

Radius functional test overview

🔍 Go to test action run

Click here to see the test run details
Name Value
Repository radius-project/radius
Commit ref f8263fe
Unique ID func83208b12b1
Image tag pr-func83208b12b1
  • Dapr: 1.14.4
  • Azure KeyVault CSI driver: 1.4.2
  • Azure Workload identity webhook: 1.3.0
  • Bicep recipe location ghcr.io/radius-project/dev/test/testrecipes/test-bicep-recipes/<name>:pr-func83208b12b1
  • Terraform recipe location http://tf-module-server.radius-test-tf-module-server.svc.cluster.local/<name>.zip (in cluster)
  • applications-rp test image location: ghcr.io/radius-project/dev/applications-rp:pr-func83208b12b1
  • dynamic-rp test image location: ghcr.io/radius-project/dev/dynamic-rp:pr-func83208b12b1
  • controller test image location: ghcr.io/radius-project/dev/controller:pr-func83208b12b1
  • ucp test image location: ghcr.io/radius-project/dev/ucpd:pr-func83208b12b1
  • deployment-engine test image location: ghcr.io/radius-project/deployment-engine:latest

Test Status

⌛ Building Radius and pushing container images for functional tests...
✅ Container images build succeeded
⌛ Publishing Bicep Recipes for functional tests...
✅ Recipe publishing succeeded
⌛ Starting corerp-cloud functional tests...
⌛ Starting ucp-cloud functional tests...
✅ ucp-cloud functional tests succeeded
✅ corerp-cloud functional tests succeeded

@nellshamrell

Copy link
Copy Markdown
Contributor Author

I'm working on a more comprehensive design for this (as well as an example implementation for it)

pull Bot pushed a commit to TheTechOddBug/radius that referenced this pull request Aug 26, 2026
…ject#12759)

Fixes radius-project#12756

## Problem

The `run-rad-commands` composite action seeded its outcome accumulator
to `succeeded` and wrote the result file from a `trap write_result
EXIT`. Every *other* assignment to `OVERALL_OUTCOME` was a failure value
(`command_failed`, `disallowed_command`), each followed by an explicit
`exit`. So success was never earned — only seeded.

That works for a normal command failure: `record()` returns non-zero, a
failure branch assigns `command_failed`, and the trap writes the truth.
It does **not** work for a *termination*. A step timeout kills bash
mid-command, so no failure branch ever runs — but the EXIT trap still
fires and writes the stale `succeeded` seed. `publish-deploy-status`
then maps that to `RUN_STATE=succeeded`, and a deploy that was killed is
published as a successful one.

This is worse than the missing-file case the mapping was designed for:
`unknown` → `in_progress` is the intended "no verdict" sentinel, and the
trap defeats it by supplying a confident wrong verdict instead of no
verdict.

radius-project#12746 adds a `timeout-minutes` bound to this step, which makes the
affected path the normal shape of a slow-deploy failure rather than an
edge case.

## Fix

Invert the default so the accumulator is pessimistic:

- Seed `OVERALL_OUTCOME="interrupted"` / `OVERALL_EXIT=1`.
- Assign `succeeded` / `0` exactly once, after the command loop
completes.

Every failure path exits before reaching the promotion, and an abnormal
termination never reaches it at all, so a killed run now reports the
seed. `publish-deploy-status` already maps unrecognized outcomes to
`failed` via its `*)` arm, so the consumer needs no change.

The fix lives in the composite action rather than the workflow templates
on purpose: templates are copied into user repositories and only update
on regeneration, whereas composite actions resolve at the pinned
`{{RADIUS_REF}}` and reach existing repositories automatically.

**On cancellation:** the issue asked whether a cancelled job reaches the
same path. It does — cancellation kills the step identically — but both
deploy workflow templates gate `Publish deployed graph and status` on
`if: ${{ !cancelled() }}`, so a cancelled job publishes no status at
all. There the seed only corrects the uploaded `rad-commands-result`
artifact. The comments and README say so explicitly rather than
overclaiming.

## Tests

New `command-outcome_test.sh` extracts the accumulator prologue
*verbatim* from `action.yml` (scoped to the `Run rad commands` step so
an unrelated step can't silently redirect the extraction), then models a
step timeout faithfully: it forks a subshell with the same options
GitHub gives a `shell: bash` step, waits for the trap to be installed,
asserts the result file is not written eagerly, and sends a real
`SIGTERM`. It then asserts the published outcome is neither
`succeeded`/`success` nor `unknown` (which would map to the neutral
`in_progress`), plus static checks that `succeeded` is assigned exactly
once and after the last failure assignment.

Mutation-tested against three regressions — restoring the optimistic
seed, removing the promotion, and renaming `command_failed` — each fails
with a specific diagnostic.

Also adds an `interrupted` → `failed` case to
`publish-deploy-status_test.sh`, and wires the new suite into `make
test` as `test-command-outcome`.

Validation: both suites pass; `shellcheck` 0.11.0 with the repo rcfile
is clean; both `action.yml` files parse.

## Docs

`.github/extension/README.md` now documents the pessimistic-accumulator
contract next to the `rad-commands-result` output description, including
the cancellation caveat.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rad deploy can run for hours without timing out

3 participants