diff --git a/Aspire.slnx b/Aspire.slnx
index 52300ace4ae..0ab163450df 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -66,6 +66,7 @@
+
@@ -471,6 +472,7 @@
+
diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md
new file mode 100644
index 00000000000..331e16d7834
--- /dev/null
+++ b/docs/specs/pipeline-generation.md
@@ -0,0 +1,517 @@
+# Pipeline Generation for Aspire
+
+## Status
+
+
+**Stage:** Spike / Proof of Concept
+**Authors:** Aspire Team
+**Date:** 2025
+
+## Summary
+
+This document describes the architecture, API primitives, and approach for generating CI/CD pipeline definitions (e.g., GitHub Actions workflows, Azure DevOps pipelines) from an Aspire application model. The core idea is that developers can declare pipeline structure in their AppHost code and Aspire generates the corresponding workflow YAML files, with each step mapped to CI/CD jobs that invoke `aspire deploy --continue` to execute the subset of pipeline steps appropriate for that job.
+
+## Motivation
+
+Today, `aspire publish`, `aspire deploy`, and `aspire do [step]` execute pipeline steps locally in a single process. This works well for developer inner-loop, but production deployments typically need:
+
+- **CI/CD integration** — Steps should run in GitHub Actions jobs, Azure DevOps stages, etc.
+- **Parallelism** — Independent steps (e.g., building multiple services) should run on separate agents.
+- **State management** — Intermediate artifacts must flow between jobs.
+- **Auditability** — The workflow YAML is version-controlled alongside the app code.
+
+Pipeline generation bridges this gap: developers define workflow structure in C#, and `aspire pipeline init` emits the workflow files.
+
+## Architecture Overview
+
+```text
+┌──────────────────────────────────────┐
+│ AppHost Code │
+│ │
+│ var wf = builder │
+│ .AddGitHubActionsWorkflow("ci"); │
+│ var build = wf.AddJob("build"); │
+│ var deploy = wf.AddJob("deploy"); │
+│ │
+│ builder.Pipeline.AddStep( │
+│ "build-app", ..., │
+│ scheduledBy: build); │
+│ builder.Pipeline.AddStep( │
+│ "deploy-app", ..., │
+│ scheduledBy: deploy); │
+└──────────────┬───────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────┐
+│ Scheduling Resolver │
+│ │
+│ • Maps steps → jobs │
+│ • Projects step DAG onto job graph │
+│ • Validates no cycles │
+│ • Computes `needs:` dependencies │
+└──────────────┬───────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────┐
+│ YAML Generator (future) │
+│ │
+│ • Emits .github/workflows/*.yml │
+│ • Includes state upload/download │
+│ • Each job runs `aspire do --cont.` │
+└──────────────────────────────────────┘
+```
+
+## Core Abstractions
+
+### `IPipelineEnvironment`
+
+A marker interface extending `IResource` that identifies a resource as a pipeline execution environment. This follows the same pattern as `IComputeEnvironmentResource` in the hosting model.
+
+```csharp
+[Experimental("ASPIREPIPELINES001")]
+public interface IPipelineEnvironment : IResource
+{
+}
+```
+
+Pipeline environments are added to the application model like any other resource. The system resolves the active environment at runtime by checking annotations.
+
+### `PipelineEnvironmentCheckAnnotation`
+
+An annotation applied to `IPipelineEnvironment` resources that determines whether the environment is relevant for the current invocation. This follows the existing annotation-based pattern used by `ComputeEnvironmentAnnotation` and `DeploymentTargetAnnotation`.
+
+```csharp
+[Experimental("ASPIREPIPELINES001")]
+public class PipelineEnvironmentCheckAnnotation(
+ Func> checkAsync) : IResourceAnnotation
+{
+ public Func> CheckAsync { get; } = checkAsync;
+}
+```
+
+For example, a GitHub Actions environment would check for the `GITHUB_ACTIONS` environment variable.
+
+### Environment Resolution
+
+`DistributedApplicationPipeline.GetEnvironmentAsync()` resolves the active environment:
+
+1. Scan the application model for all `IPipelineEnvironment` resources.
+2. For each, invoke its `PipelineEnvironmentCheckAnnotation.CheckAsync()`.
+3. If exactly one passes → return it.
+4. If none pass → return `LocalPipelineEnvironment` (internal fallback).
+5. If multiple pass → throw `InvalidOperationException`.
+
+### `IPipelineStepTarget`
+
+An interface that pipeline job objects implement. It provides the link between a pipeline step and the CI/CD construct (job, stage, etc.) it should run within.
+
+```csharp
+[Experimental("ASPIREPIPELINES001")]
+public interface IPipelineStepTarget
+{
+ string Id { get; }
+ IPipelineEnvironment Environment { get; }
+}
+```
+
+### `PipelineStep.ScheduledBy`
+
+The `PipelineStep` class gains a `ScheduledBy` property:
+
+```csharp
+public IPipelineStepTarget? ScheduledBy { get; set; }
+```
+
+When set, the step is intended to execute within the context of a specific job. When null, the step is assigned to a default target (first declared job, or a synthetic "default" job if none declared).
+
+### `IDistributedApplicationPipeline.AddStep()` — Extended
+
+The `AddStep` method gains a `scheduledBy` parameter:
+
+```csharp
+void AddStep(string name, Func action,
+ object? dependsOn = null, object? requiredBy = null,
+ IPipelineStepTarget? scheduledBy = null);
+```
+
+## GitHub Actions Implementation
+
+### `GitHubActionsWorkflowResource`
+
+A `Resource` + `IPipelineEnvironment` that represents a GitHub Actions workflow file.
+
+```csharp
+var workflow = builder.AddGitHubActionsWorkflow("deploy");
+var buildJob = workflow.AddJob("build");
+var deployJob = workflow.AddJob("deploy");
+```
+
+- `WorkflowFileName` → `"deploy.yml"`
+- `Jobs` → ordered list of `GitHubActionsJob`
+
+### `GitHubActionsJob`
+
+Implements `IPipelineStepTarget`. Properties:
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `Id` | `string` | (required) | Job identifier in the YAML |
+| `DisplayName` | `string?` | `null` | Human-readable `name:` in YAML |
+| `RunsOn` | `string` | `"ubuntu-latest"` | Runner label |
+| `DependsOnJobs` | `IReadOnlyList` | `[]` | Explicit job-level `needs:` |
+
+Jobs can declare explicit dependencies:
+
+```csharp
+deployJob.DependsOn(buildJob); // Explicit job dependency
+```
+
+### Scheduling Resolver
+
+The scheduling resolver is the core algorithm that projects the step DAG onto the job dependency graph. Given a set of pipeline steps (some with `ScheduledBy` set), it:
+
+1. **Assigns steps to jobs** — Steps with `ScheduledBy` use that job; unassigned steps go to a default job.
+2. **Projects step dependencies onto job dependencies** — If step A (on job X) depends on step B (on job Y), then job X needs job Y.
+3. **Merges explicit job dependencies** — Any `DependsOn` calls on jobs are included.
+4. **Validates the job graph is a DAG** — Uses three-state DFS cycle detection.
+5. **Groups steps per job** — For YAML generation.
+
+#### Default Job Selection
+
+- No jobs declared → creates synthetic `"default"` job
+- One job → uses it as default
+- Multiple jobs → uses the first declared job
+
+#### Error Cases
+
+| Scenario | Error |
+|----------|-------|
+| Step scheduled on job from different workflow | `SchedulingValidationException` |
+| Step assignments create circular job deps | `SchedulingValidationException` with cycle path |
+| Explicit job deps create cycle | `SchedulingValidationException` |
+
+### Example: End-to-End
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Add application resources
+var api = builder.AddProject("api");
+var web = builder.AddProject("web");
+
+// Define CI/CD workflow
+var workflow = builder.AddGitHubActionsWorkflow("deploy");
+var publishJob = workflow.AddJob("publish");
+var deployJob = workflow.AddJob("deploy");
+
+// Pipeline steps with scheduling
+builder.Pipeline.AddStep("build-images", BuildImagesAsync,
+ scheduledBy: publishJob);
+builder.Pipeline.AddStep("push-images", PushImagesAsync,
+ dependsOn: "build-images",
+ scheduledBy: publishJob);
+builder.Pipeline.AddStep("deploy-infra", DeployInfraAsync,
+ dependsOn: "push-images",
+ scheduledBy: deployJob);
+builder.Pipeline.AddStep("deploy-apps", DeployAppsAsync,
+ dependsOn: "deploy-infra",
+ scheduledBy: deployJob);
+```
+
+The resolver computes:
+
+- **`publish` job**: `build-images` → `push-images` (no `needs:`)
+- **`deploy` job**: `deploy-infra` → `deploy-apps` (`needs: publish`)
+
+## Generated Workflow Structure
+
+The YAML generator produces complete, valid GitHub Actions workflow files. Each job in the workflow follows a predictable structure:
+
+1. **Boilerplate** — `actions/checkout@v4`, `actions/setup-dotnet@v4`, `dotnet tool install -g aspire`
+2. **State download** — For jobs with dependencies, downloads state artifacts from upstream jobs
+3. **Execute** — `aspire do --continue --job ` runs only the steps assigned to this job
+4. **State upload** — Uploads `.aspire/state/` as a workflow artifact for downstream jobs
+
+### Example: Two-Job Build & Deploy Pipeline
+
+```yaml
+name: deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ build:
+ name: 'Build & Publish'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job build
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ if-no-files-found: ignore
+
+ deploy:
+ name: Deploy to Azure
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job deploy
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-deploy
+ path: .aspire/state/
+ if-no-files-found: ignore
+```
+
+### YAML Model
+
+The generated YAML is built from a simple C# object model:
+
+| Type | Purpose |
+|------|---------|
+| `WorkflowYaml` | Root workflow — name, triggers, permissions, jobs |
+| `JobYaml` | Single job — runs-on, needs, steps |
+| `StepYaml` | Single step — name, uses, run, with, env |
+| `WorkflowTriggers` | Trigger configuration — push, workflow_dispatch |
+| `PushTrigger` | Push trigger — branches list |
+
+A hand-rolled `WorkflowYamlSerializer` converts the model to YAML strings without external dependencies.
+
+### `WorkflowYamlGenerator`
+
+`WorkflowYamlGenerator.Generate()` takes a `SchedulingResult` and `GitHubActionsWorkflowResource` and produces a `WorkflowYaml`:
+
+1. Sets workflow name from the resource name
+2. Configures default triggers (`workflow_dispatch` + `push` to `main`)
+3. Sets workflow-level permissions (`contents: read`, `id-token: write`)
+4. For each job:
+ - Adds boilerplate steps (checkout, setup-dotnet, install CLI)
+ - Adds state download steps from dependency jobs
+ - Adds `aspire do --continue --job ` execution step
+ - Adds state upload step
+
+## Step State Restore
+
+### Problem
+
+In CI/CD workflows, each job runs on a different machine. When `aspire do --continue --job deploy` runs, it needs to know what job `build` already did — without re-executing `build`'s steps.
+
+### Solution: `TryRestoreStepAsync`
+
+`PipelineStep` has a `TryRestoreStepAsync` property:
+
+```csharp
+public Func>? TryRestoreStepAsync { get; init; }
+```
+
+When the pipeline executor encounters a step with `TryRestoreStepAsync`:
+
+1. Before executing the step's `Action`, call `TryRestoreStepAsync`
+2. If it returns `true` → step is marked complete, `Action` is never called
+3. If it returns `false` → step executes normally via `Action`
+
+### How It Works with CI/CD
+
+Steps use the existing `IDeploymentStateManager` to persist their output:
+
+1. **Step A** (in build job): Provisions resources, saves metadata to `.aspire/state/` via `IDeploymentStateManager`
+2. **Build job**: Uploads `.aspire/state/` as GitHub Actions artifact
+3. **Deploy job**: Downloads artifact to `.aspire/state/`
+4. **Step A** (in deploy job): `TryRestoreStepAsync` checks if state exists → returns `true` → skips execution
+5. **Step B** (in deploy job): Depends on Step A's output, runs normally using restored state
+
+## Extensibility
+
+### Adding New CI/CD Providers
+
+New providers implement:
+
+1. A `Resource` + `IPipelineEnvironment` class (like `GitHubActionsWorkflowResource`)
+2. A job/stage class implementing `IPipelineStepTarget` (like `GitHubActionsJob`)
+3. A builder extension method (`AddAzureDevOpsPipeline(...)`, etc.)
+4. A YAML/config generator specific to the provider
+
+The scheduling resolver is **provider-agnostic** — it works with any `IPipelineStepTarget` implementation.
+
+### Azure DevOps (Example Future Provider)
+
+```csharp
+var pipeline = builder.AddAzureDevOpsPipeline("deploy");
+var buildStage = pipeline.AddStage("build");
+var deployStage = pipeline.AddStage("deploy");
+```
+
+The `AzureDevOpsStage` would implement `IPipelineStepTarget` and the YAML generator would emit `azure-pipelines.yml`.
+
+## Testing Strategy
+
+### Unit Tests
+
+The scheduling resolver has extensive unit tests covering:
+
+| Test Case | Description |
+|-----------|-------------|
+| Two steps, two jobs | Basic cross-job dependency |
+| Fan-out | One step depending on three across three jobs |
+| Fan-in | Three steps depending on one setup step |
+| Diamond | A→B, A→C, B→D, C→D across four jobs |
+| Cycle detection | Circular job dependencies from step assignments |
+| Default job | Unscheduled steps grouped into default job |
+| Mixed scheduling | Some steps scheduled, some default |
+| Single job | All steps on one job — no cross-job deps |
+| No jobs declared | Synthetic default job created |
+| Steps grouped | Correct grouping of steps per job |
+| Explicit job deps | `DependsOn()` preserved in output |
+| Cross-workflow | Step from different workflow → error |
+| Explicit cycle | Direct job cycle → error |
+
+Environment resolution tests cover:
+
+| Test Case | Description |
+|-----------|-------------|
+| No environments | Falls back to `LocalPipelineEnvironment` |
+| One passing env | Returns it |
+| One failing env | Falls back to local |
+| Two envs, one passes | Returns the passing one |
+| Two envs, both pass | Throws ambiguity error |
+| No check annotation | Treated as non-relevant |
+| Late-added env | Detected after pipeline construction |
+
+### Integration Tests (Future)
+
+- End-to-end YAML generation and validation
+- Round-trip: generate YAML → parse → verify structure
+- CLI `aspire pipeline init` command execution
+
+## Future Work
+
+### Cloud Auth Decoupling (`PipelineSetupRequirementAnnotation`)
+
+The current YAML generator produces boilerplate steps only. Real deployments need cloud-specific authentication steps (e.g., `azure/login@v2`, `docker/login-action`). The design for this:
+
+```text
+Aspire.Hosting (core)
+ └── PipelineSetupRequirementAnnotation
+ - ProviderId: "azure" | "docker-registry" | ...
+ - RequiredSecrets: { "AZURE_CLIENT_ID", ... }
+ - RequiredPermissions: { "id-token: write", ... }
+
+Aspire.Hosting.Azure (existing)
+ └── Adds PipelineSetupRequirementAnnotation("azure") when Azure resources are in the model
+
+Aspire.Hosting.Pipelines.GitHubActions
+ └── Built-in renderers: "azure" → azure/login@v2, "docker-registry" → docker/login-action
+```
+
+Key benefits:
+- Azure package doesn't reference GitHub Actions — just adds a generic annotation
+- GitHub Actions package doesn't reference Azure — reads annotations by string ID
+- Extensible — new cloud providers add their own annotations
+
+### Per-PR Environments
+
+Inspired by the tui.social pattern:
+
+```csharp
+workflow.WithPullRequestEnvironments(cleanup: true);
+```
+
+This would generate:
+- Conditional job execution (PR vs production)
+- Cleanup workflow on PR close
+- Environment-scoped deployments
+
+### `aspire pipeline init` Command
+
+CLI command that:
+1. Builds the AppHost
+2. Resolves the pipeline environment
+3. Runs the YAML generator
+4. Writes the output to `.github/workflows/`
+
+## Open Questions
+
+1. **State serialization format** — JSON? Binary? How to handle large artifacts?
+2. **Secret injection** — How do CI/CD secrets map to Aspire parameters?
+3. **Multi-workflow** — Can an app model produce multiple workflow files? (Yes, via multiple `AddGitHubActionsWorkflow` calls — but what about environment resolution?)
+4. **Conditional steps** — How do steps that only run on certain branches/events interact with scheduling?
+5. **Custom runner labels** — Per-step runner requirements (e.g., GPU, Windows)?
+6. **Caching** — Should generated workflows include caching for NuGet packages, Docker layers, etc.?
+
+## Implementation Files
+
+### Source
+
+| File | Description |
+|------|-------------|
+| `src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs` | Marker interface |
+| `src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs` | Scheduling target interface |
+| `src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs` | Relevance check annotation |
+| `src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs` | Check context |
+| `src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs` | Fallback environment |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs` | Workflow resource |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs` | Job target |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs` | Builder extension |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs` | Step-to-job resolver |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs` | Validation errors |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs` | Scheduling result → YAML model |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs` | YAML model POCOs |
+| `src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs` | YAML model → string |
+
+### Tests
+
+| File | Description |
+|------|-------------|
+| `tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs` | Environment resolution tests (7) |
+| `tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs` | TryRestoreStepAsync integration tests (5) |
+| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs` | Workflow model tests (9) |
+| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs` | Scheduling validation tests (13) |
+| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs` | YAML generation tests (9) |
+| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs` | Verify snapshot tests (4) |
+
+### Modified
+
+| File | Change |
+|------|--------|
+| `src/Aspire.Hosting/Pipelines/PipelineStep.cs` | Added `ScheduledBy` and `TryRestoreStepAsync` properties |
+| `src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs` | Added `scheduledBy` to `AddStep()`, added `GetEnvironmentAsync()` |
+| `src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs` | Constructor takes model, implements `GetEnvironmentAsync()`, `TryRestoreStepAsync` in executor |
+| `src/Aspire.Hosting/DistributedApplicationBuilder.cs` | Pipeline initialized with model |
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj b/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj
new file mode 100644
index 00000000000..2a0e79fb1e1
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(DefaultTargetFramework)
+ true
+ true
+ aspire hosting pipelines github-actions ci-cd
+ GitHub Actions pipeline generation for Aspire.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs
new file mode 100644
index 00000000000..ce302309074
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs
@@ -0,0 +1,74 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Represents a job within a GitHub Actions workflow.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class GitHubActionsJob : IPipelineStepTarget
+{
+ private readonly List _dependsOnJobs = [];
+
+ internal GitHubActionsJob(string id, GitHubActionsWorkflowResource workflow)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+ ArgumentNullException.ThrowIfNull(workflow);
+
+ Id = id;
+ Workflow = workflow;
+ }
+
+ ///
+ /// Gets the unique identifier for this job within the workflow.
+ ///
+ public string Id { get; }
+
+ ///
+ /// Gets or sets the human-readable display name for this job.
+ ///
+ public string? DisplayName { get; set; }
+
+ ///
+ /// Gets or sets the runner label for this job (defaults to "ubuntu-latest").
+ ///
+ public string RunsOn { get; set; } = "ubuntu-latest";
+
+ ///
+ /// Gets the IDs of jobs that this job depends on (maps to the needs: key in the workflow YAML).
+ ///
+ public IReadOnlyList DependsOnJobs => _dependsOnJobs;
+
+ ///
+ /// Gets the workflow that owns this job.
+ ///
+ public GitHubActionsWorkflowResource Workflow { get; }
+
+ ///
+ IPipelineEnvironment IPipelineStepTarget.Environment => Workflow;
+
+ ///
+ /// Declares that this job depends on another job.
+ ///
+ /// The ID of the job this job depends on.
+ public void DependsOn(string jobId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(jobId);
+ _dependsOnJobs.Add(jobId);
+ }
+
+ ///
+ /// Declares that this job depends on another job.
+ ///
+ /// The job this job depends on.
+ public void DependsOn(GitHubActionsJob job)
+ {
+ ArgumentNullException.ThrowIfNull(job);
+ _dependsOnJobs.Add(job.Id);
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs
new file mode 100644
index 00000000000..81320913c24
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Extension methods for adding GitHub Actions workflow resources to a distributed application.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public static class GitHubActionsWorkflowExtensions
+{
+ ///
+ /// Adds a GitHub Actions workflow resource to the application model.
+ ///
+ /// The distributed application builder.
+ /// The name of the workflow resource. This also becomes the workflow filename (e.g., "deploy" → "deploy.yml").
+ /// A resource builder for the workflow resource.
+ [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")]
+ public static IResourceBuilder AddGitHubActionsWorkflow(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ var resource = new GitHubActionsWorkflowResource(name);
+
+ resource.Annotations.Add(new PipelineEnvironmentCheckAnnotation(context =>
+ {
+ // This environment is relevant when running inside GitHub Actions
+ var isGitHubActions = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
+ return Task.FromResult(isGitHubActions);
+ }));
+
+ return builder.AddResource(resource)
+ .ExcludeFromManifest();
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs
new file mode 100644
index 00000000000..7a9eb6d206c
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs
@@ -0,0 +1,48 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Represents a GitHub Actions workflow as a pipeline environment resource.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipelineEnvironment
+{
+ private readonly List _jobs = [];
+
+ ///
+ /// Gets the filename for the generated workflow YAML file (e.g., "deploy.yml").
+ ///
+ public string WorkflowFileName => $"{Name}.yml";
+
+ ///
+ /// Gets the jobs declared in this workflow.
+ ///
+ public IReadOnlyList Jobs => _jobs;
+
+ ///
+ /// Adds a job to this workflow.
+ ///
+ /// The unique job identifier within the workflow.
+ /// The created .
+ public GitHubActionsJob AddJob(string id)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+
+ if (_jobs.Any(j => j.Id == id))
+ {
+ throw new InvalidOperationException(
+ $"A job with the ID '{id}' has already been added to the workflow '{Name}'.");
+ }
+
+ var job = new GitHubActionsJob(id, this);
+ _jobs.Add(job);
+ return job;
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs
new file mode 100644
index 00000000000..e3eceb6c000
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs
@@ -0,0 +1,262 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Resolves pipeline step scheduling onto workflow jobs, validating that step-to-job
+/// assignments are consistent with the step dependency graph.
+///
+internal static class SchedulingResolver
+{
+ ///
+ /// Resolves step-to-job assignments and computes job dependencies.
+ ///
+ /// The pipeline steps to resolve.
+ /// The workflow resource containing the declared jobs.
+ /// The resolved scheduling result.
+ ///
+ /// Thrown when the step-to-job assignments create circular job dependencies or are otherwise invalid.
+ ///
+ public static SchedulingResult Resolve(IReadOnlyList steps, GitHubActionsWorkflowResource workflow)
+ {
+ ArgumentNullException.ThrowIfNull(steps);
+ ArgumentNullException.ThrowIfNull(workflow);
+
+ var defaultJob = GetOrCreateDefaultJob(workflow);
+
+ // Build step-to-job mapping
+ var stepToJob = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var step in steps)
+ {
+ if (step.ScheduledBy is GitHubActionsJob job)
+ {
+ if (job.Workflow != workflow)
+ {
+ throw new SchedulingValidationException(
+ $"Step '{step.Name}' is scheduled on job '{job.Id}' from a different workflow. " +
+ $"Steps can only be scheduled on jobs within the same workflow.");
+ }
+ stepToJob[step.Name] = job;
+ }
+ else if (step.ScheduledBy is not null)
+ {
+ throw new SchedulingValidationException(
+ $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy.GetType().Name}' " +
+ $"which is not a GitHubActionsJob.");
+ }
+ else
+ {
+ stepToJob[step.Name] = defaultJob;
+ }
+ }
+
+ // Build step lookup
+ var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal);
+
+ // Project step DAG onto job dependency graph
+ var jobDependencies = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var step in steps)
+ {
+ var currentJob = stepToJob[step.Name];
+
+ if (!jobDependencies.ContainsKey(currentJob.Id))
+ {
+ jobDependencies[currentJob.Id] = [];
+ }
+
+ foreach (var depName in step.DependsOnSteps)
+ {
+ if (!stepToJob.TryGetValue(depName, out var depJob))
+ {
+ // Dependency is not in our step list — skip (might be a well-known step)
+ continue;
+ }
+
+ if (depJob.Id != currentJob.Id)
+ {
+ jobDependencies[currentJob.Id].Add(depJob.Id);
+ }
+ }
+ }
+
+ // Ensure all jobs are in the dependency graph
+ foreach (var job in workflow.Jobs)
+ {
+ if (!jobDependencies.ContainsKey(job.Id))
+ {
+ jobDependencies[job.Id] = [];
+ }
+ }
+
+ if (!jobDependencies.ContainsKey(defaultJob.Id))
+ {
+ jobDependencies[defaultJob.Id] = [];
+ }
+
+ // Also include any explicitly declared job dependencies
+ foreach (var job in workflow.Jobs)
+ {
+ foreach (var dep in job.DependsOnJobs)
+ {
+ jobDependencies[job.Id].Add(dep);
+ }
+ }
+
+ // Validate: job dependency graph must be a DAG (detect cycles)
+ ValidateNoCycles(jobDependencies);
+
+ // Group steps by job
+ var stepsPerJob = new Dictionary>(StringComparer.Ordinal);
+ foreach (var step in steps)
+ {
+ var job = stepToJob[step.Name];
+ if (!stepsPerJob.TryGetValue(job.Id, out var list))
+ {
+ list = [];
+ stepsPerJob[job.Id] = list;
+ }
+ list.Add(step);
+ }
+
+ return new SchedulingResult
+ {
+ StepToJob = stepToJob,
+ JobDependencies = jobDependencies.ToDictionary(
+ kvp => kvp.Key,
+ kvp => (IReadOnlySet)kvp.Value,
+ StringComparer.Ordinal),
+ StepsPerJob = stepsPerJob.ToDictionary(
+ kvp => kvp.Key,
+ kvp => (IReadOnlyList)kvp.Value,
+ StringComparer.Ordinal),
+ DefaultJob = defaultJob
+ };
+ }
+
+ private static GitHubActionsJob GetOrCreateDefaultJob(GitHubActionsWorkflowResource workflow)
+ {
+ // If the workflow has no jobs, create a default one
+ if (workflow.Jobs.Count == 0)
+ {
+ return workflow.AddJob("default");
+ }
+
+ // If there's exactly one job, use it as the default
+ if (workflow.Jobs.Count == 1)
+ {
+ return workflow.Jobs[0];
+ }
+
+ // If there are multiple jobs, check if a "default" job exists
+ var defaultJob = workflow.Jobs.FirstOrDefault(j => j.Id == "default");
+ if (defaultJob is not null)
+ {
+ return defaultJob;
+ }
+
+ // Use the first job as the default
+ return workflow.Jobs[0];
+ }
+
+ private static void ValidateNoCycles(Dictionary> jobDependencies)
+ {
+ // DFS-based cycle detection with three-state visiting
+ var visited = new Dictionary(StringComparer.Ordinal);
+ var cyclePath = new List();
+
+ foreach (var jobId in jobDependencies.Keys)
+ {
+ visited[jobId] = VisitState.Unvisited;
+ }
+
+ foreach (var jobId in jobDependencies.Keys)
+ {
+ if (visited[jobId] == VisitState.Unvisited)
+ {
+ if (HasCycleDfs(jobId, jobDependencies, visited, cyclePath))
+ {
+ cyclePath.Reverse();
+ var cycleDescription = string.Join(" → ", cyclePath);
+ throw new SchedulingValidationException(
+ $"Pipeline step scheduling creates a circular dependency between jobs: {cycleDescription}. " +
+ $"This typically happens when step A depends on step B, but their job assignments " +
+ $"create a cycle in the job dependency graph.");
+ }
+ }
+ }
+ }
+
+ private static bool HasCycleDfs(
+ string jobId,
+ Dictionary> jobDependencies,
+ Dictionary visited,
+ List cyclePath)
+ {
+ visited[jobId] = VisitState.Visiting;
+
+ if (jobDependencies.TryGetValue(jobId, out var deps))
+ {
+ foreach (var dep in deps)
+ {
+ if (!visited.TryGetValue(dep, out var state))
+ {
+ continue;
+ }
+
+ if (state == VisitState.Visiting)
+ {
+ cyclePath.Add(dep);
+ cyclePath.Add(jobId);
+ return true;
+ }
+
+ if (state == VisitState.Unvisited && HasCycleDfs(dep, jobDependencies, visited, cyclePath))
+ {
+ cyclePath.Add(jobId);
+ return true;
+ }
+ }
+ }
+
+ visited[jobId] = VisitState.Visited;
+ return false;
+ }
+
+ private enum VisitState
+ {
+ Unvisited,
+ Visiting,
+ Visited
+ }
+}
+
+///
+/// The result of resolving pipeline step scheduling onto workflow jobs.
+///
+internal sealed class SchedulingResult
+{
+ ///
+ /// Gets the mapping of step names to their assigned jobs.
+ ///
+ public required Dictionary StepToJob { get; init; }
+
+ ///
+ /// Gets the computed job dependency graph (job ID → set of job IDs it depends on).
+ ///
+ public required Dictionary> JobDependencies { get; init; }
+
+ ///
+ /// Gets the steps grouped by their assigned job.
+ ///
+ public required Dictionary> StepsPerJob { get; init; }
+
+ ///
+ /// Gets the default job used for unscheduled steps.
+ ///
+ public required GitHubActionsJob DefaultJob { get; init; }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs
new file mode 100644
index 00000000000..33a77693ab1
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Exception thrown when pipeline step scheduling onto workflow jobs is invalid.
+///
+public class SchedulingValidationException : InvalidOperationException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message describing the scheduling violation.
+ public SchedulingValidationException(string message) : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message describing the scheduling violation.
+ /// The inner exception.
+ public SchedulingValidationException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs
new file mode 100644
index 00000000000..d3f96efbca1
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs
@@ -0,0 +1,144 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Generates a from a scheduling result and workflow resource.
+///
+internal static class WorkflowYamlGenerator
+{
+ private const string StateArtifactPrefix = "aspire-state-";
+ private const string StatePath = ".aspire/state/";
+
+ ///
+ /// Generates a workflow YAML model from the scheduling result.
+ ///
+ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow)
+ {
+ ArgumentNullException.ThrowIfNull(scheduling);
+ ArgumentNullException.ThrowIfNull(workflow);
+
+ var workflowYaml = new WorkflowYaml
+ {
+ Name = workflow.Name,
+ On = new WorkflowTriggers
+ {
+ WorkflowDispatch = true,
+ Push = new PushTrigger
+ {
+ Branches = ["main"]
+ }
+ },
+ Permissions = new Dictionary
+ {
+ ["contents"] = "read",
+ ["id-token"] = "write"
+ }
+ };
+
+ // Generate a YAML job for each workflow job
+ foreach (var job in workflow.Jobs)
+ {
+ var jobYaml = GenerateJob(job, scheduling);
+ workflowYaml.Jobs[job.Id] = jobYaml;
+ }
+
+ return workflowYaml;
+ }
+
+ private static JobYaml GenerateJob(GitHubActionsJob job, SchedulingResult scheduling)
+ {
+ var steps = new List();
+
+ // Boilerplate: checkout
+ steps.Add(new StepYaml
+ {
+ Name = "Checkout code",
+ Uses = "actions/checkout@v4"
+ });
+
+ // Boilerplate: setup .NET
+ steps.Add(new StepYaml
+ {
+ Name = "Setup .NET",
+ Uses = "actions/setup-dotnet@v4",
+ With = new Dictionary
+ {
+ ["dotnet-version"] = "10.0.x"
+ }
+ });
+
+ // Boilerplate: install Aspire CLI
+ steps.Add(new StepYaml
+ {
+ Name = "Install Aspire CLI",
+ Run = "dotnet tool install -g aspire"
+ });
+
+ // Download state artifacts from dependency jobs
+ var jobDeps = scheduling.JobDependencies.GetValueOrDefault(job.Id);
+ if (jobDeps is { Count: > 0 })
+ {
+ foreach (var depJobId in jobDeps)
+ {
+ steps.Add(new StepYaml
+ {
+ Name = $"Download state from {depJobId}",
+ Uses = "actions/download-artifact@v4",
+ With = new Dictionary
+ {
+ ["name"] = $"{StateArtifactPrefix}{depJobId}",
+ ["path"] = StatePath
+ }
+ });
+ }
+ }
+
+ // TODO: Auth/setup steps will be added here when PipelineSetupRequirementAnnotation is implemented.
+ // For now, users should add cloud-specific authentication steps manually.
+
+ // Run aspire do for this job's steps
+ steps.Add(new StepYaml
+ {
+ Name = "Run pipeline steps",
+ Run = $"aspire do --continue --job {job.Id}",
+ Env = new Dictionary
+ {
+ ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
+ }
+ });
+
+ // Upload state artifacts for downstream jobs
+ steps.Add(new StepYaml
+ {
+ Name = "Upload state",
+ Uses = "actions/upload-artifact@v4",
+ With = new Dictionary
+ {
+ ["name"] = $"{StateArtifactPrefix}{job.Id}",
+ ["path"] = StatePath,
+ ["if-no-files-found"] = "ignore"
+ }
+ });
+
+ // Build needs list from scheduling result
+ List? needs = null;
+ if (scheduling.JobDependencies.TryGetValue(job.Id, out var deps) && deps.Count > 0)
+ {
+ needs = [.. deps];
+ }
+
+ return new JobYaml
+ {
+ Name = job.DisplayName,
+ RunsOn = job.RunsOn,
+ Needs = needs,
+ Steps = steps
+ };
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs
new file mode 100644
index 00000000000..f8b336a0737
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+///
+/// Represents a complete GitHub Actions workflow YAML document.
+///
+internal sealed class WorkflowYaml
+{
+ public required string Name { get; init; }
+
+ public WorkflowTriggers On { get; init; } = new();
+
+ public Dictionary? Permissions { get; init; }
+
+ public Dictionary Jobs { get; init; } = new(StringComparer.Ordinal);
+}
+
+///
+/// Represents the trigger configuration for a workflow.
+///
+internal sealed class WorkflowTriggers
+{
+ public bool WorkflowDispatch { get; init; } = true;
+
+ public PushTrigger? Push { get; init; }
+}
+
+///
+/// Represents the push trigger configuration.
+///
+internal sealed class PushTrigger
+{
+ public List Branches { get; init; } = [];
+}
+
+///
+/// Represents a job in the workflow.
+///
+internal sealed class JobYaml
+{
+ public string? Name { get; init; }
+
+ public string RunsOn { get; init; } = "ubuntu-latest";
+
+ public string? If { get; init; }
+
+ public string? Environment { get; init; }
+
+ public List? Needs { get; init; }
+
+ public Dictionary? Permissions { get; init; }
+
+ public Dictionary? Env { get; init; }
+
+ public ConcurrencyYaml? Concurrency { get; init; }
+
+ public List Steps { get; init; } = [];
+}
+
+///
+/// Represents a step within a job.
+///
+internal sealed class StepYaml
+{
+ public string? Name { get; init; }
+
+ public string? Uses { get; init; }
+
+ public string? Run { get; init; }
+
+ public Dictionary? With { get; init; }
+
+ public Dictionary? Env { get; init; }
+
+ public string? Id { get; init; }
+}
+
+///
+/// Represents concurrency configuration for a job.
+///
+internal sealed class ConcurrencyYaml
+{
+ public required string Group { get; init; }
+
+ public bool CancelInProgress { get; init; }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs
new file mode 100644
index 00000000000..52a0d2f64fd
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs
@@ -0,0 +1,236 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.Text;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+///
+/// Serializes to a YAML string.
+///
+internal static class WorkflowYamlSerializer
+{
+ public static string Serialize(WorkflowYaml workflow)
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine(CultureInfo.InvariantCulture, $"name: {workflow.Name}");
+ sb.AppendLine();
+
+ WriteOn(sb, workflow.On);
+
+ if (workflow.Permissions is { Count: > 0 })
+ {
+ sb.AppendLine();
+ sb.AppendLine("permissions:");
+ foreach (var (key, value) in workflow.Permissions)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {value}");
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine("jobs:");
+
+ var firstJob = true;
+ foreach (var (jobId, job) in workflow.Jobs)
+ {
+ if (!firstJob)
+ {
+ sb.AppendLine();
+ }
+ firstJob = false;
+
+ WriteJob(sb, jobId, job);
+ }
+
+ return sb.ToString();
+ }
+
+ private static void WriteOn(StringBuilder sb, WorkflowTriggers triggers)
+ {
+ sb.AppendLine("on:");
+
+ if (triggers.WorkflowDispatch)
+ {
+ sb.AppendLine(" workflow_dispatch:");
+ }
+
+ if (triggers.Push is not null)
+ {
+ sb.AppendLine(" push:");
+ if (triggers.Push.Branches.Count > 0)
+ {
+ sb.AppendLine(" branches:");
+ foreach (var branch in triggers.Push.Branches)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" - {branch}");
+ }
+ }
+ }
+ }
+
+ private static void WriteJob(StringBuilder sb, string jobId, JobYaml job)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {jobId}:");
+
+ if (job.Name is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" name: {YamlQuote(job.Name)}");
+ }
+
+ sb.AppendLine(CultureInfo.InvariantCulture, $" runs-on: {job.RunsOn}");
+
+ if (job.If is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" if: {job.If}");
+ }
+
+ if (job.Environment is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" environment: {job.Environment}");
+ }
+
+ if (job.Needs is { Count: > 0 })
+ {
+ if (job.Needs.Count == 1)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" needs: {job.Needs[0]}");
+ }
+ else
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" needs: [{string.Join(", ", job.Needs)}]");
+ }
+ }
+
+ if (job.Concurrency is not null)
+ {
+ sb.AppendLine(" concurrency:");
+ sb.AppendLine(CultureInfo.InvariantCulture, $" group: {job.Concurrency.Group}");
+ sb.AppendLine(CultureInfo.InvariantCulture, $" cancel-in-progress: {(job.Concurrency.CancelInProgress ? "true" : "false")}");
+ }
+
+ if (job.Permissions is { Count: > 0 })
+ {
+ sb.AppendLine(" permissions:");
+ foreach (var (key, value) in job.Permissions)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {value}");
+ }
+ }
+
+ if (job.Env is { Count: > 0 })
+ {
+ sb.AppendLine(" env:");
+ foreach (var (key, value) in job.Env)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}");
+ }
+ }
+
+ if (job.Steps.Count > 0)
+ {
+ sb.AppendLine(" steps:");
+ foreach (var step in job.Steps)
+ {
+ WriteStep(sb, step);
+ }
+ }
+ }
+
+ private static void WriteStep(StringBuilder sb, StepYaml step)
+ {
+ // First property determines the leading dash
+ if (step.Name is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" - name: {YamlQuote(step.Name)}");
+ }
+ else if (step.Uses is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" - uses: {step.Uses}");
+ }
+ else if (step.Run is not null)
+ {
+ WriteRunStep(sb, step, leadWithDash: true);
+ return;
+ }
+ else
+ {
+ return;
+ }
+
+ if (step.Id is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" id: {step.Id}");
+ }
+
+ if (step.Uses is not null && step.Name is not null)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" uses: {step.Uses}");
+ }
+
+ if (step.With is { Count: > 0 })
+ {
+ sb.AppendLine(" with:");
+ foreach (var (key, value) in step.With)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}");
+ }
+ }
+
+ if (step.Env is { Count: > 0 })
+ {
+ sb.AppendLine(" env:");
+ foreach (var (key, value) in step.Env)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}");
+ }
+ }
+
+ if (step.Run is not null)
+ {
+ WriteRunStep(sb, step, leadWithDash: false);
+ }
+ }
+
+ private static void WriteRunStep(StringBuilder sb, StepYaml step, bool leadWithDash)
+ {
+ var indent = leadWithDash ? " " : " ";
+ var prefix = leadWithDash ? "- " : "";
+
+ if (step.Run!.Contains('\n'))
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}{prefix}run: |");
+ foreach (var line in step.Run.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ sb.AppendLine();
+ }
+ else
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"{indent} {line}");
+ }
+ }
+ }
+ else
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}{prefix}run: {step.Run}");
+ }
+ }
+
+ private static string YamlQuote(string value)
+ {
+ if (value.Contains('\'') || value.Contains('"') || value.Contains(':') ||
+ value.Contains('#') || value.Contains('{') || value.Contains('}') ||
+ value.Contains('[') || value.Contains(']') || value.Contains('&') ||
+ value.Contains('*') || value.Contains('!') || value.Contains('|') ||
+ value.Contains('>') || value.Contains('%') || value.Contains('@'))
+ {
+ return $"'{value.Replace("'", "''")}'";
+ }
+
+ return value;
+ }
+}
diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
index f200184e3fd..bcb020d8980 100644
--- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
@@ -99,7 +99,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder
public IDistributedApplicationEventing Eventing { get; } = new DistributedApplicationEventing();
///
- public IDistributedApplicationPipeline Pipeline { get; } = new DistributedApplicationPipeline();
+ public IDistributedApplicationPipeline Pipeline { get; }
///
public IFileSystemService FileSystemService => _directoryService;
@@ -177,6 +177,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
{
ArgumentNullException.ThrowIfNull(options);
+ Pipeline = new DistributedApplicationPipeline(new DistributedApplicationModel(Resources));
+
_options = options;
var innerBuilderOptions = new HostApplicationBuilderSettings();
diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
index 8e2c2f82941..f7eea973378 100644
--- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
+++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
@@ -25,12 +25,15 @@ internal sealed class DistributedApplicationPipeline : IDistributedApplicationPi
{
private readonly List _steps = [];
private readonly List> _configurationCallbacks = [];
+ private readonly DistributedApplicationModel _model;
// Store resolved pipeline data for diagnostics
private List? _lastResolvedSteps;
- public DistributedApplicationPipeline()
+ public DistributedApplicationPipeline(DistributedApplicationModel model)
{
+ _model = model;
+
// Dependency order
// {verb} -> {user steps} -> {verb}-prereq
@@ -266,12 +269,21 @@ public DistributedApplicationPipeline()
});
}
+ ///
+ /// Initializes a new instance of the class with an empty model.
+ /// Used for testing scenarios where the model is not needed.
+ ///
+ public DistributedApplicationPipeline() : this(new DistributedApplicationModel(Array.Empty()))
+ {
+ }
+
public bool HasSteps => _steps.Count > 0;
public void AddStep(string name,
Func action,
object? dependsOn = null,
- object? requiredBy = null)
+ object? requiredBy = null,
+ IPipelineStepTarget? scheduledBy = null)
{
if (_steps.Any(s => s.Name == name))
{
@@ -282,7 +294,8 @@ public void AddStep(string name,
var step = new PipelineStep
{
Name = name,
- Action = action
+ Action = action,
+ ScheduledBy = scheduledBy
};
if (dependsOn != null)
@@ -351,12 +364,59 @@ public void AddStep(PipelineStep step)
_steps.Add(step);
}
+ public void ScheduleStep(string stepName, IPipelineStepTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(stepName);
+ ArgumentNullException.ThrowIfNull(target);
+
+ var step = _steps.FirstOrDefault(s => s.Name == stepName)
+ ?? throw new InvalidOperationException(
+ $"No step with the name '{stepName}' exists in the pipeline. " +
+ $"Use AddStep to add the step first, or check the step name is correct.");
+
+ step.ScheduledBy = target;
+ }
+
public void AddPipelineConfiguration(Func callback)
{
ArgumentNullException.ThrowIfNull(callback);
_configurationCallbacks.Add(callback);
}
+ public async Task GetEnvironmentAsync(CancellationToken cancellationToken = default)
+ {
+ var relevantEnvironments = new List();
+ var checkContext = new PipelineEnvironmentCheckContext { CancellationToken = cancellationToken };
+
+ foreach (var resource in _model.Resources.OfType())
+ {
+ if (resource is IResource resourceWithAnnotations &&
+ resourceWithAnnotations.TryGetAnnotationsOfType(out var annotations))
+ {
+ foreach (var annotation in annotations)
+ {
+ if (await annotation.CheckAsync(checkContext).ConfigureAwait(false))
+ {
+ relevantEnvironments.Add(resource);
+ break;
+ }
+ }
+ }
+ }
+
+ if (relevantEnvironments.Count > 1)
+ {
+ var environmentNames = string.Join(", ", relevantEnvironments.Select(e => ((IResource)e).Name));
+ throw new InvalidOperationException(
+ $"Multiple pipeline environments reported as relevant for the current invocation: {environmentNames}. " +
+ $"Only one pipeline environment can be active at a time.");
+ }
+
+ return relevantEnvironments.Count == 1
+ ? relevantEnvironments[0]
+ : new LocalPipelineEnvironment();
+ }
+
public async Task ExecuteAsync(PipelineContext context)
{
var annotationSteps = await CollectStepsFromAnnotationsAsync(context).ConfigureAwait(false);
@@ -839,6 +899,18 @@ private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContex
{
try
{
+ // If the step has a restore callback, try it first. If it returns true,
+ // the step is considered already complete (e.g., restored from CI/CD state
+ // persisted by a previous job) and its Action is not invoked.
+ if (step.TryRestoreStepAsync is not null)
+ {
+ var restored = await step.TryRestoreStepAsync(stepContext).ConfigureAwait(false);
+ if (restored)
+ {
+ return;
+ }
+ }
+
await step.Action(stepContext).ConfigureAwait(false);
}
catch (DistributedApplicationException)
diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
index d5845632e08..1bc939a5b49 100644
--- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
+++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
@@ -20,10 +20,12 @@ public interface IDistributedApplicationPipeline
/// The action to execute for this step.
/// The name of the step this step depends on, or a list of step names.
/// The name of the step that requires this step, or a list of step names.
+ /// The pipeline step target to schedule this step onto (e.g., a CI/CD job).
void AddStep(string name,
Func action,
object? dependsOn = null,
- object? requiredBy = null);
+ object? requiredBy = null,
+ IPipelineStepTarget? scheduledBy = null);
///
/// Adds a deployment step to the pipeline.
@@ -31,6 +33,18 @@ void AddStep(string name,
/// The pipeline step to add.
void AddStep(PipelineStep step);
+ ///
+ /// Schedules an existing pipeline step onto a specific target (e.g., a CI/CD job).
+ /// This is useful for scheduling built-in steps that are already registered by
+ /// integrations or the core platform.
+ ///
+ /// The name of the existing step to schedule.
+ /// The pipeline step target to schedule the step onto.
+ ///
+ /// Thrown when no step with the specified name exists in the pipeline.
+ ///
+ void ScheduleStep(string stepName, IPipelineStepTarget target);
+
///
/// Registers a callback to be executed during the pipeline configuration phase.
///
@@ -43,4 +57,18 @@ void AddStep(string name,
/// The pipeline context for the execution.
/// A task representing the asynchronous operation.
Task ExecuteAsync(PipelineContext context);
+
+ ///
+ /// Resolves the active pipeline environment for the current invocation.
+ ///
+ /// A token to cancel the operation.
+ ///
+ /// The active . Returns a
+ /// if no declared environment passes its relevance check. Throws if multiple environments
+ /// report as relevant.
+ ///
+ ///
+ /// Thrown when multiple pipeline environments report as relevant for the current invocation.
+ ///
+ Task GetEnvironmentAsync(CancellationToken cancellationToken = default);
}
diff --git a/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs
new file mode 100644
index 00000000000..72de5785bc1
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// Represents an execution environment for pipeline steps, such as local execution,
+/// GitHub Actions, Azure DevOps, or other CI/CD systems.
+///
+///
+/// Pipeline environment resources are added to the distributed application model to indicate
+/// where pipeline steps should be executed. Use
+/// to register a relevance check that determines whether this environment is active for the
+/// current invocation.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public interface IPipelineEnvironment : IResource
+{
+}
diff --git a/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs b/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs
new file mode 100644
index 00000000000..bd6d6444c8c
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// Represents a target that pipeline steps can be scheduled onto, such as a job
+/// in a CI/CD workflow.
+///
+///
+/// When a pipeline step has a value, it indicates
+/// that the step should execute in the context of the specified target (e.g., a specific
+/// job in a GitHub Actions workflow). The scheduling resolver validates that step-to-target
+/// assignments are consistent with the step dependency graph.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public interface IPipelineStepTarget
+{
+ ///
+ /// Gets the unique identifier for this target within its pipeline environment.
+ ///
+ string Id { get; }
+
+ ///
+ /// Gets the pipeline environment that owns this target.
+ ///
+ IPipelineEnvironment Environment { get; }
+}
diff --git a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs
new file mode 100644
index 00000000000..761a9e51fcb
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// Represents the local execution environment for pipeline steps.
+///
+///
+/// This is the implicit fallback environment returned by
+///
+/// when no declared resource passes its relevance check.
+/// It is not added to the application model.
+///
+internal sealed class LocalPipelineEnvironment() : Resource("local"), IPipelineEnvironment
+{
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs
new file mode 100644
index 00000000000..5d16943a223
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// An annotation that provides a relevance check for a pipeline environment resource.
+///
+///
+/// Apply this annotation to an resource to indicate
+/// under what conditions the environment is active for the current invocation. For example,
+/// a GitHub Actions environment might check for the GITHUB_ACTIONS environment variable.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineEnvironmentCheckAnnotation(
+ Func> checkAsync) : IResourceAnnotation
+{
+ ///
+ /// Evaluates whether the pipeline environment is relevant for the current invocation.
+ ///
+ /// The context for the check.
+ /// A task that resolves to true if this environment is relevant; otherwise, false.
+ public Task CheckAsync(PipelineEnvironmentCheckContext context) => checkAsync(context);
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs
new file mode 100644
index 00000000000..403bfc615ab
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// Provides context for a pipeline environment relevance check.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineEnvironmentCheckContext
+{
+ ///
+ /// Gets the cancellation token for the check operation.
+ ///
+ public required CancellationToken CancellationToken { get; init; }
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs
index 7cd6ab2b744..2f16030468c 100644
--- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs
+++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs
@@ -57,6 +57,35 @@ public class PipelineStep
///
public IResource? Resource { get; set; }
+ ///
+ /// Gets or sets the pipeline step target that this step is scheduled onto.
+ ///
+ ///
+ /// When set, the step is intended to execute in the context of the specified target
+ /// (e.g., a specific job in a CI/CD workflow). The scheduling resolver validates that
+ /// step-to-target assignments are consistent with the step dependency graph.
+ /// When null, the step is assigned to a default target or runs locally.
+ ///
+ public IPipelineStepTarget? ScheduledBy { get; set; }
+
+ ///
+ /// Gets or initializes an optional callback that attempts to restore this step from prior state.
+ ///
+ ///
+ ///
+ /// When set, the pipeline executor calls this callback before executing the step's .
+ /// If the callback returns true, the step is considered already complete and its
+ /// is not invoked. If it returns false, the step executes normally.
+ ///
+ ///
+ /// This enables CI/CD scenarios where pipeline execution is distributed across multiple jobs
+ /// or machines. A step that ran in a previous job can persist its outputs (e.g., via
+ /// ), and when the pipeline resumes on a different machine,
+ /// the callback restores that state and signals that re-execution is unnecessary.
+ ///
+ ///
+ public Func>? TryRestoreStepAsync { get; init; }
+
///
/// Adds a dependency on another step.
///
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj
new file mode 100644
index 00000000000..8286d54c982
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(DefaultTargetFramework)
+
+
+
+
+
+
+
+
+
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs
new file mode 100644
index 00000000000..af47ee730f6
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs
@@ -0,0 +1,106 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Tests;
+
+[Trait("Partition", "4")]
+public class GitHubActionsWorkflowResourceTests
+{
+ [Fact]
+ public void WorkflowFileName_MatchesResourceName()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+
+ Assert.Equal("deploy.yml", workflow.WorkflowFileName);
+ }
+
+ [Fact]
+ public void AddJob_CreatesJobWithCorrectId()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job = workflow.AddJob("build");
+
+ Assert.Equal("build", job.Id);
+ Assert.Same(workflow, job.Workflow);
+ }
+
+ [Fact]
+ public void AddJob_MultipleJobs_AllTracked()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var build = workflow.AddJob("build");
+ var test = workflow.AddJob("test");
+ var deploy = workflow.AddJob("deploy");
+
+ Assert.Equal(3, workflow.Jobs.Count);
+ Assert.Same(build, workflow.Jobs[0]);
+ Assert.Same(test, workflow.Jobs[1]);
+ Assert.Same(deploy, workflow.Jobs[2]);
+ }
+
+ [Fact]
+ public void AddJob_DuplicateId_Throws()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.AddJob("build");
+
+ var ex = Assert.Throws(() => workflow.AddJob("build"));
+ Assert.Contains("build", ex.Message);
+ }
+
+ [Fact]
+ public void Job_DependsOn_ById()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.AddJob("build");
+ var deploy = workflow.AddJob("deploy");
+
+ deploy.DependsOn("build");
+
+ Assert.Single(deploy.DependsOnJobs);
+ Assert.Equal("build", deploy.DependsOnJobs[0]);
+ }
+
+ [Fact]
+ public void Job_DependsOn_ByReference()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var build = workflow.AddJob("build");
+ var deploy = workflow.AddJob("deploy");
+
+ deploy.DependsOn(build);
+
+ Assert.Single(deploy.DependsOnJobs);
+ Assert.Equal("build", deploy.DependsOnJobs[0]);
+ }
+
+ [Fact]
+ public void Job_DefaultRunsOn_IsUbuntuLatest()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job = workflow.AddJob("build");
+
+ Assert.Equal("ubuntu-latest", job.RunsOn);
+ }
+
+ [Fact]
+ public void Job_IPipelineStepTarget_EnvironmentIsWorkflow()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job = workflow.AddJob("build");
+
+ IPipelineStepTarget target = job;
+
+ Assert.Same(workflow, target.Environment);
+ }
+
+ [Fact]
+ public void Workflow_ImplementsIPipelineEnvironment()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+
+ Assert.IsAssignableFrom(workflow);
+ }
+}
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs
new file mode 100644
index 00000000000..e2ed3d1c715
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs
@@ -0,0 +1,291 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Tests;
+
+[Trait("Partition", "4")]
+public class SchedulingResolverTests
+{
+ [Fact]
+ public void Resolve_TwoStepsTwoJobs_ValidDependency_CorrectNeeds()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-step", scheduledBy: buildJob);
+ var deployStep = CreateStep("deploy-step", deployJob, "build-step");
+
+ var result = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+
+ Assert.Same(buildJob, result.StepToJob["build-step"]);
+ Assert.Same(deployJob, result.StepToJob["deploy-step"]);
+ Assert.Contains("build", result.JobDependencies["deploy"]);
+ }
+
+ [Fact]
+ public void Resolve_FanOut_OneStepDependsOnThreeAcrossThreeJobs()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+ var job3 = workflow.AddJob("job3");
+ var collectJob = workflow.AddJob("collect");
+
+ var step1 = CreateStep("step1", scheduledBy: job1);
+ var step2 = CreateStep("step2", scheduledBy: job2);
+ var step3 = CreateStep("step3", scheduledBy: job3);
+ var collectStep = CreateStep("collect-step", scheduledBy: collectJob,
+ dependsOn: ["step1", "step2", "step3"]);
+
+ var result = SchedulingResolver.Resolve([step1, step2, step3, collectStep], workflow);
+
+ var collectDeps = result.JobDependencies["collect"];
+ Assert.Contains("job1", collectDeps);
+ Assert.Contains("job2", collectDeps);
+ Assert.Contains("job3", collectDeps);
+ }
+
+ [Fact]
+ public void Resolve_FanIn_ThreeStepsDependOnOne()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var setupJob = workflow.AddJob("setup");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+ var job3 = workflow.AddJob("job3");
+
+ var setupStep = CreateStep("setup-step", scheduledBy: setupJob);
+ var step1 = CreateStep("step1", job1, "setup-step");
+ var step2 = CreateStep("step2", job2, "setup-step");
+ var step3 = CreateStep("step3", job3, "setup-step");
+
+ var result = SchedulingResolver.Resolve([setupStep, step1, step2, step3], workflow);
+
+ Assert.Contains("setup", result.JobDependencies["job1"]);
+ Assert.Contains("setup", result.JobDependencies["job2"]);
+ Assert.Contains("setup", result.JobDependencies["job3"]);
+ }
+
+ [Fact]
+ public void Resolve_Diamond_ValidDagAcrossJobs()
+ {
+ // A → B, A → C, B → D, C → D
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var jobA = workflow.AddJob("jobA");
+ var jobB = workflow.AddJob("jobB");
+ var jobC = workflow.AddJob("jobC");
+ var jobD = workflow.AddJob("jobD");
+
+ var stepA = CreateStep("A", scheduledBy: jobA);
+ var stepB = CreateStep("B", jobB, "A");
+ var stepC = CreateStep("C", jobC, "A");
+ var stepD = CreateStep("D", jobD, ["B", "C"]);
+
+ var result = SchedulingResolver.Resolve([stepA, stepB, stepC, stepD], workflow);
+
+ Assert.Contains("jobA", result.JobDependencies["jobB"]);
+ Assert.Contains("jobA", result.JobDependencies["jobC"]);
+ Assert.Contains("jobB", result.JobDependencies["jobD"]);
+ Assert.Contains("jobC", result.JobDependencies["jobD"]);
+ }
+
+ [Fact]
+ public void Resolve_Cycle_ThrowsSchedulingValidationException()
+ {
+ // Step A on job1 depends on Step B on job2 depends on Step C on job1 depends on Step A
+ // This creates job1 → job2 → job1 cycle
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+
+ var stepA = CreateStep("A", job1, "C");
+ var stepB = CreateStep("B", job2, "A");
+ var stepC = CreateStep("C", job1, "B");
+
+ var ex = Assert.Throws(
+ () => SchedulingResolver.Resolve([stepA, stepB, stepC], workflow));
+
+ Assert.Contains("circular dependency", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_DefaultJob_UnscheduledStepsGrouped()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+
+ var step1 = CreateStep("step1"); // No scheduledBy — goes to default job
+ var step2 = CreateStep("step2"); // No scheduledBy — goes to default job
+ var step3 = CreateStep("step3", scheduledBy: buildJob);
+
+ var result = SchedulingResolver.Resolve([step1, step2, step3], workflow);
+
+ // step1 and step2 should be on the default job (first job = build)
+ Assert.Same(buildJob, result.StepToJob["step1"]);
+ Assert.Same(buildJob, result.StepToJob["step2"]);
+ Assert.Same(buildJob, result.StepToJob["step3"]);
+ }
+
+ [Fact]
+ public void Resolve_MixedScheduledAndUnscheduled()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var publishJob = workflow.AddJob("publish");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build"); // No scheduledBy → default (first job = publish)
+ var publishStep = CreateStep("publish", publishJob, "build");
+ var deployStep = CreateStep("deploy", deployJob, "publish");
+
+ var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow);
+
+ // build goes to default job (publish, the first job)
+ Assert.Same(publishJob, result.StepToJob["build"]);
+ Assert.Same(publishJob, result.StepToJob["publish"]);
+ Assert.Same(deployJob, result.StepToJob["deploy"]);
+
+ // deploy depends on publish job (since deploy-step depends on publish-step which is on publish)
+ Assert.Contains("publish", result.JobDependencies["deploy"]);
+ }
+
+ [Fact]
+ public void Resolve_SingleJob_AllStepsOnSameJob_NoJobDependencies()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job = workflow.AddJob("main");
+
+ var step1 = CreateStep("step1", scheduledBy: job);
+ var step2 = CreateStep("step2", job, "step1");
+ var step3 = CreateStep("step3", job, "step2");
+
+ var result = SchedulingResolver.Resolve([step1, step2, step3], workflow);
+
+ // All on same job, so no cross-job dependencies
+ Assert.Empty(result.JobDependencies["main"]);
+ }
+
+ [Fact]
+ public void Resolve_NoJobs_CreatesDefaultJob()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+
+ var step1 = CreateStep("step1");
+ var step2 = CreateStep("step2", null, "step1");
+
+ var result = SchedulingResolver.Resolve([step1, step2], workflow);
+
+ Assert.Equal("default", result.DefaultJob.Id);
+ Assert.Same(result.DefaultJob, result.StepToJob["step1"]);
+ Assert.Same(result.DefaultJob, result.StepToJob["step2"]);
+ }
+
+ [Fact]
+ public void Resolve_StepsGroupedPerJob_Correctly()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+
+ var stepA = CreateStep("A", scheduledBy: job1);
+ var stepB = CreateStep("B", scheduledBy: job1);
+ var stepC = CreateStep("C", scheduledBy: job2);
+
+ var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow);
+
+ Assert.Equal(2, result.StepsPerJob["job1"].Count);
+ Assert.Contains(result.StepsPerJob["job1"], s => s.Name == "A");
+ Assert.Contains(result.StepsPerJob["job1"], s => s.Name == "B");
+ Assert.Single(result.StepsPerJob["job2"]);
+ Assert.Equal("C", result.StepsPerJob["job2"][0].Name);
+ }
+
+ [Fact]
+ public void Resolve_ExplicitJobDependency_Preserved()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var setupJob = workflow.AddJob("setup");
+ var deployJob = workflow.AddJob("deploy");
+
+ // Explicit job-level dependency (not from steps)
+ deployJob.DependsOn(setupJob);
+
+ var stepA = CreateStep("A", scheduledBy: setupJob);
+ var stepB = CreateStep("B", scheduledBy: deployJob);
+
+ var result = SchedulingResolver.Resolve([stepA, stepB], workflow);
+
+ Assert.Contains("setup", result.JobDependencies["deploy"]);
+ }
+
+ [Fact]
+ public void Resolve_StepFromDifferentWorkflow_Throws()
+ {
+ var workflow1 = new GitHubActionsWorkflowResource("deploy");
+ var workflow2 = new GitHubActionsWorkflowResource("other");
+ var job = workflow2.AddJob("build");
+
+ var step = CreateStep("step1", scheduledBy: job);
+
+ var ex = Assert.Throws(
+ () => SchedulingResolver.Resolve([step], workflow1));
+
+ Assert.Contains("different workflow", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_ExplicitJobDependency_CreatesCycle_Throws()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+
+ // Explicit cycle: job1 → job2 → job1
+ job1.DependsOn(job2);
+ job2.DependsOn(job1);
+
+ var stepA = CreateStep("A", scheduledBy: job1);
+ var stepB = CreateStep("B", scheduledBy: job2);
+
+ var ex = Assert.Throws(
+ () => SchedulingResolver.Resolve([stepA, stepB], workflow));
+
+ Assert.Contains("circular dependency", ex.Message);
+ }
+
+ // Helper methods
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = scheduledBy
+ };
+ }
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = [dependsOn],
+ ScheduledBy = scheduledBy
+ };
+ }
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string[] dependsOn)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = [.. dependsOn],
+ ScheduledBy = scheduledBy
+ };
+ }
+}
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt
new file mode 100644
index 00000000000..88e3439d227
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt
@@ -0,0 +1,34 @@
+name: deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ default:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job default
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-default
+ path: .aspire/state/
+ if-no-files-found: ignore
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt
new file mode 100644
index 00000000000..e3996654679
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt
@@ -0,0 +1,35 @@
+name: build-windows
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ build-win:
+ name: Build on Windows
+ runs-on: windows-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job build-win
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-build-win
+ path: .aspire/state/
+ if-no-files-found: ignore
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt
new file mode 100644
index 00000000000..9a95c438337
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt
@@ -0,0 +1,98 @@
+name: ci-cd
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job build
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ if-no-files-found: ignore
+
+ test:
+ name: Run Tests
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job test
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-test
+ path: .aspire/state/
+ if-no-files-found: ignore
+
+ deploy:
+ name: Deploy
+ runs-on: ubuntu-latest
+ needs: [build, test]
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ - name: Download state from test
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-state-test
+ path: .aspire/state/
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job deploy
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-deploy
+ path: .aspire/state/
+ if-no-files-found: ignore
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt
new file mode 100644
index 00000000000..96bcb88a3a3
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt
@@ -0,0 +1,64 @@
+name: deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ build:
+ name: 'Build & Publish'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job build
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ if-no-files-found: ignore
+
+ deploy:
+ name: Deploy to Azure
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - name: Install Aspire CLI
+ run: dotnet tool install -g aspire
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-state-build
+ path: .aspire/state/
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do --continue --job deploy
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-state-deploy
+ path: .aspire/state/
+ if-no-files-found: ignore
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs
new file mode 100644
index 00000000000..3dc7ed4dc6b
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs
@@ -0,0 +1,223 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Tests;
+
+[Trait("Partition", "4")]
+public class WorkflowYamlGeneratorTests
+{
+ [Fact]
+ public void Generate_BareWorkflow_CreatesDefaultJobWithBoilerplate()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app");
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.Equal("deploy", yaml.Name);
+ Assert.Single(yaml.Jobs);
+ Assert.True(yaml.Jobs.ContainsKey("default"));
+
+ var job = yaml.Jobs["default"];
+ Assert.Contains(job.Steps, s => s.Name == "Checkout code");
+ Assert.Contains(job.Steps, s => s.Name == "Setup .NET");
+ Assert.Contains(job.Steps, s => s.Name == "Install Aspire CLI");
+ Assert.Contains(job.Steps, s => s.Run?.Contains("aspire do --continue --job default") == true);
+ }
+
+ [Fact]
+ public void Generate_TwoJobs_CorrectNeedsDependencies()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-app", buildJob);
+ var deployStep = CreateStep("deploy-app", deployJob, "build-app");
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.Equal(2, yaml.Jobs.Count);
+ Assert.Null(yaml.Jobs["build"].Needs);
+ Assert.NotNull(yaml.Jobs["deploy"].Needs);
+ Assert.Contains("build", yaml.Jobs["deploy"].Needs!);
+ }
+
+ [Fact]
+ public void Generate_MultipleJobDeps_NeedsContainsAll()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("build");
+ var job2 = workflow.AddJob("test");
+ var job3 = workflow.AddJob("deploy");
+
+ var step1 = CreateStep("build-app", job1);
+ var step2 = CreateStep("run-tests", job2);
+ var step3 = CreateStep("deploy-app", job3, ["build-app", "run-tests"]);
+
+ var scheduling = SchedulingResolver.Resolve([step1, step2, step3], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.NotNull(yaml.Jobs["deploy"].Needs);
+ Assert.Contains("build", yaml.Jobs["deploy"].Needs!);
+ Assert.Contains("test", yaml.Jobs["deploy"].Needs!);
+ }
+
+ [Fact]
+ public void Generate_DependentJobs_HasStateDownloadSteps()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-app", buildJob);
+ var deployStep = CreateStep("deploy-app", deployJob, "build-app");
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ // deploy job should download state from build
+ var deployJobYaml = yaml.Jobs["deploy"];
+ Assert.Contains(deployJobYaml.Steps, s =>
+ s.Name == "Download state from build" &&
+ s.Uses == "actions/download-artifact@v4");
+ }
+
+ [Fact]
+ public void Generate_AllJobs_HaveStateUploadStep()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-app", buildJob);
+ var deployStep = CreateStep("deploy-app", deployJob, "build-app");
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ foreach (var (_, jobYaml) in yaml.Jobs)
+ {
+ Assert.Contains(jobYaml.Steps, s =>
+ s.Name == "Upload state" &&
+ s.Uses == "actions/upload-artifact@v4");
+ }
+ }
+
+ [Fact]
+ public void Generate_JobRunsOn_MatchesJobConfiguration()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ buildJob.RunsOn = "windows-latest";
+
+ var step = CreateStep("build-app", buildJob);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.Equal("windows-latest", yaml.Jobs["build"].RunsOn);
+ }
+
+ [Fact]
+ public void Generate_JobDisplayName_IsPreserved()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ buildJob.DisplayName = "Build Application";
+
+ var step = CreateStep("build-app", buildJob);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.Equal("Build Application", yaml.Jobs["build"].Name);
+ }
+
+ [Fact]
+ public void Generate_DefaultTriggers_WorkflowDispatchAndPush()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app");
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ Assert.True(yaml.On.WorkflowDispatch);
+ Assert.NotNull(yaml.On.Push);
+ Assert.Contains("main", yaml.On.Push!.Branches);
+ }
+
+ [Fact]
+ public void SerializeRoundTrip_ProducesValidYaml()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+ buildJob.DisplayName = "Build & Publish";
+
+ var buildStep = CreateStep("build-app", buildJob);
+ var deployStep = CreateStep("deploy-app", deployJob, "build-app");
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow);
+ var yamlString = WorkflowYamlSerializer.Serialize(yamlModel);
+
+ // Verify key structural elements
+ Assert.Contains("name: deploy", yamlString);
+ Assert.Contains("workflow_dispatch:", yamlString);
+ Assert.Contains("push:", yamlString);
+ Assert.Contains("branches:", yamlString);
+ Assert.Contains("- main", yamlString);
+ Assert.Contains(" build:", yamlString);
+ Assert.Contains(" deploy:", yamlString);
+ Assert.Contains("needs:", yamlString);
+ Assert.Contains("actions/checkout@v4", yamlString);
+ Assert.Contains("actions/setup-dotnet@v4", yamlString);
+ Assert.Contains("aspire do --continue --job build", yamlString);
+ Assert.Contains("aspire do --continue --job deploy", yamlString);
+ Assert.Contains("actions/upload-artifact@v4", yamlString);
+ Assert.Contains("actions/download-artifact@v4", yamlString);
+ Assert.Contains("'Build & Publish'", yamlString); // Quoted because of &
+ }
+
+ // Helpers
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = scheduledBy
+ };
+ }
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = [dependsOn],
+ ScheduledBy = scheduledBy
+ };
+ }
+
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string[] dependsOn)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = [.. dependsOn],
+ ScheduledBy = scheduledBy
+ };
+ }
+}
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs
new file mode 100644
index 00000000000..ef5bfa7e264
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs
@@ -0,0 +1,123 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Tests;
+
+[Trait("Partition", "4")]
+public class WorkflowYamlSnapshotTests
+{
+ [Fact]
+ public Task BareWorkflow_SingleDefaultJob()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = new PipelineStep
+ {
+ Name = "build-app",
+ Action = _ => Task.CompletedTask
+ };
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+ var output = WorkflowYamlSerializer.Serialize(yaml);
+
+ return Verify(output).UseDirectory("Snapshots");
+ }
+
+ [Fact]
+ public Task TwoJobPipeline_BuildAndDeploy()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ buildJob.DisplayName = "Build & Publish";
+ var deployJob = workflow.AddJob("deploy");
+ deployJob.DisplayName = "Deploy to Azure";
+
+ var buildStep = new PipelineStep
+ {
+ Name = "build-app",
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = buildJob
+ };
+
+ var deployStep = new PipelineStep
+ {
+ Name = "deploy-app",
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = ["build-app"],
+ ScheduledBy = deployJob
+ };
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+ var output = WorkflowYamlSerializer.Serialize(yaml);
+
+ return Verify(output).UseDirectory("Snapshots");
+ }
+
+ [Fact]
+ public Task ThreeJobDiamond_FanOutAndIn()
+ {
+ var workflow = new GitHubActionsWorkflowResource("ci-cd");
+ var buildJob = workflow.AddJob("build");
+ buildJob.DisplayName = "Build";
+ var testJob = workflow.AddJob("test");
+ testJob.DisplayName = "Run Tests";
+ var deployJob = workflow.AddJob("deploy");
+ deployJob.DisplayName = "Deploy";
+
+ var buildStep = new PipelineStep
+ {
+ Name = "build-app",
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = buildJob
+ };
+
+ var testStep = new PipelineStep
+ {
+ Name = "run-tests",
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = ["build-app"],
+ ScheduledBy = testJob
+ };
+
+ var deployStep = new PipelineStep
+ {
+ Name = "deploy-app",
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = ["build-app", "run-tests"],
+ ScheduledBy = deployJob
+ };
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, testStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+ var output = WorkflowYamlSerializer.Serialize(yaml);
+
+ return Verify(output).UseDirectory("Snapshots");
+ }
+
+ [Fact]
+ public Task CustomRunsOn_WindowsJob()
+ {
+ var workflow = new GitHubActionsWorkflowResource("build-windows");
+ var winJob = workflow.AddJob("build-win");
+ winJob.DisplayName = "Build on Windows";
+ winJob.RunsOn = "windows-latest";
+
+ var step = new PipelineStep
+ {
+ Name = "build-app",
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = winJob
+ };
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+ var output = WorkflowYamlSerializer.Serialize(yaml);
+
+ return Verify(output).UseDirectory("Snapshots");
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs
new file mode 100644
index 00000000000..ec9c9900cb0
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs
@@ -0,0 +1,137 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.Pipelines;
+
+namespace Aspire.Hosting.Tests.Pipelines;
+
+[Trait("Partition", "4")]
+public class PipelineEnvironmentTests
+{
+ [Fact]
+ public async Task GetEnvironmentAsync_NoEnvironments_ReturnsLocalPipelineEnvironment()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var environment = await pipeline.GetEnvironmentAsync();
+
+ Assert.IsType(environment);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_OneEnvironmentWithPassingCheck_ReturnsIt()
+ {
+ var resources = new ResourceCollection();
+ var env = new TestPipelineEnvironment("test-env");
+ env.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(true)));
+ resources.Add(env);
+
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var result = await pipeline.GetEnvironmentAsync();
+
+ Assert.Same(env, result);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_OneEnvironmentWithFailingCheck_ReturnsLocalPipelineEnvironment()
+ {
+ var resources = new ResourceCollection();
+ var env = new TestPipelineEnvironment("test-env");
+ env.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(false)));
+ resources.Add(env);
+
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var result = await pipeline.GetEnvironmentAsync();
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_TwoEnvironments_OnePasses_ReturnsPassingOne()
+ {
+ var resources = new ResourceCollection();
+
+ var env1 = new TestPipelineEnvironment("env1");
+ env1.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(false)));
+ resources.Add(env1);
+
+ var env2 = new TestPipelineEnvironment("env2");
+ env2.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(true)));
+ resources.Add(env2);
+
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var result = await pipeline.GetEnvironmentAsync();
+
+ Assert.Same(env2, result);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_TwoEnvironments_BothPass_Throws()
+ {
+ var resources = new ResourceCollection();
+
+ var env1 = new TestPipelineEnvironment("env1");
+ env1.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(true)));
+ resources.Add(env1);
+
+ var env2 = new TestPipelineEnvironment("env2");
+ env2.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(true)));
+ resources.Add(env2);
+
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var ex = await Assert.ThrowsAsync(
+ () => pipeline.GetEnvironmentAsync());
+
+ Assert.Contains("env1", ex.Message);
+ Assert.Contains("env2", ex.Message);
+ Assert.Contains("Multiple pipeline environments", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_EnvironmentWithoutCheckAnnotation_TreatedAsNonRelevant()
+ {
+ var resources = new ResourceCollection();
+ var env = new TestPipelineEnvironment("no-check-env");
+ // No PipelineEnvironmentCheckAnnotation added
+ resources.Add(env);
+
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var result = await pipeline.GetEnvironmentAsync();
+
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public async Task GetEnvironmentAsync_ResourcesAddedAfterConstruction_AreDetected()
+ {
+ var resources = new ResourceCollection();
+ var model = new DistributedApplicationModel(resources);
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ // Add environment AFTER pipeline construction (simulates builder.AddResource after pipeline is created)
+ var env = new TestPipelineEnvironment("late-env");
+ env.Annotations.Add(new PipelineEnvironmentCheckAnnotation(_ => Task.FromResult(true)));
+ resources.Add(env);
+
+ var result = await pipeline.GetEnvironmentAsync();
+
+ Assert.Same(env, result);
+ }
+
+ private sealed class TestPipelineEnvironment(string name) : Resource(name), IPipelineEnvironment
+ {
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs
new file mode 100644
index 00000000000..081dfebfa1f
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs
@@ -0,0 +1,126 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREPIPELINES001
+
+using Aspire.Hosting.Pipelines;
+
+namespace Aspire.Hosting.Tests.Pipelines;
+
+[Trait("Partition", "4")]
+public class ScheduleStepTests
+{
+ [Fact]
+ public void ScheduleStep_ExistingStep_SetsScheduledBy()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var target = new TestStepTarget("build-job");
+
+ pipeline.AddStep("build-app", _ => Task.CompletedTask);
+ pipeline.ScheduleStep("build-app", target);
+
+ var steps = GetSteps(pipeline);
+ var step = steps.Single(s => s.Name == "build-app");
+ Assert.Same(target, step.ScheduledBy);
+ }
+
+ [Fact]
+ public void ScheduleStep_NonExistentStep_Throws()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var target = new TestStepTarget("build-job");
+
+ var ex = Assert.Throws(
+ () => pipeline.ScheduleStep("does-not-exist", target));
+ Assert.Contains("does-not-exist", ex.Message);
+ }
+
+ [Fact]
+ public void ScheduleStep_MultipleStepsOnDifferentTargets_AllScheduled()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var buildTarget = new TestStepTarget("build-job");
+ var deployTarget = new TestStepTarget("deploy-job");
+
+ pipeline.AddStep("build-app", _ => Task.CompletedTask);
+ pipeline.AddStep("deploy-app", _ => Task.CompletedTask, dependsOn: "build-app");
+
+ pipeline.ScheduleStep("build-app", buildTarget);
+ pipeline.ScheduleStep("deploy-app", deployTarget);
+
+ var steps = GetSteps(pipeline);
+ Assert.Same(buildTarget, steps.Single(s => s.Name == "build-app").ScheduledBy);
+ Assert.Same(deployTarget, steps.Single(s => s.Name == "deploy-app").ScheduledBy);
+ }
+
+ [Fact]
+ public void ScheduleStep_OverridesPreviousScheduling()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var target1 = new TestStepTarget("job1");
+ var target2 = new TestStepTarget("job2");
+
+ pipeline.AddStep("my-step", _ => Task.CompletedTask, scheduledBy: target1);
+ pipeline.ScheduleStep("my-step", target2);
+
+ var steps = GetSteps(pipeline);
+ Assert.Same(target2, steps.Single(s => s.Name == "my-step").ScheduledBy);
+ }
+
+ [Fact]
+ public void ScheduleStep_NullStepName_ThrowsArgumentNull()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var target = new TestStepTarget("build-job");
+
+ Assert.Throws(() => pipeline.ScheduleStep(null!, target));
+ }
+
+ [Fact]
+ public void ScheduleStep_NullTarget_ThrowsArgumentNull()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ pipeline.AddStep("my-step", _ => Task.CompletedTask);
+
+ Assert.Throws(() => pipeline.ScheduleStep("my-step", null!));
+ }
+
+ [Fact]
+ public void ScheduleStep_BuiltInSteps_CanBeScheduled()
+ {
+ var model = new DistributedApplicationModel(new ResourceCollection());
+ var pipeline = new DistributedApplicationPipeline(model);
+ var buildTarget = new TestStepTarget("build-job");
+ var deployTarget = new TestStepTarget("deploy-job");
+
+ // Built-in steps already exist from constructor
+ pipeline.ScheduleStep(WellKnownPipelineSteps.Build, buildTarget);
+ pipeline.ScheduleStep(WellKnownPipelineSteps.Deploy, deployTarget);
+
+ var steps = GetSteps(pipeline);
+ Assert.Same(buildTarget, steps.Single(s => s.Name == WellKnownPipelineSteps.Build).ScheduledBy);
+ Assert.Same(deployTarget, steps.Single(s => s.Name == WellKnownPipelineSteps.Deploy).ScheduledBy);
+ }
+
+ private static List GetSteps(DistributedApplicationPipeline pipeline)
+ {
+ var field = typeof(DistributedApplicationPipeline)
+ .GetField("_steps", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+ return (List)field.GetValue(pipeline)!;
+ }
+
+ private sealed class TestStepTarget(string id) : IPipelineStepTarget
+ {
+ public string Id => id;
+ public IPipelineEnvironment Environment { get; } = new StubPipelineEnvironment("test-env");
+ }
+
+ private sealed class StubPipelineEnvironment(string name) : Resource(name), IPipelineEnvironment;
+}
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs
new file mode 100644
index 00000000000..fe8243388d8
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs
@@ -0,0 +1,174 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable CS0618 // Type or member is obsolete
+#pragma warning disable ASPIREPIPELINES001
+#pragma warning disable ASPIREPIPELINES002
+#pragma warning disable ASPIREPIPELINES003
+#pragma warning disable ASPIRECOMPUTE001
+#pragma warning disable ASPIRECOMPUTE003
+
+using Aspire.Hosting.Pipelines;
+using Aspire.Hosting.Utils;
+using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Tests.Pipelines;
+
+[Trait("Partition", "4")]
+public class StepStateRestoreTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ public async Task ExecuteAsync_StepWithSuccessfulRestore_SkipsExecution()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+ var pipeline = new DistributedApplicationPipeline();
+
+ var actionExecuted = false;
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "restorable-step",
+ Action = async (_) => { actionExecuted = true; await Task.CompletedTask; },
+ TryRestoreStepAsync = _ => Task.FromResult(true)
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ Assert.False(actionExecuted, "Step action should not execute when restore succeeds");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_StepWithFailedRestore_ExecutesNormally()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+ var pipeline = new DistributedApplicationPipeline();
+
+ var actionExecuted = false;
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "non-restorable-step",
+ Action = async (_) => { actionExecuted = true; await Task.CompletedTask; },
+ TryRestoreStepAsync = _ => Task.FromResult(false)
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ Assert.True(actionExecuted, "Step action should execute when restore fails");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_StepWithoutRestoreFunc_AlwaysExecutes()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+ var pipeline = new DistributedApplicationPipeline();
+
+ var actionExecuted = false;
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "plain-step",
+ Action = async (_) => { actionExecuted = true; await Task.CompletedTask; }
+ // No TryRestoreStepAsync set
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ Assert.True(actionExecuted, "Step action should execute when no restore func is set");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_MixedRestoredAndFresh_CorrectBehavior()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+ var pipeline = new DistributedApplicationPipeline();
+
+ var executedSteps = new List();
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step1",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("step1"); } await Task.CompletedTask; },
+ TryRestoreStepAsync = _ => Task.FromResult(true) // Restorable — will be skipped
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step2",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("step2"); } await Task.CompletedTask; },
+ DependsOnSteps = ["step1"]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step3",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("step3"); } await Task.CompletedTask; }
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ Assert.DoesNotContain("step1", executedSteps);
+ Assert.Contains("step2", executedSteps);
+ Assert.Contains("step3", executedSteps);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_RestoredStep_DependentsStillRun()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+ var pipeline = new DistributedApplicationPipeline();
+
+ var executedSteps = new List();
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "A",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("A"); } await Task.CompletedTask; },
+ TryRestoreStepAsync = _ => Task.FromResult(true) // Restored
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "B",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("B"); } await Task.CompletedTask; },
+ DependsOnSteps = ["A"]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "C",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("C"); } await Task.CompletedTask; },
+ DependsOnSteps = ["B"]
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ Assert.DoesNotContain("A", executedSteps);
+ Assert.Contains("B", executedSteps);
+ Assert.Contains("C", executedSteps);
+ }
+
+ private static PipelineContext CreateDeployingContext(DistributedApplication app)
+ {
+ return new PipelineContext(
+ app.Services.GetRequiredService(),
+ app.Services.GetRequiredService(),
+ app.Services,
+ app.Services.GetRequiredService>(),
+ CancellationToken.None);
+ }
+}