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..bb3d9ee99f7
--- /dev/null
+++ b/docs/specs/pipeline-generation.md
@@ -0,0 +1,516 @@
+# 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.Cli/Commands/PipelineCommand.cs b/src/Aspire.Cli/Commands/PipelineCommand.cs
new file mode 100644
index 00000000000..76ed37d483b
--- /dev/null
+++ b/src/Aspire.Cli/Commands/PipelineCommand.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.CommandLine.Help;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.Interaction;
+using Aspire.Cli.Resources;
+using Aspire.Cli.Telemetry;
+using Aspire.Cli.Utils;
+
+namespace Aspire.Cli.Commands;
+
+///
+/// Pipeline command group for managing CI/CD pipeline generation.
+///
+internal sealed class PipelineCommand : BaseCommand
+{
+ internal override HelpGroup HelpGroup => HelpGroup.Deployment;
+
+ public PipelineCommand(
+ IInteractionService interactionService,
+ IFeatures features,
+ ICliUpdateNotifier updateNotifier,
+ CliExecutionContext executionContext,
+ PipelineInitCommand initCommand,
+ AspireCliTelemetry telemetry)
+ : base("pipeline", PipelineCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
+ {
+ Subcommands.Add(initCommand);
+ }
+
+ protected override bool UpdateNotificationsEnabled => false;
+
+ protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ new HelpAction().Invoke(parseResult);
+ return Task.FromResult(ExitCodeConstants.InvalidCommand);
+ }
+}
diff --git a/src/Aspire.Cli/Commands/PipelineInitCommand.cs b/src/Aspire.Cli/Commands/PipelineInitCommand.cs
new file mode 100644
index 00000000000..60ef2b64281
--- /dev/null
+++ b/src/Aspire.Cli/Commands/PipelineInitCommand.cs
@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using Aspire.Cli.Configuration;
+using Aspire.Cli.DotNet;
+using Aspire.Cli.Interaction;
+using Aspire.Cli.Projects;
+using Aspire.Cli.Resources;
+using Aspire.Cli.Telemetry;
+using Aspire.Cli.Utils;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace Aspire.Cli.Commands;
+
+internal sealed class PipelineInitCommand : PipelineCommandBase
+{
+ internal override HelpGroup HelpGroup => HelpGroup.Deployment;
+
+ public PipelineInitCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfiguration configuration, ILogger logger, IAnsiConsole ansiConsole)
+ : base("init", PipelineInitCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, configuration, logger, ansiConsole)
+ {
+ }
+
+ protected override string OperationCompletedPrefix => PipelineInitCommandStrings.OperationCompletedPrefix;
+ protected override string OperationFailedPrefix => PipelineInitCommandStrings.OperationFailedPrefix;
+ protected override string GetOutputPathDescription() => PipelineInitCommandStrings.OutputPathArgumentDescription;
+
+ protected override Task GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ var baseArgs = new List { "--operation", "publish", "--step", "pipeline-init" };
+
+ if (fullyQualifiedOutputPath is not null)
+ {
+ baseArgs.AddRange(["--output-path", fullyQualifiedOutputPath]);
+ }
+
+ var logLevel = parseResult.GetValue(s_logLevelOption);
+ if (!string.IsNullOrEmpty(logLevel))
+ {
+ baseArgs.AddRange(["--log-level", logLevel!]);
+ }
+
+ var includeExceptionDetails = parseResult.GetValue(s_includeExceptionDetailsOption);
+ if (includeExceptionDetails)
+ {
+ baseArgs.AddRange(["--include-exception-details", "true"]);
+ }
+
+ var environment = parseResult.GetValue(s_environmentOption);
+ if (!string.IsNullOrEmpty(environment))
+ {
+ baseArgs.AddRange(["--environment", environment!]);
+ }
+
+ baseArgs.AddRange(unmatchedTokens);
+
+ return Task.FromResult([.. baseArgs]);
+ }
+
+ protected override string GetCanceledMessage() => PipelineInitCommandStrings.OperationCanceled;
+
+ protected override string GetProgressMessage(ParseResult parseResult)
+ {
+ return "Generating pipeline workflow files";
+ }
+}
diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs
index 38b24cf00df..6f303d24114 100644
--- a/src/Aspire.Cli/Commands/RootCommand.cs
+++ b/src/Aspire.Cli/Commands/RootCommand.cs
@@ -121,6 +121,7 @@ public RootCommand(
PublishCommand publishCommand,
DeployCommand deployCommand,
DoCommand doCommand,
+ PipelineCommand pipelineCommand,
ConfigCommand configCommand,
CacheCommand cacheCommand,
CertificatesCommand certificatesCommand,
@@ -215,6 +216,7 @@ public RootCommand(
Subcommands.Add(doctorCommand);
Subcommands.Add(deployCommand);
Subcommands.Add(doCommand);
+ Subcommands.Add(pipelineCommand);
Subcommands.Add(updateCommand);
Subcommands.Add(extensionInternalCommand);
Subcommands.Add(mcpCommand);
diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
index 618a0972cf8..0b54f24ae02 100644
--- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
+++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs
@@ -210,6 +210,16 @@ private static void AddMissingPackageSources(NuGetConfigContext context)
var existingKeys = new HashSet(context.ExistingAdds
.Select(e => (string?)e.Attribute("key") ?? string.Empty), StringComparer.OrdinalIgnoreCase);
+ // Build a lookup from source URL to friendly key name from the mappings
+ var sourceToFriendlyKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var mapping in context.Mappings)
+ {
+ if (mapping.Key is not null && !sourceToFriendlyKey.ContainsKey(mapping.Source))
+ {
+ sourceToFriendlyKey[mapping.Source] = mapping.Key;
+ }
+ }
+
var missingSources = context.RequiredSources
.Where(s => !existingValues.Contains(s) && !existingKeys.Contains(s))
.ToArray();
@@ -217,11 +227,15 @@ private static void AddMissingPackageSources(NuGetConfigContext context)
// Add missing sources
foreach (var source in missingSources)
{
- // Use the source URL as both key and value for consistency with our temporary config
+ // Use the friendly key from PackageMapping if available, otherwise fall back to source URL
+ var keyName = sourceToFriendlyKey.TryGetValue(source, out var friendlyKey) ? friendlyKey : source;
var add = new XElement("add");
- add.SetAttributeValue("key", source);
+ add.SetAttributeValue("key", keyName);
add.SetAttributeValue("value", source);
context.PackageSources.Add(add);
+
+ // Update the urlToExistingKey map so downstream methods can find the key
+ context.UrlToExistingKey[source] = keyName;
}
}
@@ -657,9 +671,15 @@ private static bool IsSourceSafeToRemove(string sourceKey, string? sourceValue)
return false;
}
+ // Check if the key follows the aspire-hive-* naming convention
+ if (sourceKey.StartsWith("aspire-hive-", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
var urlToCheck = sourceValue ?? sourceKey;
- // Check if this is an Aspire PR hive
+ // Check if this is an Aspire PR hive (absolute path or %HOME%-relative path)
if (!string.IsNullOrEmpty(urlToCheck) && urlToCheck.Contains(".aspire") && urlToCheck.Contains("hives"))
{
return true;
diff --git a/src/Aspire.Cli/Packaging/PackageMapping.cs b/src/Aspire.Cli/Packaging/PackageMapping.cs
index 4c49a912d12..fcf070052f9 100644
--- a/src/Aspire.Cli/Packaging/PackageMapping.cs
+++ b/src/Aspire.Cli/Packaging/PackageMapping.cs
@@ -3,9 +3,15 @@
namespace Aspire.Cli.Packaging;
-internal class PackageMapping(string PackageFilter, string source)
+internal class PackageMapping(string PackageFilter, string source, string? key = null)
{
public const string AllPackages = "*";
public string PackageFilter { get; } = PackageFilter;
public string Source { get; } = source;
+
+ ///
+ /// Optional friendly key name for this source in NuGet.config (e.g., "aspire-hive-pr-15643").
+ /// When set, used as the <add key="..." /> attribute instead of the source URL/path.
+ ///
+ public string? Key { get; } = key;
}
\ No newline at end of file
diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs
index ddb0b6dae7a..5cb0407b840 100644
--- a/src/Aspire.Cli/Packaging/PackagingService.cs
+++ b/src/Aspire.Cli/Packaging/PackagingService.cs
@@ -41,12 +41,14 @@ public Task> GetChannelsAsync(CancellationToken canc
var prHives = executionContext.HivesDirectory.GetDirectories();
foreach (var prHive in prHives)
{
- // The packages subdirectory contains the actual .nupkg files
- // Use forward slashes for cross-platform NuGet config compatibility
- var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/');
+ // Use %HOME% environment variable instead of the absolute path so that the
+ // NuGet.config is portable across machines (e.g., local dev → CI runner).
+ // NuGet expands %HOME% at runtime on all platforms.
+ var packagesPath = $"%HOME%/.aspire/hives/{prHive.Name}/packages";
+ var sourceKey = $"aspire-hive-{prHive.Name}";
var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Prerelease, new[]
{
- new PackageMapping("Aspire*", packagesPath),
+ new PackageMapping("Aspire*", packagesPath, key: sourceKey),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nuGetPackageCache);
diff --git a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
index 9ac09517be1..8caa49aa2cd 100644
--- a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
+++ b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs
@@ -38,10 +38,23 @@ public static async Task GenerateAsync(PackageMapping[] mappings, string targetP
private static async Task GenerateNuGetConfigAsync(PackageMapping[] mappings, FileInfo configFile)
{
- var distinctSources = mappings
- .Select(m => m.Source)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Select((source, index) => new { Source = source, Key = source })
+ // Build distinct sources with their preferred key names.
+ // If any mapping for a source specifies a Key, use that; otherwise fall back to the source URL.
+ var sourceKeyLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var m in mappings)
+ {
+ if (!sourceKeyLookup.ContainsKey(m.Source))
+ {
+ sourceKeyLookup[m.Source] = m.Key ?? m.Source;
+ }
+ else if (m.Key is not null)
+ {
+ sourceKeyLookup[m.Source] = m.Key;
+ }
+ }
+
+ var distinctSources = sourceKeyLookup
+ .Select(kvp => new { Source = kvp.Key, Key = kvp.Value })
.ToArray();
await using var fileStream = configFile.Create();
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 55881912e73..b6b067cc31c 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -457,6 +457,8 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
diff --git a/src/Aspire.Cli/Resources/PipelineCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/PipelineCommandStrings.Designer.cs
new file mode 100644
index 00000000000..ce4def13dfa
--- /dev/null
+++ b/src/Aspire.Cli/Resources/PipelineCommandStrings.Designer.cs
@@ -0,0 +1,72 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Aspire.Cli.Resources {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class PipelineCommandStrings {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal PipelineCommandStrings() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.PipelineCommandStrings", typeof(PipelineCommandStrings).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Manage CI/CD pipeline configuration (Preview).
+ ///
+ public static string Description {
+ get {
+ return ResourceManager.GetString("Description", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Cli/Resources/PipelineCommandStrings.resx b/src/Aspire.Cli/Resources/PipelineCommandStrings.resx
new file mode 100644
index 00000000000..aff26a3853f
--- /dev/null
+++ b/src/Aspire.Cli/Resources/PipelineCommandStrings.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Manage CI/CD pipeline configuration (Preview)
+
+
diff --git a/src/Aspire.Cli/Resources/PipelineInitCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/PipelineInitCommandStrings.Designer.cs
new file mode 100644
index 00000000000..f643dcf2b62
--- /dev/null
+++ b/src/Aspire.Cli/Resources/PipelineInitCommandStrings.Designer.cs
@@ -0,0 +1,108 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Aspire.Cli.Resources {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class PipelineInitCommandStrings {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal PipelineInitCommandStrings() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.PipelineInitCommandStrings", typeof(PipelineInitCommandStrings).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Generate CI/CD pipeline workflow files from the app model (Preview).
+ ///
+ public static string Description {
+ get {
+ return ResourceManager.GetString("Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Pipeline initialization was canceled..
+ ///
+ public static string OperationCanceled {
+ get {
+ return ResourceManager.GetString("OperationCanceled", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PIPELINE INIT COMPLETED.
+ ///
+ public static string OperationCompletedPrefix {
+ get {
+ return ResourceManager.GetString("OperationCompletedPrefix", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PIPELINE INIT FAILED.
+ ///
+ public static string OperationFailedPrefix {
+ get {
+ return ResourceManager.GetString("OperationFailedPrefix", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The optional output path for generated pipeline files.
+ ///
+ public static string OutputPathArgumentDescription {
+ get {
+ return ResourceManager.GetString("OutputPathArgumentDescription", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Cli/Resources/PipelineInitCommandStrings.resx b/src/Aspire.Cli/Resources/PipelineInitCommandStrings.resx
new file mode 100644
index 00000000000..a42305bb052
--- /dev/null
+++ b/src/Aspire.Cli/Resources/PipelineInitCommandStrings.resx
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+ The optional output path for generated pipeline files
+
+
+ Pipeline initialization was canceled.
+
+
+ PIPELINE INIT COMPLETED
+
+
+ PIPELINE INIT FAILED
+
+
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.cs.xlf
new file mode 100644
index 00000000000..42ca4a29049
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.cs.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.de.xlf
new file mode 100644
index 00000000000..ea2821e353b
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.de.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.es.xlf
new file mode 100644
index 00000000000..9ecd3d2662e
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.es.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.fr.xlf
new file mode 100644
index 00000000000..e2449ed994f
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.fr.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.it.xlf
new file mode 100644
index 00000000000..4e3aea53cc0
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.it.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ja.xlf
new file mode 100644
index 00000000000..2cc57b84545
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ja.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ko.xlf
new file mode 100644
index 00000000000..20a16c68d56
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ko.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pl.xlf
new file mode 100644
index 00000000000..fe61e3f7aee
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pl.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pt-BR.xlf
new file mode 100644
index 00000000000..0203bfa39ed
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pt-BR.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ru.xlf
new file mode 100644
index 00000000000..6f6dae91245
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ru.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.tr.xlf
new file mode 100644
index 00000000000..58e57020cbd
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.tr.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hans.xlf
new file mode 100644
index 00000000000..aea50641d71
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hans.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hant.xlf
new file mode 100644
index 00000000000..9d45736ace9
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hant.xlf
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Manage CI/CD pipeline configuration (Preview)
+ Manage CI/CD pipeline configuration (Preview)
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.cs.xlf
new file mode 100644
index 00000000000..4bf885bc241
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.cs.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.de.xlf
new file mode 100644
index 00000000000..fb021cf5e1b
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.de.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.es.xlf
new file mode 100644
index 00000000000..f8b0377bae4
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.es.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.fr.xlf
new file mode 100644
index 00000000000..b761af799ef
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.fr.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.it.xlf
new file mode 100644
index 00000000000..6d9543187b7
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.it.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ja.xlf
new file mode 100644
index 00000000000..3df10d8d267
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ja.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ko.xlf
new file mode 100644
index 00000000000..e65a27ffe8b
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ko.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pl.xlf
new file mode 100644
index 00000000000..162768d0b4f
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pl.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pt-BR.xlf
new file mode 100644
index 00000000000..72b6b3ddaf6
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pt-BR.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ru.xlf
new file mode 100644
index 00000000000..61538e10934
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ru.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.tr.xlf
new file mode 100644
index 00000000000..ed75c55d519
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.tr.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hans.xlf
new file mode 100644
index 00000000000..e0f0fe99baf
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hans.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hant.xlf
new file mode 100644
index 00000000000..663eb4d629e
--- /dev/null
+++ b/src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hant.xlf
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+ Generate CI/CD pipeline workflow files from the app model (Preview)
+
+
+
+ Pipeline initialization was canceled.
+ Pipeline initialization was canceled.
+
+
+
+ PIPELINE INIT COMPLETED
+ PIPELINE INIT COMPLETED
+
+
+
+ PIPELINE INIT FAILED
+ PIPELINE INIT FAILED
+
+
+
+ The optional output path for generated pipeline files
+ The optional output path for generated pipeline files
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs
index 97de4cb0ee9..360c434d5af 100644
--- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs
+++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs
@@ -71,7 +71,7 @@ public AzureContainerAppResource(string name, Action Task.CompletedTask,
- Tags = [WellKnownPipelineTags.DeployCompute]
+ Tags = [WellKnownPipelineTags.DeployCompute, WellKnownDependencyTags.AzureCli]
};
deployStep.DependsOn(printResourceSummary);
diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs
index f1bda8bf8a8..b6572c42f9b 100644
--- a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs
+++ b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs
@@ -30,7 +30,7 @@ public AzureContainerRegistryResource(string name, Action AzureContainerRegistryHelpers.LoginToRegistryAsync(this, context),
- Tags = ["acr-login"],
+ Tags = ["acr-login", WellKnownDependencyTags.AzureCli, WellKnownDependencyTags.Docker],
RequiredBySteps = [WellKnownPipelineSteps.PushPrereq],
Resource = this
};
diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs
index b944b8711db..431e769f972 100644
--- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs
+++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs
@@ -51,7 +51,7 @@ public AzureBicepResource(string name, string? templateFile = null, string? temp
Name = $"provision-{name}",
Description = $"Provisions the Azure Bicep resource {name} using Azure infrastructure.",
Action = async ctx => await ProvisionAzureBicepResourceAsync(ctx, this).ConfigureAwait(false),
- Tags = [WellKnownPipelineTags.ProvisionInfrastructure]
+ Tags = [WellKnownPipelineTags.ProvisionInfrastructure, WellKnownDependencyTags.AzureCli]
};
provisionStep.RequiredBy(AzureEnvironmentResource.ProvisionInfrastructureStepName);
provisionStep.DependsOn(AzureEnvironmentResource.CreateProvisioningContextStepName);
diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
index 9211021b1b5..45f635a4c92 100644
--- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
+++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
@@ -82,6 +82,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
Name = "validate-azure-login",
Description = "Validates Azure CLI authentication before deployment.",
Action = ctx => ValidateAzureLoginAsync(ctx),
+ Tags = [WellKnownDependencyTags.AzureCli],
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
@@ -99,6 +100,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
// Add Azure deployment information to the pipeline summary
AddToPipelineSummary(ctx, provisioningContext);
},
+ Tags = [WellKnownDependencyTags.AzureCli],
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
@@ -109,7 +111,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
Name = ProvisionInfrastructureStepName,
Description = "Aggregation step for all Azure infrastructure provisioning operations.",
Action = _ => Task.CompletedTask,
- Tags = [WellKnownPipelineTags.ProvisionInfrastructure],
+ Tags = [WellKnownPipelineTags.ProvisionInfrastructure, WellKnownDependencyTags.AzureCli],
RequiredBySteps = [WellKnownPipelineSteps.Deploy],
DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq]
};
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/GitHelper.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs
new file mode 100644
index 00000000000..0abc3a3c2cf
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs
@@ -0,0 +1,129 @@
+// 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;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Helper for executing Git commands from the hosting extension.
+///
+internal static class GitHelper
+{
+ ///
+ /// Checks whether the given directory is inside a Git work tree.
+ ///
+ public static async Task IsGitRepoAsync(string directory, ILogger logger, CancellationToken ct = default)
+ {
+ var (exitCode, _) = await RunGitAsync(directory, "rev-parse --is-inside-work-tree", logger, ct).ConfigureAwait(false);
+ return exitCode == 0;
+ }
+
+ ///
+ /// Gets the root directory of the Git repository containing .
+ ///
+ public static async Task GetRepoRootAsync(string directory, ILogger logger, CancellationToken ct = default)
+ {
+ var (exitCode, output) = await RunGitAsync(directory, "rev-parse --show-toplevel", logger, ct).ConfigureAwait(false);
+ return exitCode == 0 ? output.Trim() : null;
+ }
+
+ ///
+ /// Gets the URL of the named Git remote, or null if none exists.
+ ///
+ public static async Task GetRemoteUrlAsync(string directory, ILogger logger, string remote = "origin", CancellationToken ct = default)
+ {
+ var (exitCode, output) = await RunGitAsync(directory, $"remote get-url {remote}", logger, ct).ConfigureAwait(false);
+ return exitCode == 0 ? output.Trim() : null;
+ }
+
+ ///
+ /// Initializes a new Git repository in the given directory.
+ ///
+ public static async Task InitAsync(string directory, ILogger logger, CancellationToken ct = default)
+ {
+ var (exitCode, _) = await RunGitAsync(directory, "init", logger, ct).ConfigureAwait(false);
+ return exitCode == 0;
+ }
+
+ ///
+ /// Adds a remote to the Git repository.
+ ///
+ public static async Task AddRemoteAsync(string directory, string url, ILogger logger, string remote = "origin", CancellationToken ct = default)
+ {
+ var (exitCode, _) = await RunGitAsync(directory, $"remote add {remote} {url}", logger, ct).ConfigureAwait(false);
+ return exitCode == 0;
+ }
+
+ ///
+ /// Stages all files and creates a commit.
+ ///
+ public static async Task AddAllAndCommitAsync(string directory, string message, ILogger logger, CancellationToken ct = default)
+ {
+ var (addExit, _) = await RunGitAsync(directory, "add .", logger, ct).ConfigureAwait(false);
+ if (addExit != 0)
+ {
+ return false;
+ }
+
+ var (commitExit, _) = await RunGitAsync(directory, $"commit -m \"{message}\"", logger, ct).ConfigureAwait(false);
+ return commitExit == 0;
+ }
+
+ ///
+ /// Pushes the current branch to the remote.
+ ///
+ public static async Task PushAsync(string directory, ILogger logger, string remote = "origin", string branch = "main", CancellationToken ct = default)
+ {
+ var (exitCode, _) = await RunGitAsync(directory, $"push -u {remote} {branch}", logger, ct).ConfigureAwait(false);
+ return exitCode == 0;
+ }
+
+ ///
+ /// Gets the current branch name.
+ ///
+ public static async Task GetCurrentBranchAsync(string directory, ILogger logger, CancellationToken ct = default)
+ {
+ var (exitCode, output) = await RunGitAsync(directory, "branch --show-current", logger, ct).ConfigureAwait(false);
+ return exitCode == 0 ? output.Trim() : null;
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunGitAsync(string workingDirectory, string arguments, ILogger logger, CancellationToken ct)
+ {
+ try
+ {
+ var startInfo = new ProcessStartInfo("git", arguments)
+ {
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = new Process { StartInfo = startInfo };
+ process.Start();
+
+ var outputTask = process.StandardOutput.ReadToEndAsync(ct);
+ var errorTask = process.StandardError.ReadToEndAsync(ct);
+
+ await process.WaitForExitAsync(ct).ConfigureAwait(false);
+
+ var output = await outputTask.ConfigureAwait(false);
+ var error = await errorTask.ConfigureAwait(false);
+
+ if (process.ExitCode != 0)
+ {
+ logger.LogDebug("git {Arguments} exited with code {ExitCode}: {Error}", arguments, process.ExitCode, error.Trim());
+ }
+
+ return (process.ExitCode, output);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
+ {
+ logger.LogDebug(ex, "Git is not installed or not found in PATH");
+ return (-1, string.Empty);
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs
new file mode 100644
index 00000000000..d3187b2f0cc
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs
@@ -0,0 +1,76 @@
+// 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 job within a GitHub Actions workflow.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class GitHubActionsJobResource : Resource, IPipelineStepTarget
+{
+ private readonly List _dependsOnJobs = [];
+
+ internal GitHubActionsJobResource(string id, GitHubActionsWorkflowResource workflow)
+ : base(id)
+ {
+ 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(GitHubActionsJobResource job)
+ {
+ ArgumentNullException.ThrowIfNull(job);
+ _dependsOnJobs.Add(job.Id);
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs
new file mode 100644
index 00000000000..f707bdb993f
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs
@@ -0,0 +1,75 @@
+// 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 stage within a GitHub Actions workflow. Stages are a logical grouping
+/// of jobs. GitHub Actions does not have a native stage concept, so stages map to
+/// a set of jobs with a shared naming prefix and implicit dependencies.
+///
+///
+/// A stage can itself be used as a scheduling target via .
+/// When a step is scheduled onto a stage (rather than a specific job), the resolver
+/// automatically creates a default job within the stage to host the step.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class GitHubActionsStageResource(string name, GitHubActionsWorkflowResource workflow)
+ : Resource(name), IPipelineStepTarget
+{
+ private readonly List _jobs = [];
+
+ ///
+ /// Gets the workflow that owns this stage.
+ ///
+ public GitHubActionsWorkflowResource Workflow { get; } = workflow ?? throw new ArgumentNullException(nameof(workflow));
+
+ ///
+ /// Gets the jobs declared in this stage.
+ ///
+ public IReadOnlyList Jobs => _jobs;
+
+ ///
+ string IPipelineStepTarget.Id => Name;
+
+ ///
+ IPipelineEnvironment IPipelineStepTarget.Environment => Workflow;
+
+ ///
+ /// Adds a job to this stage.
+ ///
+ /// The unique job identifier within the workflow.
+ /// The created .
+ public GitHubActionsJobResource AddJob(string id)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+
+ var job = Workflow.AddJob(id);
+ _jobs.Add(job);
+ return job;
+ }
+
+ ///
+ /// Gets or creates a default job for this stage.
+ ///
+ /// The default for this stage.
+ internal GitHubActionsJobResource GetOrAddDefaultJob()
+ {
+ var defaultId = Name == "default" ? "default" : $"{Name}-default";
+
+ for (var i = 0; i < _jobs.Count; i++)
+ {
+ if (_jobs[i].Id == defaultId)
+ {
+ return _jobs[i];
+ }
+ }
+
+ return AddJob(defaultId);
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs
new file mode 100644
index 00000000000..4a737af5cc9
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs
@@ -0,0 +1,346 @@
+// 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;
+using Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+using Microsoft.Extensions.Logging;
+
+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);
+ }));
+
+ resource.Annotations.Add(new PipelineScopeAnnotation(context =>
+ {
+ var runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID");
+ var runAttempt = Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") ?? "1";
+ var jobId = Environment.GetEnvironmentVariable("GITHUB_JOB");
+
+ if (string.IsNullOrEmpty(runId) || string.IsNullOrEmpty(jobId))
+ {
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(new PipelineScopeResult
+ {
+ RunId = $"{runId}-{runAttempt}",
+ JobId = jobId
+ });
+ }));
+
+ resource.Annotations.Add(new PipelineStepAnnotation(context =>
+ {
+ var workflow = (GitHubActionsWorkflowResource)context.Resource;
+ var existingSteps = context.ExistingSteps;
+
+ if (existingSteps.Count == 0)
+ {
+ return [];
+ }
+
+ // Filter out orphaned steps that have no connection to the deployment graph.
+ // Steps like "publish-manifest", "diagnostics", and "pipeline-init" are
+ // standalone tools for the CLI, not part of the deployment DAG.
+ // Also exclude synthetic scheduling-target steps from prior runs to avoid
+ // circular dependencies when `aspire pipeline init` is run more than once.
+ var connectedSteps = FilterConnectedSteps(existingSteps, workflow.Name);
+
+ if (connectedSteps.Count == 0)
+ {
+ return [];
+ }
+
+ // Normalize RequiredBy→DependsOn before scheduling. The pipeline does this
+ // later in ExecuteAsync, but the scheduler needs the full dependency graph
+ // (including RequiredBy-derived edges) to compute correct job assignments.
+ NormalizeRequiredByToDependsOn(connectedSteps);
+
+ // Run the scheduler to compute job assignments and terminal steps
+ var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow);
+
+ // Register scope map so the executor can filter steps in continuation mode.
+ // This must happen here (not in the generator callback) so it's available
+ // during both `aspire pipeline init` and `aspire do` executions.
+ var scopeToSteps = new Dictionary>(StringComparer.Ordinal);
+
+ // Create a synthetic step per job that depends on the terminal steps for that job
+ var syntheticSteps = new List();
+ foreach (var job in workflow.Jobs)
+ {
+ var stageName = FindStageName(workflow, job);
+ var stepName = $"gha-{workflow.Name}-{stageName}-stage-{job.Id}-job";
+
+ var terminalSteps = scheduling.TerminalStepsPerJob.GetValueOrDefault(job.Id);
+
+ var syntheticStep = new PipelineStep
+ {
+ Name = stepName,
+ Description = $"Scheduling target for job '{job.Id}' in workflow '{workflow.Name}'",
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = terminalSteps?.ToList() ?? [],
+ ScheduledBy = job
+ };
+
+ syntheticSteps.Add(syntheticStep);
+
+ // Build scope map entry: include all real steps + the synthetic step
+ var jobStepNames = scheduling.StepsPerJob.TryGetValue(job.Id, out var jobSteps)
+ ? jobSteps.Select(s => s.Name).ToList()
+ : [];
+ jobStepNames.Add(stepName);
+ scopeToSteps[job.Id] = jobStepNames;
+ }
+
+ workflow.Annotations.Add(new PipelineScopeMapAnnotation(scopeToSteps));
+
+ return syntheticSteps;
+ }));
+
+ resource.Annotations.Add(new PipelineWorkflowGeneratorAnnotation(async context =>
+ {
+ var workflow = (GitHubActionsWorkflowResource)context.Environment;
+ var logger = context.StepContext.Logger;
+
+ // Bootstrap Git repo and GitHub remote if needed.
+ // This may initialize a Git repo, create .gitignore, create a GitHub repo,
+ // and set the RepositoryRootDirectory on the context.
+ var repoRoot = await GitHubRepositoryBootstrapper.BootstrapAsync(context).ConfigureAwait(false);
+
+ if (repoRoot is not null)
+ {
+ context.RepositoryRootDirectory = repoRoot;
+ }
+
+ if (context.RepositoryRootDirectory is null)
+ {
+ logger.LogError("Could not determine the repository root directory. Workflow generation cannot continue.");
+ return;
+ }
+
+ // Resolve scheduling (which steps run in which jobs).
+ // Filter out orphaned steps (same as the annotation factory) so the YAML
+ // generator only includes steps that are part of the deployment graph.
+ // Note: scope map is already registered by the PipelineStepAnnotation factory above.
+ var connectedSteps = FilterConnectedSteps(context.Steps, workflow.Name);
+ var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow);
+
+ // Generate the YAML model
+ var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ // Apply user customization callbacks
+ foreach (var customization in workflow.Annotations.OfType())
+ {
+ customization.Callback(yamlModel);
+ }
+
+ // Serialize to YAML string
+ var yamlContent = WorkflowYamlSerializer.Serialize(yamlModel);
+
+ // Write to .github/workflows/{name}.yml relative to the repo root
+ var outputDir = Path.Combine(context.RepositoryRootDirectory, ".github", "workflows");
+ Directory.CreateDirectory(outputDir);
+
+ var outputPath = Path.Combine(outputDir, workflow.WorkflowFileName);
+ await File.WriteAllTextAsync(outputPath, yamlContent, context.CancellationToken).ConfigureAwait(false);
+
+ logger.LogInformation("Generated GitHub Actions workflow: {Path}", outputPath);
+ context.StepContext.Summary.Add("📄 Workflow", outputPath);
+
+ // Offer to commit and push the generated files
+ await GitHubRepositoryBootstrapper.OfferCommitAndPushAsync(
+ context.RepositoryRootDirectory, context.StepContext).ConfigureAwait(false);
+ }));
+
+ return builder.AddResource(resource)
+ .ExcludeFromManifest();
+ }
+
+ ///
+ /// Adds a stage to the GitHub Actions workflow. Stages are a logical grouping of jobs.
+ ///
+ /// The workflow resource builder.
+ /// The unique stage name within the workflow.
+ /// The created .
+ [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")]
+ public static GitHubActionsStageResource AddStage(
+ this IResourceBuilder builder,
+ string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ return builder.Resource.AddStage(name);
+ }
+
+ ///
+ /// Adds a job to the GitHub Actions workflow.
+ ///
+ /// The workflow resource builder.
+ /// The unique job identifier within the workflow.
+ /// The created .
+ [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")]
+ public static GitHubActionsJobResource AddJob(
+ this IResourceBuilder builder,
+ string id)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(id);
+
+ return builder.Resource.AddJob(id);
+ }
+
+ ///
+ /// Registers a callback to customize the generated model
+ /// before it is serialized to disk. Multiple callbacks can be registered and will be
+ /// invoked in registration order.
+ ///
+ /// The workflow resource builder.
+ /// A callback that receives the model for mutation.
+ /// The resource builder for chaining.
+ ///
+ ///
+ /// var workflow = builder.AddGitHubActionsWorkflow("deploy");
+ /// workflow.ConfigureWorkflow(yaml =>
+ /// {
+ /// foreach (var job in yaml.Jobs.Values)
+ /// {
+ /// job.Env ??= new();
+ /// job.Env["MY_SECRET"] = "${{ secrets.MY_SECRET }}";
+ /// }
+ /// });
+ ///
+ ///
+ [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")]
+ public static IResourceBuilder ConfigureWorkflow(
+ this IResourceBuilder builder,
+ Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ return builder.WithAnnotation(new WorkflowCustomizationAnnotation(configure));
+ }
+
+ ///
+ /// Finds the stage name that contains the specified job, or "default" if the job
+ /// is not part of any explicit stage.
+ ///
+ private static string FindStageName(GitHubActionsWorkflowResource workflow, GitHubActionsJobResource job)
+ {
+ foreach (var stage in workflow.Stages)
+ {
+ for (var i = 0; i < stage.Jobs.Count; i++)
+ {
+ if (stage.Jobs[i].Id == job.Id)
+ {
+ return stage.Name;
+ }
+ }
+ }
+
+ return "default";
+ }
+
+ ///
+ /// Filters out orphaned steps that have no connection to the deployment graph.
+ /// A step is "connected" if it has dependencies, is depended upon by other steps,
+ /// or has explicit scheduling. Steps like "publish-manifest", "diagnostics", and
+ /// "pipeline-init" are standalone CLI tools and should not be scheduled into CI jobs.
+ /// Synthetic scheduling-target steps from prior runs (matching gha-{workflowName}-*-job)
+ /// are also excluded to prevent circular dependencies when running pipeline init again.
+ ///
+ private static List FilterConnectedSteps(IReadOnlyList steps, string workflowName)
+ {
+ var syntheticPrefix = $"gha-{workflowName}-";
+ const string syntheticSuffix = "-job";
+
+ // Build set of step names that are depended upon
+ var dependedUpon = new HashSet(StringComparer.Ordinal);
+ foreach (var step in steps)
+ {
+ foreach (var dep in step.DependsOnSteps)
+ {
+ dependedUpon.Add(dep);
+ }
+ foreach (var req in step.RequiredBySteps)
+ {
+ dependedUpon.Add(req);
+ }
+ }
+
+ var connected = new List();
+ foreach (var step in steps)
+ {
+ // Exclude synthetic scheduling-target steps from prior runs
+ if (step.Name.StartsWith(syntheticPrefix, StringComparison.Ordinal) &&
+ step.Name.EndsWith(syntheticSuffix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // A step is connected if:
+ // - It depends on other steps
+ // - Other steps depend on it (via DependsOn or RequiredBy)
+ // - It has explicit scheduling (ScheduledBy is set)
+ if (step.DependsOnSteps.Count > 0 ||
+ step.RequiredBySteps.Count > 0 ||
+ dependedUpon.Contains(step.Name) ||
+ step.ScheduledBy is not null)
+ {
+ connected.Add(step);
+ }
+ }
+
+ return connected;
+ }
+
+ ///
+ /// Converts RequiredBy relationships to their equivalent DependsOn relationships.
+ /// If step A is required by step B, this adds step A as a dependency of step B.
+ /// This must be done before scheduling so the resolver sees the full dependency graph.
+ ///
+ private static void NormalizeRequiredByToDependsOn(List steps)
+ {
+ var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal);
+ foreach (var step in steps)
+ {
+ foreach (var requiredByStepName in step.RequiredBySteps)
+ {
+ if (stepsByName.TryGetValue(requiredByStepName, out var requiredByStep) &&
+ !requiredByStep.DependsOnSteps.Contains(step.Name))
+ {
+ requiredByStep.DependsOnSteps.Add(step.Name);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs
new file mode 100644
index 00000000000..b887c52c923
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs
@@ -0,0 +1,112 @@
+// 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.
+///
+///
+/// A workflow can itself be used as a scheduling target via .
+/// When a step is scheduled onto a workflow (rather than a specific job), the resolver
+/// automatically creates a default stage and job to host the step.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipelineEnvironment, IPipelineStepTarget
+{
+ private readonly List _jobs = [];
+ private readonly List _stages = [];
+
+ ///
+ /// 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;
+
+ ///
+ /// Gets the stages declared in this workflow.
+ ///
+ public IReadOnlyList Stages => _stages;
+
+ ///
+ string IPipelineStepTarget.Id => Name;
+
+ ///
+ IPipelineEnvironment IPipelineStepTarget.Environment => this;
+
+ ///
+ /// Adds a stage to this workflow. Stages are a logical grouping of jobs.
+ ///
+ /// The unique stage name within the workflow.
+ /// The created .
+ public GitHubActionsStageResource AddStage(string name)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ if (_stages.Any(s => s.Name == name))
+ {
+ throw new InvalidOperationException(
+ $"A stage with the name '{name}' has already been added to the workflow '{Name}'.");
+ }
+
+ var stage = new GitHubActionsStageResource(name, this);
+ _stages.Add(stage);
+ return stage;
+ }
+
+ ///
+ /// Adds a job to this workflow.
+ ///
+ /// The unique job identifier within the workflow.
+ /// The created .
+ public GitHubActionsJobResource 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 GitHubActionsJobResource(id, this);
+ _jobs.Add(job);
+ return job;
+ }
+
+ ///
+ /// Gets or creates the default stage for this workflow.
+ ///
+ /// The default .
+ internal GitHubActionsStageResource GetOrAddDefaultStage()
+ {
+ for (var i = 0; i < _stages.Count; i++)
+ {
+ if (_stages[i].Name == "default")
+ {
+ return _stages[i];
+ }
+ }
+
+ return AddStage("default");
+ }
+
+ ///
+ /// Gets or creates a default job for this workflow by delegating to the default stage.
+ ///
+ /// The default .
+ internal GitHubActionsJobResource GetOrAddDefaultJob()
+ {
+ var stage = GetOrAddDefaultStage();
+ return stage.GetOrAddDefaultJob();
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs
new file mode 100644
index 00000000000..fe8e8c79963
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs
@@ -0,0 +1,179 @@
+// 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;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Lightweight GitHub API client that uses the gh CLI for authentication
+/// and for REST API calls.
+///
+internal sealed class GitHubApiClient : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public GitHubApiClient(ILogger logger)
+ {
+ _logger = logger;
+ _httpClient = new HttpClient
+ {
+ BaseAddress = new Uri("https://api.github.com"),
+ DefaultRequestHeaders =
+ {
+ { "Accept", "application/vnd.github+json" },
+ { "User-Agent", "aspire-pipeline-init" },
+ { "X-GitHub-Api-Version", "2022-11-28" }
+ }
+ };
+ }
+
+ ///
+ /// Checks whether the gh CLI is installed and available on PATH.
+ ///
+ public static async Task IsGhInstalledAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var (exitCode, _) = await RunGhAsync("--version", ct).ConfigureAwait(false);
+ return exitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Gets an authentication token from the gh CLI.
+ /// Returns null if the user is not authenticated.
+ ///
+ public async Task GetAuthTokenAsync(CancellationToken ct = default)
+ {
+ var (exitCode, output) = await RunGhAsync("auth token", ct).ConfigureAwait(false);
+ if (exitCode != 0)
+ {
+ _logger.LogDebug("gh auth token returned exit code {ExitCode}. User may not be authenticated.", exitCode);
+ return null;
+ }
+
+ var token = output.Trim();
+ return string.IsNullOrEmpty(token) ? null : token;
+ }
+
+ ///
+ /// Gets the authenticated user's login name.
+ ///
+ public async Task GetAuthenticatedUserAsync(string token, CancellationToken ct = default)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/user");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("GET /user returned {StatusCode}", response.StatusCode);
+ return null;
+ }
+
+ using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false);
+ return doc.RootElement.TryGetProperty("login", out var login) ? login.GetString() : null;
+ }
+
+ ///
+ /// Gets the list of organizations the authenticated user is a member of.
+ /// Returns org login names.
+ ///
+ public async Task> GetUserOrgsAsync(string token, CancellationToken ct = default)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/user/orgs?per_page=100");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("GET /user/orgs returned {StatusCode}", response.StatusCode);
+ return [];
+ }
+
+ using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false);
+
+ var orgs = new List();
+ foreach (var element in doc.RootElement.EnumerateArray())
+ {
+ if (element.TryGetProperty("login", out var login) && login.GetString() is { } orgLogin)
+ {
+ orgs.Add(orgLogin);
+ }
+ }
+
+ return orgs;
+ }
+
+ ///
+ /// Creates a new GitHub repository.
+ ///
+ /// GitHub auth token.
+ /// Repository name.
+ /// Organization login, or null for a personal repo.
+ /// Whether the repo should be private.
+ /// Cancellation token.
+ /// The full clone URL (https), or null on failure.
+ public async Task CreateRepoAsync(string token, string name, string? org, bool isPrivate, CancellationToken ct = default)
+ {
+ var endpoint = org is not null ? $"/orgs/{org}/repos" : "/user/repos";
+
+ var body = JsonSerializer.Serialize(new
+ {
+ name,
+ @private = isPrivate,
+ auto_init = false
+ });
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ _logger.LogWarning("Failed to create repository. Status: {StatusCode}, Body: {Body}", response.StatusCode, errorBody);
+ return null;
+ }
+
+ using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false);
+ return doc.RootElement.TryGetProperty("clone_url", out var cloneUrl) ? cloneUrl.GetString() : null;
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunGhAsync(string arguments, CancellationToken ct)
+ {
+ var startInfo = new ProcessStartInfo("gh", arguments)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = new Process { StartInfo = startInfo };
+ process.Start();
+
+ var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
+ await process.WaitForExitAsync(ct).ConfigureAwait(false);
+
+ return (process.ExitCode, output);
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs
new file mode 100644
index 00000000000..ccfeeeb2a8e
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs
@@ -0,0 +1,383 @@
+// 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 ASPIREINTERACTION001
+#pragma warning disable ASPIREPIPELINES001
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Orchestrates Git repository and GitHub remote bootstrapping during aspire pipeline init.
+///
+internal static class GitHubRepositoryBootstrapper
+{
+ ///
+ /// Bootstraps the Git repo and GitHub remote if needed, returning the repository root directory.
+ ///
+ ///
+ /// All user prompts use
+ /// rather than PromptConfirmationAsync because the CLI-to-AppHost backchannel only
+ /// proxies InputsInteractionInfo prompts (not MessageBoxInteractionInfo).
+ ///
+ public static async Task BootstrapAsync(PipelineWorkflowGenerationContext context)
+ {
+ var logger = context.StepContext.Logger;
+ var ct = context.CancellationToken;
+ var interactionService = context.StepContext.Services.GetService();
+
+ // Use the repo root already detected by the central pipeline if available.
+ var repoRoot = context.RepositoryRootDirectory;
+
+ if (repoRoot is not null)
+ {
+ var isGitRepo = await GitHelper.IsGitRepoAsync(repoRoot, logger, ct).ConfigureAwait(false);
+
+ if (!isGitRepo)
+ {
+ // Repo root was detected (e.g. via aspire.config.json) but it's not a git repo yet.
+ if (!await PromptBooleanAsync(interactionService,
+ "Initialize Git Repository",
+ "No Git repository found. Would you like to initialize one?",
+ ct).ConfigureAwait(false))
+ {
+ return repoRoot;
+ }
+
+ await InitGitRepoAsync(repoRoot, interactionService, context, logger, ct).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ // No repo root from central detection — try to detect ourselves.
+ var cwd = Directory.GetCurrentDirectory();
+ var isGitRepo = await GitHelper.IsGitRepoAsync(cwd, logger, ct).ConfigureAwait(false);
+
+ if (isGitRepo)
+ {
+ repoRoot = await GitHelper.GetRepoRootAsync(cwd, logger, ct).ConfigureAwait(false) ?? cwd;
+ logger.LogInformation("Git repository detected at: {RepoRoot}", repoRoot);
+ }
+ else
+ {
+ if (!await PromptBooleanAsync(interactionService,
+ "Initialize Git Repository",
+ "No Git repository found in the current directory. Would you like to initialize one?",
+ ct).ConfigureAwait(false))
+ {
+ logger.LogInformation("Skipping Git initialization. Using current directory as root.");
+ return cwd;
+ }
+
+ repoRoot = cwd;
+ await InitGitRepoAsync(repoRoot, interactionService, context, logger, ct).ConfigureAwait(false);
+ }
+ }
+
+ // Check for existing GitHub remote
+ var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false);
+
+ if (IsGitHubUrl(remoteUrl))
+ {
+ logger.LogInformation("GitHub remote already configured: {Url}", remoteUrl);
+ return repoRoot;
+ }
+
+ // Offer to create a GitHub repository
+ var pushMessage = remoteUrl is null
+ ? "No Git remote configured. Would you like to create a GitHub repository and push?"
+ : "The current remote is not a GitHub URL. Would you like to create a GitHub repository?";
+
+ if (!await PromptBooleanAsync(interactionService, "Push to GitHub", pushMessage, ct).ConfigureAwait(false))
+ {
+ return repoRoot;
+ }
+
+ // Set up GitHub repo
+ var cloneUrl = await SetupGitHubRepoAsync(repoRoot, interactionService!, logger, ct).ConfigureAwait(false);
+ if (cloneUrl is not null)
+ {
+ context.StepContext.Summary.Add("🔗 GitHub", cloneUrl);
+ }
+
+ return repoRoot;
+ }
+
+ private static async Task InitGitRepoAsync(
+ string directory,
+ IInteractionService? interactionService,
+ PipelineWorkflowGenerationContext context,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ logger.LogInformation("Initializing Git repository in {Directory}...", directory);
+
+ if (!await GitHelper.InitAsync(directory, logger, ct).ConfigureAwait(false))
+ {
+ logger.LogError("Failed to initialize Git repository.");
+ return;
+ }
+
+ logger.LogInformation("Git repository initialized.");
+
+ // Offer to create .gitignore
+ var gitignorePath = Path.Combine(directory, ".gitignore");
+ if (!File.Exists(gitignorePath))
+ {
+ if (await PromptBooleanAsync(interactionService,
+ "Create .gitignore",
+ "Would you like to create a .gitignore file with sensible defaults for .NET/Aspire projects?",
+ ct).ConfigureAwait(false))
+ {
+ await File.WriteAllTextAsync(gitignorePath, GitIgnoreTemplate.Content, ct).ConfigureAwait(false);
+ logger.LogInformation("Created .gitignore");
+ context.StepContext.Summary.Add("📄 .gitignore", gitignorePath);
+ }
+ }
+ }
+
+ ///
+ /// After YAML generation, optionally commits and pushes all changes.
+ ///
+ public static async Task OfferCommitAndPushAsync(string repoRoot, PipelineStepContext stepContext)
+ {
+ var logger = stepContext.Logger;
+ var ct = stepContext.CancellationToken;
+ var interactionService = stepContext.Services.GetService();
+
+ // Check if there's a remote to push to
+ var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false);
+ if (remoteUrl is null)
+ {
+ return;
+ }
+
+ if (!await PromptBooleanAsync(interactionService,
+ "Commit & Push",
+ "Would you like to commit the generated workflow files and push to GitHub?",
+ ct).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ logger.LogInformation("Committing and pushing...");
+
+ if (!await GitHelper.AddAllAndCommitAsync(repoRoot, "Add Aspire CI/CD pipeline workflow", logger, ct).ConfigureAwait(false))
+ {
+ logger.LogWarning("Failed to commit changes. You may need to commit manually.");
+ return;
+ }
+
+ var branch = await GitHelper.GetCurrentBranchAsync(repoRoot, logger, ct).ConfigureAwait(false) ?? "main";
+
+ if (!await GitHelper.PushAsync(repoRoot, logger, branch: branch, ct: ct).ConfigureAwait(false))
+ {
+ logger.LogWarning("Failed to push. You may need to push manually with: git push -u origin {Branch}", branch);
+ return;
+ }
+
+ logger.LogInformation("Pushed to {Remote} on branch {Branch}", remoteUrl, branch);
+ stepContext.Summary.Add("🚀 Pushed", $"{branch} → {remoteUrl}");
+ }
+
+ ///
+ /// Prompts a yes/no question via
+ /// using , which is supported by the CLI backchannel.
+ /// Returns false if the interaction service is unavailable or the user declines/cancels.
+ ///
+ private static async Task PromptBooleanAsync(
+ IInteractionService? interactionService,
+ string title,
+ string message,
+ CancellationToken ct)
+ {
+ if (interactionService is null)
+ {
+ return false;
+ }
+
+ var result = await interactionService.PromptInputAsync(
+ title,
+ message,
+ new InteractionInput
+ {
+ Name = "confirm",
+ Label = title,
+ InputType = InputType.Boolean,
+ Value = "true",
+ Required = true
+ },
+ cancellationToken: ct).ConfigureAwait(false);
+
+ if (result.Canceled || result.Data?.Value is null)
+ {
+ return false;
+ }
+
+ return string.Equals(result.Data.Value, "true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static async Task SetupGitHubRepoAsync(
+ string repoRoot,
+ IInteractionService interactionService,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ using var github = new GitHubApiClient(logger);
+
+ // Check if gh CLI is installed
+ if (!await GitHubApiClient.IsGhInstalledAsync(ct).ConfigureAwait(false))
+ {
+ logger.LogWarning("The GitHub CLI (gh) is not installed. Install it from https://cli.github.com/ to create repositories.");
+ return null;
+ }
+
+ // Get auth token
+ var token = await github.GetAuthTokenAsync(ct).ConfigureAwait(false);
+ if (token is null)
+ {
+ logger.LogWarning("Not authenticated with GitHub CLI. Run 'gh auth login' first.");
+ return null;
+ }
+
+ // Fetch user and orgs in parallel
+ var userTask = github.GetAuthenticatedUserAsync(token, ct);
+ var orgsTask = github.GetUserOrgsAsync(token, ct);
+
+ await Task.WhenAll(userTask, orgsTask).ConfigureAwait(false);
+
+ var username = await userTask.ConfigureAwait(false);
+ var orgs = await orgsTask.ConfigureAwait(false);
+
+ if (username is null)
+ {
+ logger.LogWarning("Could not determine authenticated GitHub user.");
+ return null;
+ }
+
+ // Build owner choices: personal account + orgs
+ var ownerChoices = new List>
+ {
+ new(username, $"{username} (personal account)")
+ };
+
+ foreach (var org in orgs)
+ {
+ ownerChoices.Add(new(org, org));
+ }
+
+ // Prompt for owner
+ var ownerResult = await interactionService.PromptInputAsync(
+ "GitHub Repository Owner",
+ "Select the owner for the new repository.",
+ new InteractionInput
+ {
+ Name = "owner",
+ Label = "Owner",
+ InputType = InputType.Choice,
+ Options = ownerChoices,
+ Value = username,
+ Required = true
+ },
+ cancellationToken: ct).ConfigureAwait(false);
+
+ if (ownerResult.Canceled || ownerResult.Data?.Value is null)
+ {
+ return null;
+ }
+
+ var selectedOwner = ownerResult.Data.Value;
+ var isOrg = selectedOwner != username;
+
+ // Prompt for repo name (default: directory name)
+ var defaultRepoName = new DirectoryInfo(repoRoot).Name;
+
+ var nameResult = await interactionService.PromptInputAsync(
+ "Repository Name",
+ null,
+ new InteractionInput
+ {
+ Name = "repoName",
+ Label = "Repository name",
+ InputType = InputType.Text,
+ Value = defaultRepoName,
+ Required = true
+ },
+ cancellationToken: ct).ConfigureAwait(false);
+
+ if (nameResult.Canceled || string.IsNullOrWhiteSpace(nameResult.Data?.Value))
+ {
+ return null;
+ }
+
+ var repoName = nameResult.Data.Value;
+
+ // Prompt for visibility
+ var visibilityResult = await interactionService.PromptInputAsync(
+ "Repository Visibility",
+ null,
+ new InteractionInput
+ {
+ Name = "visibility",
+ Label = "Visibility",
+ InputType = InputType.Choice,
+ Options =
+ [
+ new("private", "Private"),
+ new("public", "Public")
+ ],
+ Value = "private",
+ Required = true
+ },
+ cancellationToken: ct).ConfigureAwait(false);
+
+ if (visibilityResult.Canceled)
+ {
+ return null;
+ }
+
+ var isPrivate = visibilityResult.Data?.Value != "public";
+
+ // Create the repo
+ logger.LogInformation("Creating GitHub repository {Owner}/{Repo}...", selectedOwner, repoName);
+
+ var cloneUrl = await github.CreateRepoAsync(
+ token,
+ repoName,
+ isOrg ? selectedOwner : null,
+ isPrivate,
+ ct).ConfigureAwait(false);
+
+ if (cloneUrl is null)
+ {
+ logger.LogError("Failed to create GitHub repository.");
+ return null;
+ }
+
+ logger.LogInformation("Created repository: {Url}", cloneUrl);
+
+ // Add remote
+ if (!await GitHelper.AddRemoteAsync(repoRoot, cloneUrl, logger, ct: ct).ConfigureAwait(false))
+ {
+ logger.LogWarning("Failed to add remote. You can add it manually: git remote add origin {Url}", cloneUrl);
+ }
+ else
+ {
+ logger.LogInformation("Added remote 'origin' → {Url}", cloneUrl);
+ }
+
+ return cloneUrl;
+ }
+
+ private static bool IsGitHubUrl(string? url)
+ {
+ if (string.IsNullOrEmpty(url))
+ {
+ return false;
+ }
+
+ return url.Contains("github.com", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs
new file mode 100644
index 00000000000..56efd56ed19
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs
@@ -0,0 +1,42 @@
+// 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;
+
+///
+/// Provides a sensible .gitignore template for Aspire projects.
+///
+internal static class GitIgnoreTemplate
+{
+ public const string Content = """
+ ## .NET
+ bin/
+ obj/
+ artifacts/
+ TestResults/
+ *.user
+ *.suo
+ *.userosscache
+ *.sln.docstates
+
+ ## NuGet
+ *.nupkg
+ **/[Pp]ackages/*
+ !**/[Pp]ackages/build/
+
+ ## IDE
+ .vs/
+ .vscode/
+ .idea/
+ *.swp
+ *~
+
+ ## Aspire
+ .aspire/state/
+ .aspire/secrets/
+
+ ## OS
+ .DS_Store
+ Thumbs.db
+ """;
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs
new file mode 100644
index 00000000000..a231a425cd4
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs
@@ -0,0 +1,463 @@
+// 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);
+
+ // Build reverse dependency map: step name → steps that depend on it
+ var reverseDeps = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var step in steps)
+ {
+ foreach (var dep in step.DependsOnSteps)
+ {
+ if (!reverseDeps.TryGetValue(dep, out var list))
+ {
+ list = [];
+ reverseDeps[dep] = list;
+ }
+
+ list.Add(step);
+ }
+ }
+
+ // Phase 1: resolve all explicitly scheduled steps (ScheduledBy is set)
+ var explicitStepToJob = new Dictionary(StringComparer.Ordinal);
+ var hasExplicitTargets = false;
+
+ foreach (var step in steps)
+ {
+ if (step.ScheduledBy is not null)
+ {
+ explicitStepToJob[step.Name] = ResolveExplicitTarget(step, workflow);
+ hasExplicitTargets = true;
+ }
+ }
+
+ // Pre-existing jobs/stages on the workflow also count as explicit targets
+ hasExplicitTargets = hasExplicitTargets || workflow.Jobs.Count > 0 || workflow.Stages.Count > 0;
+
+ // Phase 2: resolve unscheduled steps by pulling them into the first consumer's target
+ var stepToJob = new Dictionary(explicitStepToJob, StringComparer.Ordinal);
+ var orphanSteps = new List();
+
+ foreach (var step in steps)
+ {
+ if (step.ScheduledBy is not null)
+ {
+ continue;
+ }
+
+ if (hasExplicitTargets)
+ {
+ var consumerJob = FindFirstConsumerJob(step.Name, reverseDeps, explicitStepToJob);
+ if (consumerJob is not null)
+ {
+ stepToJob[step.Name] = consumerJob;
+ }
+ else
+ {
+ orphanSteps.Add(step);
+ }
+ }
+ else
+ {
+ stepToJob[step.Name] = workflow.GetOrAddDefaultJob();
+ }
+ }
+
+ // Phase 2b: resolve orphan steps (no consumer chain to an explicit target) by
+ // co-locating them with their dependencies. This avoids creating spurious cross-job
+ // dependencies that can introduce cycles in the job dependency graph.
+ // Iterate until stable to handle chains of orphan-to-orphan dependencies.
+ var remaining = orphanSteps;
+ while (remaining.Count > 0)
+ {
+ var unresolved = new List();
+ var progress = false;
+
+ foreach (var step in remaining)
+ {
+ var depJob = FindDependencyJob(step, stepToJob);
+ if (depJob is not null)
+ {
+ stepToJob[step.Name] = depJob;
+ progress = true;
+ }
+ else
+ {
+ unresolved.Add(step);
+ }
+ }
+
+ if (!progress)
+ {
+ // No progress — assign remaining orphans to first available job
+ foreach (var step in unresolved)
+ {
+ stepToJob[step.Name] = GetFirstAvailableJob(workflow);
+ }
+
+ break;
+ }
+
+ remaining = unresolved;
+ }
+
+ // 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] = [];
+ }
+ }
+
+ // 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);
+ }
+
+ // Compute terminal steps per job: steps that no other step in the same job depends on.
+ // These are the "leaf" steps whose transitive closure covers all steps in the job.
+ var terminalStepsPerJob = new Dictionary>(StringComparer.Ordinal);
+ foreach (var (jobId, jobSteps) in stepsPerJob)
+ {
+ var jobStepNames = new HashSet(jobSteps.Select(s => s.Name), StringComparer.Ordinal);
+
+ // A step is a dependency within this job if any other step in the same job depends on it
+ var intraJobDependencies = new HashSet(StringComparer.Ordinal);
+ foreach (var step in jobSteps)
+ {
+ foreach (var dep in step.DependsOnSteps)
+ {
+ if (jobStepNames.Contains(dep))
+ {
+ intraJobDependencies.Add(dep);
+ }
+ }
+ }
+
+ // Terminal steps are those NOT depended on by any other step in the same job
+ var terminals = jobSteps
+ .Where(s => !intraJobDependencies.Contains(s.Name))
+ .Select(s => s.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ terminalStepsPerJob[jobId] = terminals.Count > 0 ? terminals : [jobSteps[^1].Name];
+ }
+
+ // The default job is whatever was auto-created during resolution (if any)
+ GitHubActionsJobResource? defaultJob = null;
+ for (var i = 0; i < workflow.Jobs.Count; i++)
+ {
+ if (workflow.Jobs[i].Id == "default")
+ {
+ defaultJob = workflow.Jobs[i];
+ break;
+ }
+ }
+ defaultJob ??= workflow.Jobs.Count > 0 ? workflow.Jobs[0] : null;
+
+ 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),
+ TerminalStepsPerJob = terminalStepsPerJob,
+ DefaultJob = defaultJob
+ };
+ }
+
+ ///
+ /// Resolves the job for a step that has an explicit target.
+ ///
+ private static GitHubActionsJobResource ResolveExplicitTarget(PipelineStep step, GitHubActionsWorkflowResource workflow)
+ {
+ return step.ScheduledBy switch
+ {
+ GitHubActionsJobResource job when 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."),
+
+ GitHubActionsJobResource job => job,
+
+ GitHubActionsStageResource stage when stage.Workflow != workflow =>
+ throw new SchedulingValidationException(
+ $"Step '{step.Name}' is scheduled on stage '{stage.Name}' from a different workflow. " +
+ $"Steps can only be scheduled on stages within the same workflow."),
+
+ GitHubActionsStageResource stage => stage.GetOrAddDefaultJob(),
+
+ GitHubActionsWorkflowResource w when w != workflow =>
+ throw new SchedulingValidationException(
+ $"Step '{step.Name}' is scheduled on workflow '{w.Name}' but is being resolved against workflow '{workflow.Name}'."),
+
+ GitHubActionsWorkflowResource w => w.GetOrAddDefaultJob(),
+
+ _ => throw new SchedulingValidationException(
+ $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy!.GetType().Name}' " +
+ $"which is not a recognized GitHub Actions target (workflow, stage, or job).")
+ };
+ }
+
+ ///
+ /// BFS through reverse dependencies to find the first explicitly-scheduled consumer's job.
+ /// This enables unscheduled steps to be "pulled into" the target of their nearest consumer.
+ ///
+ private static GitHubActionsJobResource? FindFirstConsumerJob(
+ string stepName,
+ Dictionary> reverseDeps,
+ Dictionary explicitStepToJob)
+ {
+ var visited = new HashSet(StringComparer.Ordinal) { stepName };
+ var queue = new Queue();
+ queue.Enqueue(stepName);
+
+ while (queue.Count > 0)
+ {
+ var current = queue.Dequeue();
+
+ if (!reverseDeps.TryGetValue(current, out var consumers))
+ {
+ continue;
+ }
+
+ foreach (var consumer in consumers)
+ {
+ if (explicitStepToJob.TryGetValue(consumer.Name, out var job))
+ {
+ return job;
+ }
+
+ if (visited.Add(consumer.Name))
+ {
+ queue.Enqueue(consumer.Name);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Finds a job for an orphan step by looking at where its dependencies are assigned.
+ /// This co-locates orphan steps with their dependencies to avoid creating cross-job
+ /// dependencies that could introduce cycles in the job dependency graph.
+ ///
+ private static GitHubActionsJobResource? FindDependencyJob(
+ PipelineStep step,
+ Dictionary stepToJob)
+ {
+ foreach (var depName in step.DependsOnSteps)
+ {
+ if (stepToJob.TryGetValue(depName, out var job))
+ {
+ return job;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the first available job on the workflow for orphan unscheduled steps
+ /// (steps with no downstream consumer that has explicit scheduling).
+ ///
+ private static GitHubActionsJobResource GetFirstAvailableJob(GitHubActionsWorkflowResource workflow)
+ {
+ if (workflow.Stages.Count > 0)
+ {
+ return workflow.Stages[0].GetOrAddDefaultJob();
+ }
+
+ if (workflow.Jobs.Count > 0)
+ {
+ return workflow.Jobs[0];
+ }
+
+ return workflow.GetOrAddDefaultJob();
+ }
+
+ 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 terminal step names per job. Terminal steps are those not depended on by any other
+ /// step in the same job — executing them via aspire do covers all steps in the job.
+ ///
+ public required Dictionary> TerminalStepsPerJob { get; init; }
+
+ ///
+ /// Gets the default job used for unscheduled steps, or null if all steps were explicitly scheduled.
+ ///
+ public GitHubActionsJobResource? 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/WorkflowCustomizationAnnotation.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.cs
new file mode 100644
index 00000000000..4320edcebd7
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.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;
+using Aspire.Hosting.Pipelines.GitHubActions.Yaml;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions;
+
+///
+/// Annotation that stores a callback for customizing the generated
+/// before it is serialized to disk.
+///
+internal sealed class WorkflowCustomizationAnnotation(Action callback) : IResourceAnnotation
+{
+ ///
+ /// Gets the customization callback.
+ ///
+ public Action Callback { get; } = callback ?? throw new ArgumentNullException(nameof(callback));
+}
diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs
new file mode 100644
index 00000000000..67ef2329591
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs
@@ -0,0 +1,355 @@
+// 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.Reflection;
+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-do-state-";
+ // Workspace-relative staging path for artifact upload/download.
+ // upload-artifact@v4 does NOT evaluate ${{ env.HOME }} or other env vars in
+ // the `path` parameter, so we must use a workspace-relative path and copy
+ // to/from the real state directory ($HOME/.aspire/deployments/) via run steps.
+ // IMPORTANT: Do NOT prefix with '.' — upload-artifact@v4 with the default
+ // include-hidden-files: false skips directories whose names start with '.'.
+ private const string StateStagingPath = "aspire-state-staging";
+ private const string StateRealPath = "$HOME/.aspire/deployments";
+
+ // Workspace-relative output path for publish artifacts (docker-compose.yaml, .env, etc.).
+ // This is passed to `aspire do --output-path` so both publish and deploy jobs use
+ // the same workspace-relative path, enabling artifact transfer between jobs.
+ // IMPORTANT: Do NOT prefix with '.' — see StateStagingPath comment.
+ private const string OutputPath = "aspire-output";
+
+ ///
+ /// Generates a workflow YAML model from the scheduling result.
+ ///
+ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow)
+ {
+ ArgumentNullException.ThrowIfNull(scheduling);
+ ArgumentNullException.ThrowIfNull(workflow);
+
+ var buildChannel = DetectBuildChannel();
+
+ var workflowYaml = new WorkflowYaml
+ {
+ Name = workflow.Name,
+ On = new WorkflowTriggers
+ {
+ WorkflowDispatch = true,
+ Push = new PushTrigger
+ {
+ Branches = ["main"]
+ },
+ PullRequest = new PullRequestTrigger
+ {
+ 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, workflow, buildChannel);
+ workflowYaml.Jobs[job.Id] = jobYaml;
+ }
+
+ return workflowYaml;
+ }
+
+ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, BuildChannel buildChannel)
+ {
+ // Collect dependency tags from all pipeline steps assigned to this job
+ var dependencyTags = new HashSet(StringComparer.Ordinal);
+ if (scheduling.StepsPerJob.TryGetValue(job.Id, out var pipelineSteps))
+ {
+ foreach (var step in pipelineSteps)
+ {
+ foreach (var tag in step.Tags)
+ {
+ dependencyTags.Add(tag);
+ }
+ }
+ }
+
+ // Every job runs `aspire do`, which implicitly requires the Aspire CLI
+ dependencyTags.Add(WellKnownDependencyTags.AspireCli);
+
+ var steps = new List();
+
+ // Always: checkout
+ steps.Add(new StepYaml
+ {
+ Name = "Checkout code",
+ Uses = "actions/checkout@v4"
+ });
+
+ // Conditional: Setup .NET (when any step needs .NET or Aspire CLI, which is .NET-based)
+ if (dependencyTags.Contains(WellKnownDependencyTags.DotNet) ||
+ dependencyTags.Contains(WellKnownDependencyTags.AspireCli))
+ {
+ steps.Add(new StepYaml
+ {
+ Name = "Setup .NET",
+ Uses = "actions/setup-dotnet@v4",
+ With = new Dictionary
+ {
+ ["dotnet-version"] = "10.0.x"
+ }
+ });
+ }
+
+ // Conditional: Setup Node.js (when any step needs Node.js)
+ if (dependencyTags.Contains(WellKnownDependencyTags.NodeJs))
+ {
+ steps.Add(new StepYaml
+ {
+ Name = "Setup Node.js",
+ Uses = "actions/setup-node@v4",
+ With = new Dictionary
+ {
+ ["node-version"] = "20"
+ }
+ });
+ }
+
+ // Conditional: Install Aspire CLI (when any step needs it — always true since aspire do runs)
+ if (dependencyTags.Contains(WellKnownDependencyTags.AspireCli))
+ {
+ steps.Add(GenerateAspireCliInstallStep(buildChannel));
+ }
+
+ // Conditional: Azure login (when any step needs Azure CLI)
+ if (dependencyTags.Contains(WellKnownDependencyTags.AzureCli))
+ {
+ steps.Add(new StepYaml
+ {
+ Name = "Azure login",
+ Uses = "azure/login@v2",
+ With = new Dictionary
+ {
+ ["client-id"] = "${{ vars.AZURE_CLIENT_ID }}",
+ ["tenant-id"] = "${{ vars.AZURE_TENANT_ID }}",
+ ["subscription-id"] = "${{ vars.AZURE_SUBSCRIPTION_ID }}"
+ }
+ });
+ }
+
+ // Download state artifacts from dependency jobs and restore
+ 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"] = StateStagingPath
+ }
+ });
+ }
+
+ // Restore deployment state and publish output from staging
+ steps.Add(new StepYaml
+ {
+ Name = "Restore pipeline state",
+ Run = string.Join("\n",
+ $"if [ -d \"{StateStagingPath}/deployments\" ]; then",
+ $" mkdir -p {StateRealPath}",
+ $" cp -r {StateStagingPath}/deployments/. {StateRealPath}/",
+ "fi",
+ $"if [ -d \"{StateStagingPath}/output\" ]; then",
+ $" mkdir -p {OutputPath}",
+ $" cp -r {StateStagingPath}/output/. {OutputPath}/",
+ "fi")
+ });
+ }
+
+ // Run aspire do targeting the synthetic scheduling step for this job.
+ // The synthetic step depends on the terminal steps, so its transitive closure
+ // covers all steps assigned to this job.
+ // --output-path forces a workspace-relative output directory so publish artifacts
+ // land in a known location that can be transferred between CI jobs via artifacts.
+ var stageName = FindStageName(workflow, job);
+ var syntheticStepName = $"gha-{workflow.Name}-{stageName}-stage-{job.Id}-job";
+
+ steps.Add(new StepYaml
+ {
+ Name = "Run pipeline steps",
+ Run = $"aspire do {syntheticStepName} --output-path {OutputPath}",
+ Env = new Dictionary
+ {
+ ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
+ }
+ });
+
+ // Stage deployment state and publish output into a single staging directory.
+ // Both are needed by downstream jobs: deployment state for secrets/config,
+ // publish output (docker-compose.yaml, .env, etc.) for deploy steps.
+ steps.Add(new StepYaml
+ {
+ Name = "Stage pipeline state",
+ Run = string.Join("\n",
+ $"mkdir -p {StateStagingPath}",
+ $"if [ -d \"{StateRealPath}\" ]; then",
+ $" mkdir -p {StateStagingPath}/deployments",
+ $" cp -r {StateRealPath}/. {StateStagingPath}/deployments/",
+ "fi",
+ $"if [ -d \"{OutputPath}\" ]; then",
+ $" mkdir -p {StateStagingPath}/output",
+ $" cp -r {OutputPath}/. {StateStagingPath}/output/",
+ "fi",
+ $"echo \"Staged files:\" && find {StateStagingPath} -type f 2>/dev/null || echo \"No files staged\"")
+ });
+
+ // Upload state artifacts for downstream jobs.
+ // include-hidden-files is needed for .env files produced by docker-compose publish.
+ steps.Add(new StepYaml
+ {
+ Name = "Upload state",
+ Uses = "actions/upload-artifact@v4",
+ With = new Dictionary
+ {
+ ["name"] = $"{StateArtifactPrefix}{job.Id}",
+ ["path"] = StateStagingPath,
+ ["if-no-files-found"] = "ignore",
+ ["include-hidden-files"] = "true"
+ }
+ });
+
+ // 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
+ };
+ }
+
+ private const string InstallScriptUrl = "https://aspire.dev/install.sh";
+ private const string PrInstallScriptUrl = "https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh";
+
+ private static StepYaml GenerateAspireCliInstallStep(BuildChannel buildChannel)
+ {
+ const string addToPath = """echo "$HOME/.aspire/bin" >> $GITHUB_PATH""";
+
+ // PR build: use the PR-specific install script with GH_TOKEN for artifact download
+ if (buildChannel.PrNumber is { } prNumber)
+ {
+ return new StepYaml
+ {
+ Name = "Install Aspire CLI",
+ Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}\n{addToPath}",
+ Env = new Dictionary
+ {
+ ["GH_TOKEN"] = "${{ github.token }}"
+ }
+ };
+ }
+
+ // Non-PR build: use aspire.dev/install.sh with quality flag based on prerelease status
+ // prerelease (e.g. preview/dev build) → -q dev
+ // stable (no prerelease suffix) → no flag
+ var installCommand = buildChannel.IsPrerelease
+ ? $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev"
+ : $"curl -sSL {InstallScriptUrl} | bash";
+
+ return new StepYaml
+ {
+ Name = "Install Aspire CLI",
+ Run = $"{installCommand}\n{addToPath}"
+ };
+ }
+
+ ///
+ /// Detects the build channel by inspecting the assembly's informational version.
+ /// PR builds contain -pr.{number} in the version suffix (e.g. 13.3.0-pr.15643.g8a1b2c3d).
+ ///
+ internal static BuildChannel DetectBuildChannel()
+ {
+ var version = typeof(WorkflowYamlGenerator).Assembly
+ .GetCustomAttribute()?.InformationalVersion;
+
+ return ParseBuildChannel(version);
+ }
+
+ ///
+ /// Parses a build channel from a version string.
+ ///
+ internal static BuildChannel ParseBuildChannel(string? version)
+ {
+ if (string.IsNullOrEmpty(version))
+ {
+ return new BuildChannel(PrNumber: null, IsPrerelease: false);
+ }
+
+ // Strip the +commit suffix (e.g. "+8a1b2c3d...")
+ var plusIdx = version.IndexOf('+');
+ var versionCore = plusIdx >= 0 ? version[..plusIdx] : version;
+
+ // Check for PR pattern: "-pr.{digits}" in the version string
+ const string prMarker = "-pr.";
+ var prIdx = versionCore.IndexOf(prMarker, StringComparison.OrdinalIgnoreCase);
+ if (prIdx >= 0)
+ {
+ var afterMarker = versionCore.AsSpan(prIdx + prMarker.Length);
+ // Take digits until next '.' or end of string
+ var dotIdx = afterMarker.IndexOf('.');
+ var numberSpan = dotIdx >= 0 ? afterMarker[..dotIdx] : afterMarker;
+ if (int.TryParse(numberSpan, out var prNumber))
+ {
+ return new BuildChannel(PrNumber: prNumber, IsPrerelease: true);
+ }
+ }
+
+ // Any prerelease suffix (contains '-') means it's a dev/preview build
+ var isPrerelease = versionCore.Contains('-');
+ return new BuildChannel(PrNumber: null, IsPrerelease: isPrerelease);
+ }
+
+ ///
+ /// Represents the detected build channel from assembly version metadata.
+ ///
+ internal readonly record struct BuildChannel(int? PrNumber, bool IsPrerelease);
+
+ private static string FindStageName(GitHubActionsWorkflowResource workflow, GitHubActionsJobResource job)
+ {
+ foreach (var stage in workflow.Stages)
+ {
+ for (var i = 0; i < stage.Jobs.Count; i++)
+ {
+ if (stage.Jobs[i].Id == job.Id)
+ {
+ return stage.Name;
+ }
+ }
+ }
+
+ return "default";
+ }
+}
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..1955d71c400
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs
@@ -0,0 +1,185 @@
+// 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.GitHubActions.Yaml;
+
+///
+/// Represents a complete GitHub Actions workflow YAML document.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class WorkflowYaml
+{
+ ///
+ /// Gets the name of the workflow.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the trigger configuration for the workflow.
+ ///
+ public WorkflowTriggers On { get; init; } = new();
+
+ ///
+ /// Gets the top-level permissions for the workflow.
+ ///
+ public Dictionary? Permissions { get; init; }
+
+ ///
+ /// Gets the jobs defined in the workflow, keyed by job ID.
+ ///
+ public Dictionary Jobs { get; init; } = new(StringComparer.Ordinal);
+}
+
+///
+/// Represents the trigger configuration for a workflow.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class WorkflowTriggers
+{
+ ///
+ /// Gets a value indicating whether the workflow can be manually triggered.
+ ///
+ public bool WorkflowDispatch { get; init; } = true;
+
+ ///
+ /// Gets the push trigger configuration.
+ ///
+ public PushTrigger? Push { get; init; }
+
+ ///
+ /// Gets the pull request trigger configuration.
+ ///
+ public PullRequestTrigger? PullRequest { get; init; }
+}
+
+///
+/// Represents the push trigger configuration.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PushTrigger
+{
+ ///
+ /// Gets the branch patterns that trigger the workflow on push.
+ ///
+ public List Branches { get; init; } = [];
+}
+
+///
+/// Represents the pull request trigger configuration.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PullRequestTrigger
+{
+ ///
+ /// Gets the branch patterns that trigger the workflow on pull request.
+ ///
+ public List Branches { get; init; } = [];
+}
+
+///
+/// Represents a job in the workflow.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class JobYaml
+{
+ ///
+ /// Gets the display name of the job.
+ ///
+ public string? Name { get; init; }
+
+ ///
+ /// Gets the runner label for the job (e.g., "ubuntu-latest").
+ ///
+ public string RunsOn { get; init; } = "ubuntu-latest";
+
+ ///
+ /// Gets the conditional expression controlling whether the job runs.
+ ///
+ public string? If { get; init; }
+
+ ///
+ /// Gets the GitHub deployment environment name for the job.
+ ///
+ public string? Environment { get; init; }
+
+ ///
+ /// Gets the list of job IDs that this job depends on.
+ ///
+ public List? Needs { get; init; }
+
+ ///
+ /// Gets the permissions granted to the job.
+ ///
+ public Dictionary? Permissions { get; init; }
+
+ ///
+ /// Gets the environment variables for the job.
+ ///
+ public Dictionary? Env { get; init; }
+
+ ///
+ /// Gets the concurrency configuration for the job.
+ ///
+ public ConcurrencyYaml? Concurrency { get; init; }
+
+ ///
+ /// Gets the steps in the job.
+ ///
+ public List Steps { get; init; } = [];
+}
+
+///
+/// Represents a step within a job.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class StepYaml
+{
+ ///
+ /// Gets the display name of the step.
+ ///
+ public string? Name { get; init; }
+
+ ///
+ /// Gets the action reference (e.g., "actions/checkout@v4").
+ ///
+ public string? Uses { get; init; }
+
+ ///
+ /// Gets the shell command to run.
+ ///
+ public string? Run { get; init; }
+
+ ///
+ /// Gets the input parameters passed to the action.
+ ///
+ public Dictionary? With { get; init; }
+
+ ///
+ /// Gets the environment variables for the step.
+ ///
+ public Dictionary? Env { get; init; }
+
+ ///
+ /// Gets the step identifier for referencing in expressions.
+ ///
+ public string? Id { get; init; }
+}
+
+///
+/// Represents concurrency configuration for a job.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class ConcurrencyYaml
+{
+ ///
+ /// Gets the concurrency group name.
+ ///
+ public required string Group { get; init; }
+
+ ///
+ /// Gets a value indicating whether to cancel in-progress runs in the same concurrency group.
+ ///
+ 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..b855be5cecf
--- /dev/null
+++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs
@@ -0,0 +1,258 @@
+// 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.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}");
+ }
+ }
+ }
+
+ if (triggers.PullRequest is not null)
+ {
+ sb.AppendLine(" pull_request:");
+ if (triggers.PullRequest.Branches.Count > 0)
+ {
+ sb.AppendLine(" branches:");
+ foreach (var branch in triggers.PullRequest.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)
+ {
+ // GitHub Actions expressions (${{ ... }}) must NOT be single-quoted or they
+ // are treated as literal strings instead of being evaluated.
+ if (value.Contains("${{"))
+ {
+ return 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/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
index 1e960005d69..e143d4b9775 100644
--- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
@@ -45,7 +45,7 @@ public ProjectResource(string name) : base(name)
Name = $"build-{name}",
Description = $"Builds the container image for the {name} project.",
Action = BuildProjectImage,
- Tags = [WellKnownPipelineTags.BuildCompute],
+ Tags = [WellKnownPipelineTags.BuildCompute, WellKnownDependencyTags.DotNet, WellKnownDependencyTags.Docker],
RequiredBySteps = [WellKnownPipelineSteps.Build],
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq],
Resource = this
@@ -58,7 +58,7 @@ public ProjectResource(string name) : base(name)
{
Name = $"push-{name}",
Action = ctx => PipelineStepHelpers.PushImageToRegistryAsync(this, ctx),
- Tags = [WellKnownPipelineTags.PushContainerImage],
+ Tags = [WellKnownPipelineTags.PushContainerImage, WellKnownDependencyTags.Docker],
RequiredBySteps = [WellKnownPipelineSteps.Push],
Resource = this
};
diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml
index 091fae9ffc4..cb55a5ff414 100644
--- a/src/Aspire.Hosting/CompatibilitySuppressions.xml
+++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml
@@ -85,6 +85,13 @@
lib/net8.0/Aspire.Hosting.dll
true
+
+ CP0006
+ M:Aspire.Hosting.Pipelines.IDistributedApplicationPipeline.ScheduleStep(System.String,Aspire.Hosting.Pipelines.IPipelineStepTarget)
+ lib/net8.0/Aspire.Hosting.dll
+ lib/net8.0/Aspire.Hosting.dll
+ true
+
CP0021
M:Aspire.Hosting.ResourceBuilderExtensions.WithImagePushOptions``1(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Action{Aspire.Hosting.ApplicationModel.ContainerImagePushOptionsCallbackContext})``0:T:Aspire.Hosting.ApplicationModel.IComputeResource
diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
index 54b059c81c1..2032c5d7221 100644
--- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
@@ -49,7 +49,7 @@ await containerImageBuilder.BuildImageAsync(
builder.Resource,
ctx.CancellationToken).ConfigureAwait(false);
},
- Tags = [WellKnownPipelineTags.BuildCompute],
+ Tags = [WellKnownPipelineTags.BuildCompute, WellKnownDependencyTags.Docker],
RequiredBySteps = [WellKnownPipelineSteps.Build],
DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq],
Resource = builder.Resource
@@ -63,7 +63,7 @@ await containerImageBuilder.BuildImageAsync(
{
Name = $"push-{builder.Resource.Name}",
Action = ctx => PipelineStepHelpers.PushImageToRegistryAsync(builder.Resource, ctx),
- Tags = [WellKnownPipelineTags.PushContainerImage],
+ Tags = [WellKnownPipelineTags.PushContainerImage, WellKnownDependencyTags.Docker],
RequiredBySteps = [WellKnownPipelineSteps.Push],
Resource = builder.Resource
};
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..a24e6846121 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
@@ -264,6 +267,22 @@ public DistributedApplicationPipeline()
DumpDependencyGraphDiagnostics(stepsToAnalyze, context);
}
});
+
+ // Add pipeline-init step for generating CI/CD workflow files
+ _steps.Add(new PipelineStep
+ {
+ Name = WellKnownPipelineSteps.PipelineInit,
+ Description = "Generates CI/CD pipeline workflow files from pipeline environment resources in the app model.",
+ Action = ExecutePipelineInitAsync
+ });
+ }
+
+ ///
+ /// 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;
@@ -272,6 +291,23 @@ public void AddStep(string name,
Func action,
object? dependsOn = null,
object? requiredBy = null)
+ {
+ AddStep(name, action, dependsOn, requiredBy, scheduledBy: null);
+ }
+
+ ///
+ /// Adds a deployment step to the pipeline with an optional scheduling target.
+ ///
+ /// The unique name of the step.
+ /// 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).
+ public void AddStep(string name,
+ Func action,
+ object? dependsOn,
+ object? requiredBy,
+ IPipelineStepTarget? scheduledBy)
{
if (_steps.Any(s => s.Name == name))
{
@@ -282,7 +318,8 @@ public void AddStep(string name,
var step = new PipelineStep
{
Name = name,
- Action = action
+ Action = action,
+ ScheduledBy = scheduledBy
};
if (dependsOn != null)
@@ -351,20 +388,73 @@ 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);
- var allSteps = _steps.Concat(annotationSteps).ToList();
+ var (resourceSteps, deferredAnnotations) = await CollectResourceStepsAsync(context, _steps).ConfigureAwait(false);
+ var allStepsSoFar = _steps.Concat(resourceSteps).ToList();
+
+ // Execute configuration callbacks before the deferred (pipeline environment) pass.
+ // Config callbacks wire up cross-step dependencies (e.g., push-X.DependsOn(build-X))
+ // that the scheduling resolver needs to see for correct job assignments.
+ await ExecuteConfigurationCallbacksAsync(context, allStepsSoFar).ConfigureAwait(false);
- // Execute configuration callbacks even if there are no steps
- // This allows callbacks to run validation or other logic
- await ExecuteConfigurationCallbacksAsync(context, allSteps).ConfigureAwait(false);
+ // Deferred pass: pipeline environment annotations (e.g., GHA workflow) run
+ // scheduling over the fully-wired step graph, including config callback edges.
+ var environmentSteps = await CollectEnvironmentStepsAsync(context, deferredAnnotations, allStepsSoFar).ConfigureAwait(false);
+ var allSteps = allStepsSoFar.Concat(environmentSteps).ToList();
if (allSteps.Count == 0)
{
@@ -382,8 +472,13 @@ public async Task ExecuteAsync(PipelineContext context)
var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context);
+ // Detect scope from the active pipeline environment. If we're running inside
+ // a CI job with a scope annotation, enter continuation mode: only in-scope steps
+ // execute, while out-of-scope steps are skipped (or have their TryRestoreStepAsync invoked).
+ var inScopeStepNames = await DetectScopeAsync(context).ConfigureAwait(false);
+
// Build dependency graph and execute with readiness-based scheduler
- await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false);
+ await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context, inScopeStepNames).ConfigureAwait(false);
}
///
@@ -443,6 +538,69 @@ private static (List StepsToExecute, Dictionary
+ /// Detects the current execution scope from the active pipeline environment.
+ /// Returns the set of in-scope step names, or null if not in continuation mode.
+ ///
+ private async Task?> DetectScopeAsync(PipelineContext context)
+ {
+ // Find the active pipeline environment
+ var environment = await GetEnvironmentAsync(context.CancellationToken).ConfigureAwait(false);
+
+ if (environment is not IResource environmentResource)
+ {
+ return null;
+ }
+
+ // Check for scope annotation
+ if (!environmentResource.TryGetAnnotationsOfType(out var scopeAnnotations))
+ {
+ return null;
+ }
+
+ var scopeAnnotation = scopeAnnotations.FirstOrDefault();
+ if (scopeAnnotation is null)
+ {
+ return null;
+ }
+
+ var scopeContext = new PipelineScopeContext { CancellationToken = context.CancellationToken };
+ var scopeResult = await scopeAnnotation.ResolveAsync(scopeContext).ConfigureAwait(false);
+
+ if (scopeResult is null)
+ {
+ return null;
+ }
+
+ // We have a scope — look up the scope map to find which steps are in this scope
+ if (!environmentResource.TryGetAnnotationsOfType(out var mapAnnotations))
+ {
+ context.Logger.LogWarning(
+ "Scope detected (RunId={RunId}, JobId={JobId}) but no PipelineScopeMapAnnotation found. " +
+ "All steps will execute.",
+ scopeResult.RunId,
+ scopeResult.JobId);
+ return null;
+ }
+
+ var scopeMap = mapAnnotations.FirstOrDefault();
+ if (scopeMap is null || !scopeMap.ScopeToSteps.TryGetValue(scopeResult.JobId, out var stepNames))
+ {
+ context.Logger.LogWarning(
+ "Scope '{JobId}' not found in scope map. All steps will execute.",
+ scopeResult.JobId);
+ return null;
+ }
+
+ context.Logger.LogInformation(
+ "Continuation mode: RunId={RunId}, JobId={JobId}, executing {Count} step(s)",
+ scopeResult.RunId,
+ scopeResult.JobId,
+ stepNames.Count);
+
+ return new HashSet(stepNames, StringComparer.Ordinal);
+ }
+
private static List ComputeTransitiveDependencies(
PipelineStep step,
Dictionary stepsByName)
@@ -476,9 +634,14 @@ void Visit(string stepName)
return result;
}
- private static async Task> CollectStepsFromAnnotationsAsync(PipelineContext context)
+ ///
+ /// First pass: collects steps from non-pipeline-environment resource annotations.
+ /// Returns the collected steps and any deferred (pipeline environment) annotations for the second pass.
+ ///
+ private static async Task<(List Steps, List<(IResource Resource, PipelineStepAnnotation Annotation)> DeferredAnnotations)> CollectResourceStepsAsync(PipelineContext context, IReadOnlyList existingSteps)
{
var steps = new List();
+ var deferredAnnotations = new List<(IResource Resource, PipelineStepAnnotation Annotation)>();
foreach (var resource in context.Model.Resources)
{
@@ -487,10 +650,18 @@ private static async Task> CollectStepsFromAnnotationsAsync(P
foreach (var annotation in annotations)
{
+ if (resource is IPipelineEnvironment)
+ {
+ // Defer pipeline environment annotations — they need visibility of all collected steps
+ deferredAnnotations.Add((resource, annotation));
+ continue;
+ }
+
var factoryContext = new PipelineStepFactoryContext
{
PipelineContext = context,
- Resource = resource
+ Resource = resource,
+ ExistingSteps = existingSteps
};
var annotationSteps = await annotation.CreateStepsAsync(factoryContext).ConfigureAwait(false);
@@ -502,6 +673,38 @@ private static async Task> CollectStepsFromAnnotationsAsync(P
}
}
+ return (steps, deferredAnnotations);
+ }
+
+ ///
+ /// Second pass: collects steps from deferred pipeline environment annotations.
+ /// These annotations run scheduling over the fully-wired step graph (including
+ /// configuration callback edges) so that job assignments are correct.
+ ///
+ private static async Task> CollectEnvironmentStepsAsync(
+ PipelineContext context,
+ List<(IResource Resource, PipelineStepAnnotation Annotation)> deferredAnnotations,
+ List allStepsSoFar)
+ {
+ var steps = new List();
+
+ foreach (var (resource, annotation) in deferredAnnotations)
+ {
+ var factoryContext = new PipelineStepFactoryContext
+ {
+ PipelineContext = context,
+ Resource = resource,
+ ExistingSteps = allStepsSoFar
+ };
+
+ var annotationSteps = await annotation.CreateStepsAsync(factoryContext).ConfigureAwait(false);
+ foreach (var step in annotationSteps)
+ {
+ steps.Add(step);
+ step.Resource ??= resource;
+ }
+ }
+
return steps;
}
@@ -583,7 +786,8 @@ private static void ValidateSteps(IEnumerable steps)
private static async Task ExecuteStepsAsTaskDag(
List steps,
Dictionary stepsByName,
- PipelineContext context)
+ PipelineContext context,
+ HashSet? inScopeStepNames = null)
{
// Validate no cycles exist in the dependency graph
ValidateDependencyGraph(steps, stepsByName);
@@ -655,7 +859,7 @@ async Task ExecuteStepWithDependencies(PipelineStep step)
{
PipelineLoggerProvider.CurrentStep = reportingStep;
- await ExecuteStepAsync(step, stepContext).ConfigureAwait(false);
+ await ExecuteStepAsync(step, stepContext, inScopeStepNames).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -835,10 +1039,32 @@ void DetectCycles(string stepName, Stack path)
}
}
- private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContext stepContext)
+ private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContext stepContext, HashSet? inScopeStepNames = null)
{
try
{
+ // In continuation mode, out-of-scope steps are either restored or skipped.
+ var isInScope = inScopeStepNames is null || inScopeStepNames.Contains(step.Name);
+
+ // 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;
+ }
+ }
+
+ // Out-of-scope steps that couldn't restore are auto-skipped in continuation mode.
+ // Their dependencies are still satisfied so downstream in-scope steps can proceed.
+ if (!isInScope)
+ {
+ return;
+ }
+
await step.Action(stepContext).ConfigureAwait(false);
}
catch (DistributedApplicationException)
@@ -1213,4 +1439,186 @@ public override string ToString()
return sb.ToString();
}
+
+ private async Task ExecutePipelineInitAsync(PipelineStepContext context)
+ {
+ // Discover all pipeline environment resources in the app model
+ var environments = _model.Resources.OfType().ToList();
+
+ if (environments.Count == 0)
+ {
+ context.Logger.LogWarning(
+ "No pipeline environment resources found in the app model. " +
+ "Add a pipeline environment (e.g., builder.AddGitHubActionsWorkflow(\"deploy\")) to generate workflow files.");
+ return;
+ }
+
+ // Detect the repository root directory (best-effort default — extensions may override)
+ var repoRoot = await DetectRepositoryRootAsync(context).ConfigureAwait(false);
+
+ if (repoRoot is not null)
+ {
+ context.Logger.LogInformation("Using repository root: {RepoRoot}", repoRoot);
+ }
+
+ foreach (var env in environments)
+ {
+ var resource = (IResource)env;
+
+ if (!resource.TryGetAnnotationsOfType(out var generators))
+ {
+ context.Logger.LogWarning(
+ "Pipeline environment '{Name}' does not have a workflow generator annotation. Skipping.",
+ resource.Name);
+ continue;
+ }
+
+ context.Logger.LogInformation("Generating workflow files for pipeline environment: {Name}", resource.Name);
+
+ var generationContext = new PipelineWorkflowGenerationContext
+ {
+ StepContext = context,
+ Environment = env,
+ Steps = _lastResolvedSteps ?? _steps,
+ RepositoryRootDirectory = repoRoot,
+ };
+
+ foreach (var generator in generators)
+ {
+ await generator.GenerateAsync(generationContext).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Detects the repository root directory by trying git, then aspire.config.json,
+ /// and confirms the result with the user via the interaction service.
+ ///
+ private static async Task DetectRepositoryRootAsync(PipelineStepContext context)
+ {
+ var ct = context.CancellationToken;
+ var logger = context.Logger;
+
+ // Strategy 1: Try git rev-parse --show-toplevel
+ var gitRoot = await TryGetGitRootAsync(logger, ct).ConfigureAwait(false);
+
+ // Strategy 2: Walk up directories looking for aspire.config.json
+ var configRoot = TryGetAspireConfigRoot(logger);
+
+ // Determine the best candidate
+ string? detectedRoot = gitRoot ?? configRoot;
+
+ if (detectedRoot is null)
+ {
+ logger.LogWarning("Could not auto-detect repository root via git or aspire.config.json.");
+ }
+
+ // Confirm with the user via interaction service, allowing override
+ var interactionService = context.Services.GetService();
+
+ if (interactionService is null)
+ {
+ // No interaction service available (e.g., non-interactive mode) — use what we detected
+ return detectedRoot;
+ }
+
+ var prompt = detectedRoot is not null
+ ? $"Detected repository root: {detectedRoot}"
+ : "Could not auto-detect the repository root.";
+
+ var result = await interactionService.PromptInputAsync(
+ "Repository Root",
+ prompt + " Enter the path or press OK to confirm.",
+ new InteractionInput
+ {
+ Name = "repoRoot",
+ Label = "Repository root directory",
+ InputType = InputType.Text,
+ Value = detectedRoot ?? Directory.GetCurrentDirectory(),
+ Required = true,
+ },
+ cancellationToken: ct).ConfigureAwait(false);
+
+ if (result.Canceled)
+ {
+ logger.LogWarning("User cancelled repository root selection.");
+ return null;
+ }
+
+ var selectedPath = result.Data?.Value;
+ if (string.IsNullOrWhiteSpace(selectedPath))
+ {
+ return detectedRoot;
+ }
+
+ var fullPath = Path.GetFullPath(selectedPath);
+ if (!Directory.Exists(fullPath))
+ {
+ logger.LogError("The specified repository root does not exist: {Path}", fullPath);
+ return null;
+ }
+
+ return fullPath;
+ }
+
+ private static async Task TryGetGitRootAsync(ILogger logger, CancellationToken ct)
+ {
+ try
+ {
+ var startInfo = new ProcessStartInfo("git", "rev-parse --show-toplevel")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using var process = new Process { StartInfo = startInfo };
+ process.Start();
+
+ var outputTask = process.StandardOutput.ReadToEndAsync(ct);
+ await process.WaitForExitAsync(ct).ConfigureAwait(false);
+
+ if (process.ExitCode != 0)
+ {
+ logger.LogDebug("git rev-parse returned non-zero exit code {ExitCode}.", process.ExitCode);
+ return null;
+ }
+
+ var output = (await outputTask.ConfigureAwait(false)).Trim();
+ if (!string.IsNullOrEmpty(output) && Directory.Exists(output))
+ {
+ logger.LogDebug("Detected git repository root: {GitRoot}", output);
+ return Path.GetFullPath(output);
+ }
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
+ {
+ logger.LogDebug(ex, "Git is not installed or not accessible.");
+ }
+
+ return null;
+ }
+
+ private static string? TryGetAspireConfigRoot(ILogger logger)
+ {
+ const string configFileName = "aspire.config.json";
+
+ var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
+
+ while (directory is not null)
+ {
+ var configPath = Path.Combine(directory.FullName, configFileName);
+ if (File.Exists(configPath))
+ {
+ logger.LogDebug("Found {ConfigFile} at: {Directory}", configFileName, directory.FullName);
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+
+ logger.LogDebug("Could not find {ConfigFile} walking up from current directory.", configFileName);
+ return null;
+ }
}
diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs
new file mode 100644
index 00000000000..955bc67aa3d
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs
@@ -0,0 +1,39 @@
+// 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;
+
+///
+/// Extension methods for .
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public static class DistributedApplicationPipelineExtensions
+{
+ ///
+ /// Adds a step to the pipeline and schedules it to run on the specified target.
+ ///
+ /// The pipeline.
+ /// The unique name of the step.
+ /// The action to execute for this step.
+ /// The target to schedule the step on.
+ /// 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.
+ public static void AddStep(
+ this IDistributedApplicationPipeline pipeline,
+ string name,
+ Func action,
+ IPipelineStepTarget scheduledBy,
+ object? dependsOn = null,
+ object? requiredBy = null)
+ {
+ ArgumentNullException.ThrowIfNull(pipeline);
+ ArgumentNullException.ThrowIfNull(scheduledBy);
+
+ pipeline.AddStep(name, action, dependsOn, requiredBy);
+ pipeline.ScheduleStep(name, scheduledBy);
+ }
+}
diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
index d5845632e08..6cf20fc3cd0 100644
--- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
+++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
@@ -37,6 +37,13 @@ void AddStep(string name,
/// The callback function to execute during the configuration phase.
void AddPipelineConfiguration(Func callback);
+ ///
+ /// Schedules a pipeline step to run on a specific pipeline step target (e.g., a workflow job or stage).
+ ///
+ /// The name of the step to schedule.
+ /// The target to schedule the step on.
+ void ScheduleStep(string stepName, IPipelineStepTarget target);
+
///
/// Executes all steps in the pipeline in dependency order.
///
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/Internal/ContinuationStateManager.cs b/src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs
new file mode 100644
index 00000000000..dff7e1bd347
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs
@@ -0,0 +1,327 @@
+// 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
+#pragma warning disable ASPIREPIPELINES002
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Pipelines.Internal;
+
+///
+/// State manager for continuation-mode pipeline execution. Reads merged state from
+/// all scope files in a run-scoped directory; writes only to the current scope's file.
+///
+internal sealed class ContinuationStateManager : IDeploymentStateManager
+{
+ private static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ private readonly ILogger _logger;
+ private readonly PipelineScopeResult _scope;
+ private readonly string _stateDirectory;
+ private readonly string _writeFilePath;
+
+ private readonly SemaphoreSlim _stateLock = new(1, 1);
+ private readonly object _sectionsLock = new();
+ private readonly Dictionary _sectionVersions = new();
+ private JsonObject? _mergedState;
+ private bool _isLoaded;
+
+ public ContinuationStateManager(
+ ILogger logger,
+ PipelineScopeResult scope,
+ string baseDirectory)
+ {
+ _logger = logger;
+ _scope = scope;
+ _stateDirectory = Path.Combine(baseDirectory, scope.RunId);
+ _writeFilePath = Path.Combine(_stateDirectory, $"{scope.JobId}.json");
+ }
+
+ ///
+ public string? StateFilePath => _writeFilePath;
+
+ ///
+ /// Loads and merges all state files from the run-scoped directory.
+ ///
+ private async Task LoadMergedStateAsync(CancellationToken cancellationToken)
+ {
+ await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_isLoaded && _mergedState is not null)
+ {
+ return _mergedState;
+ }
+
+ _mergedState = new JsonObject();
+
+ if (Directory.Exists(_stateDirectory))
+ {
+ var jsonFiles = Directory.GetFiles(_stateDirectory, "*.json");
+ _logger.LogDebug(
+ "Loading {Count} state file(s) from {Directory}",
+ jsonFiles.Length,
+ _stateDirectory);
+
+ var jsonDocumentOptions = new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ foreach (var filePath in jsonFiles)
+ {
+ try
+ {
+ var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
+ var flattenedState = JsonNode.Parse(content, documentOptions: jsonDocumentOptions)?.AsObject();
+ if (flattenedState is null)
+ {
+ continue;
+ }
+
+ var unflattenedState = JsonFlattener.UnflattenJsonObject(flattenedState);
+ MergeJsonObject(_mergedState, unflattenedState, Path.GetFileName(filePath));
+
+ _logger.LogDebug("Merged state from {File}", Path.GetFileName(filePath));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load state file {File}", filePath);
+ }
+ }
+ }
+ else
+ {
+ _logger.LogDebug("State directory {Directory} does not exist, starting with empty state", _stateDirectory);
+ }
+
+ _isLoaded = true;
+ return _mergedState;
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
+ ///
+ public async Task AcquireSectionAsync(
+ string sectionName,
+ CancellationToken cancellationToken = default)
+ {
+ var state = await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false);
+
+ long version;
+ lock (_sectionsLock)
+ {
+ if (!_sectionVersions.TryGetValue(sectionName, out version))
+ {
+ version = 0;
+ _sectionVersions[sectionName] = version;
+ }
+ }
+
+ await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ JsonObject? data = null;
+ string? value = null;
+
+ var sectionData = TryGetNestedPropertyValue(state, sectionName);
+ if (sectionData is JsonObject o)
+ {
+ data = o.DeepClone().AsObject();
+ }
+ else if (sectionData is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String)
+ {
+ value = jsonValue.GetValue();
+ }
+
+ var section = new DeploymentStateSection(sectionName, data, version);
+ if (value is not null)
+ {
+ section.SetValue(value);
+ }
+
+ return section;
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
+ ///
+ public async Task SaveSectionAsync(
+ DeploymentStateSection section,
+ CancellationToken cancellationToken = default)
+ {
+ await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false);
+
+ lock (_sectionsLock)
+ {
+ if (_sectionVersions.TryGetValue(section.SectionName, out var currentVersion)
+ && currentVersion != section.Version)
+ {
+ throw new InvalidOperationException(
+ $"Concurrency conflict in section '{section.SectionName}'. " +
+ $"Expected version {section.Version}, current is {currentVersion}.");
+ }
+
+ _sectionVersions[section.SectionName] = section.Version + 1;
+ }
+
+ section.Version++;
+
+ await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // Update merged state for in-memory reads
+ SetNestedPropertyValue(_mergedState!, section.SectionName, section.Data.DeepClone().AsObject());
+
+ // Write only the current scope's state file
+ await WriteScopeFileAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
+ ///
+ public async Task DeleteSectionAsync(
+ DeploymentStateSection section,
+ CancellationToken cancellationToken = default)
+ {
+ await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false);
+
+ lock (_sectionsLock)
+ {
+ if (_sectionVersions.TryGetValue(section.SectionName, out var currentVersion)
+ && currentVersion != section.Version)
+ {
+ throw new InvalidOperationException(
+ $"Concurrency conflict in section '{section.SectionName}'. " +
+ $"Expected version {section.Version}, current is {currentVersion}.");
+ }
+
+ _sectionVersions[section.SectionName] = section.Version + 1;
+ }
+
+ section.Version++;
+
+ await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ SetNestedPropertyValue(_mergedState!, section.SectionName, null);
+ await WriteScopeFileAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
+ ///
+ /// Writes the current scope's portion of the merged state to its scope file.
+ /// Only sections modified by this scope are included (tracked by the merged state).
+ ///
+ private async Task WriteScopeFileAsync(CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(_stateDirectory);
+
+ // We write the full merged state as the scope file. This is fine because
+ // each scope writes distinct sections in practice. The merge-on-read
+ // approach handles the recombination correctly.
+ var flattened = JsonFlattener.FlattenJsonObject(_mergedState!);
+ await File.WriteAllTextAsync(
+ _writeFilePath,
+ flattened.ToJsonString(s_jsonSerializerOptions),
+ cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug("State saved to {Path}", _writeFilePath);
+ }
+
+ ///
+ /// Deep-merges properties from into .
+ ///
+ private static void MergeJsonObject(JsonObject target, JsonObject source, string sourceFile)
+ {
+ foreach (var kvp in source)
+ {
+ if (kvp.Value is null)
+ {
+ continue;
+ }
+
+ if (target.TryGetPropertyValue(kvp.Key, out var existingNode) && existingNode is JsonObject existingObj
+ && kvp.Value is JsonObject sourceObj)
+ {
+ MergeJsonObject(existingObj, sourceObj.DeepClone().AsObject(), sourceFile);
+ }
+ else
+ {
+ target[kvp.Key] = kvp.Value.DeepClone();
+ }
+ }
+ }
+
+ private static JsonNode? TryGetNestedPropertyValue(JsonObject? node, string path)
+ {
+ if (node is null)
+ {
+ return null;
+ }
+
+ var segments = path.Split(':');
+ JsonNode? current = node;
+
+ foreach (var segment in segments)
+ {
+ if (current is not JsonObject currentObj || !currentObj.TryGetPropertyValue(segment, out var nextNode))
+ {
+ return null;
+ }
+ current = nextNode;
+ }
+
+ return current;
+ }
+
+ private static void SetNestedPropertyValue(JsonObject root, string path, JsonObject? value)
+ {
+ var segments = path.Split(':');
+
+ var current = root;
+ for (var i = 0; i < segments.Length - 1; i++)
+ {
+ var segment = segments[i];
+ if (!current.TryGetPropertyValue(segment, out var nextNode) || nextNode is not JsonObject nextObj)
+ {
+ if (value is null)
+ {
+ return;
+ }
+ nextObj = new JsonObject();
+ current[segment] = nextObj;
+ }
+ current = nextObj;
+ }
+
+ if (value is null)
+ {
+ current.Remove(segments[^1]);
+ }
+ else
+ {
+ current[segments[^1]] = value;
+ }
+ }
+}
diff --git a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs
new file mode 100644
index 00000000000..3127eb9a19f
--- /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/PipelineScopeAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs
new file mode 100644
index 00000000000..043c5dce9bf
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs
@@ -0,0 +1,38 @@
+// 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 resolves the current execution scope from the CI environment.
+///
+///
+///
+/// Apply this annotation to an resource to enable automatic
+/// scope detection during pipeline execution. When a scope is detected, the executor enters
+/// continuation mode: only steps assigned to the current scope execute, while steps from other
+/// scopes are restored from state.
+///
+///
+/// Each pipeline provider supplies its own resolution logic. For example, a GitHub Actions provider
+/// reads GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GITHUB_JOB to produce a
+/// with run-level isolation and job-level step filtering.
+///
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineScopeAnnotation(
+ Func> resolveAsync) : IResourceAnnotation
+{
+ ///
+ /// Resolves the current pipeline execution scope from the environment.
+ ///
+ /// The context for the scope resolution.
+ ///
+ /// A containing the run and job identifiers if running
+ /// in a recognized CI environment; otherwise, null.
+ ///
+ public Task ResolveAsync(PipelineScopeContext context) => resolveAsync(context);
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs
new file mode 100644
index 00000000000..dc90c788786
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineScopeContext.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 resolving the current pipeline execution scope.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineScopeContext
+{
+ ///
+ /// Gets the cancellation token for the scope resolution operation.
+ ///
+ public required CancellationToken CancellationToken { get; init; }
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs
new file mode 100644
index 00000000000..ec011bb4190
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs
@@ -0,0 +1,35 @@
+// 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 maps scope identifiers to the pipeline steps assigned to each scope.
+///
+///
+///
+/// This annotation is populated by pipeline environment providers after scheduling resolution.
+/// For example, the GitHub Actions provider maps job IDs (from GITHUB_JOB) to the step
+/// names assigned to each job by the SchedulingResolver.
+///
+///
+/// During continuation-mode execution, the pipeline executor uses this mapping to determine
+/// which steps should execute in the current scope and which should be restored from state.
+///
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineScopeMapAnnotation(
+ IReadOnlyDictionary> scopeToSteps) : IResourceAnnotation
+{
+ ///
+ /// Gets the mapping from scope identifiers to the step names assigned to each scope.
+ ///
+ ///
+ /// A dictionary where the key is a scope identifier (e.g., a CI job ID) and the value
+ /// is the ordered list of step names that should execute within that scope.
+ ///
+ public IReadOnlyDictionary> ScopeToSteps { get; } = scopeToSteps;
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs
new file mode 100644
index 00000000000..c9a02377599
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs
@@ -0,0 +1,33 @@
+// 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 the resolved scope for the current pipeline execution context,
+/// identifying both the workflow run and the specific job within that run.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineScopeResult
+{
+ ///
+ /// Gets the unique identifier for the current workflow run.
+ ///
+ ///
+ /// Used to isolate state directories so concurrent workflow runs on the same machine
+ /// (e.g., self-hosted CI runners) don't interfere with each other. For GitHub Actions,
+ /// this is typically composed from GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT.
+ ///
+ public required string RunId { get; init; }
+
+ ///
+ /// Gets the unique identifier for the current job/scope within the workflow run.
+ ///
+ ///
+ /// Used for step filtering (determining which steps to execute vs. restore) and
+ /// state file naming. For GitHub Actions, this comes from GITHUB_JOB.
+ ///
+ public required string JobId { 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/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs b/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs
index d76fd535c3a..869f1a127f8 100644
--- a/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs
+++ b/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs
@@ -22,4 +22,11 @@ public class PipelineStepFactoryContext
/// Gets the resource that this factory is associated with.
///
public required IResource Resource { get; init; }
+
+ ///
+ /// Gets the pipeline steps that were directly added to the pipeline (not from annotations).
+ /// This allows annotation factories to see the existing steps and create synthetic steps
+ /// that depend on them.
+ ///
+ public IReadOnlyList ExistingSteps { get; init; } = [];
}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs
new file mode 100644
index 00000000000..8c0eccaea05
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.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;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// An annotation that provides a callback to generate workflow files for a pipeline environment.
+///
+///
+/// Pipeline environment resources (e.g., GitHub Actions workflows) annotate themselves with this
+/// to provide the implementation for generating CI/CD workflow files during aspire pipeline init.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineWorkflowGeneratorAnnotation(Func generateAsync) : IResourceAnnotation
+{
+ ///
+ /// Generates the workflow files for the pipeline environment.
+ ///
+ /// The generation context.
+ /// A task representing the async operation.
+ public Task GenerateAsync(PipelineWorkflowGenerationContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ return generateAsync(context);
+ }
+}
+
+///
+/// Context provided to pipeline workflow generators.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class PipelineWorkflowGenerationContext
+{
+ ///
+ /// Gets the pipeline step context for the current execution.
+ ///
+ public required PipelineStepContext StepContext { get; init; }
+
+ ///
+ /// Gets the pipeline environment resource that the workflow is being generated for.
+ ///
+ public required IPipelineEnvironment Environment { get; init; }
+
+ ///
+ /// Gets the pipeline steps registered in the app model.
+ ///
+ public required IReadOnlyList Steps { get; init; }
+
+ ///
+ /// Gets or sets the root directory of the repository. This is used as the base path for
+ /// writing generated workflow files (e.g., .github/workflows/).
+ ///
+ ///
+ ///
+ /// When set by the pipeline infrastructure, this is resolved by detecting the git
+ /// repository root or the location of aspire.config.json.
+ ///
+ ///
+ /// Pipeline environment extensions (e.g., GitHub Actions) may override this value
+ /// during bootstrapping — for example, after initializing a new Git repository.
+ ///
+ ///
+ public string? RepositoryRootDirectory { get; set; }
+
+ ///
+ /// Gets the cancellation token.
+ ///
+ public CancellationToken CancellationToken => StepContext.CancellationToken;
+}
diff --git a/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs b/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs
new file mode 100644
index 00000000000..9963ae3ca12
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs
@@ -0,0 +1,44 @@
+// 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;
+
+///
+/// Defines well-known dependency tags that declare what tooling a pipeline step requires
+/// on the CI machine. Pipeline integrations use these tags to heuristically determine
+/// which setup steps (e.g., "Setup .NET", "Setup Node.js") to emit in the generated workflow.
+///
+///
+/// These tags are orthogonal to , which categorize
+/// what a step does. Dependency tags describe what a step needs to run.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public static class WellKnownDependencyTags
+{
+ ///
+ /// Indicates the step requires the .NET SDK to be installed on the CI machine.
+ ///
+ public const string DotNet = "requires-dotnet";
+
+ ///
+ /// Indicates the step requires Node.js to be installed on the CI machine.
+ ///
+ public const string NodeJs = "requires-nodejs";
+
+ ///
+ /// Indicates the step requires Docker to be available on the CI machine.
+ ///
+ public const string Docker = "requires-docker";
+
+ ///
+ /// Indicates the step requires the Azure CLI to be available on the CI machine.
+ ///
+ public const string AzureCli = "requires-azure-cli";
+
+ ///
+ /// Indicates the step requires the Aspire CLI to be installed on the CI machine.
+ ///
+ public const string AspireCli = "requires-aspire-cli";
+}
diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs
index 855271cefa1..69b5df6ffa5 100644
--- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs
+++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs
@@ -63,4 +63,9 @@ public static class WellKnownPipelineSteps
/// The diagnostic step that dumps dependency graph information for troubleshooting.
///
public const string Diagnostics = "diagnostics";
+
+ ///
+ /// The step that generates CI/CD pipeline workflow files from pipeline environment resources.
+ ///
+ public const string PipelineInit = "pipeline-init";
}
diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml
index 13162c35237..04afc3bc74f 100644
--- a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml
+++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml
@@ -1,10 +1,10 @@
-
+
-
+
@@ -13,9 +13,8 @@
-
+
-
diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml
index d73c31e907d..9efc878b35f 100644
--- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml
+++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml
@@ -1,16 +1,15 @@
-
+
-
+
-
+
-
\ No newline at end of file
diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml
index e978e326b2e..69961fcf30e 100644
--- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml
+++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml
@@ -1,18 +1,17 @@
-
+
-
+
-
+
-
diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml
index 3695a23bf34..7a9ba2a1c33 100644
--- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml
+++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml
@@ -1,17 +1,16 @@
-
+
-
+
-
+
-
diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml
index 8afa43a77c9..2c712572df3 100644
--- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml
+++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml
@@ -1,10 +1,10 @@
-
+
-
+
-
+
diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
index 757ea829731..5b43413b821 100644
--- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
+++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
@@ -183,6 +183,8 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
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/GitHelperTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs
new file mode 100644
index 00000000000..36a9a4bb6e0
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs
@@ -0,0 +1,170 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.Pipelines.GitHubActions.Tests;
+
+public class GitHelperTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly NullLogger _logger = NullLogger.Instance;
+
+ public GitHelperTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-git-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ [Fact]
+ public async Task IsGitRepoAsync_ReturnsFalse_ForNonGitDirectory()
+ {
+ var result = await GitHelper.IsGitRepoAsync(_tempDir, _logger);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task InitAsync_CreatesGitRepo()
+ {
+ var result = await GitHelper.InitAsync(_tempDir, _logger);
+
+ Assert.True(result);
+ Assert.True(Directory.Exists(Path.Combine(_tempDir, ".git")));
+ }
+
+ [Fact]
+ public async Task IsGitRepoAsync_ReturnsTrue_AfterInit()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+
+ var result = await GitHelper.IsGitRepoAsync(_tempDir, _logger);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task GetRepoRootAsync_ReturnsRoot_AfterInit()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+
+ var root = await GitHelper.GetRepoRootAsync(_tempDir, _logger);
+
+ Assert.NotNull(root);
+ // Resolve symlinks for macOS /tmp → /private/tmp
+ Assert.Equal(
+ Path.GetFullPath(_tempDir),
+ Path.GetFullPath(root));
+ }
+
+ [Fact]
+ public async Task GetRepoRootAsync_ReturnsNull_ForNonGitDirectory()
+ {
+ var root = await GitHelper.GetRepoRootAsync(_tempDir, _logger);
+
+ Assert.Null(root);
+ }
+
+ [Fact]
+ public async Task GetRemoteUrlAsync_ReturnsNull_WhenNoRemote()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+
+ var url = await GitHelper.GetRemoteUrlAsync(_tempDir, _logger);
+
+ Assert.Null(url);
+ }
+
+ [Fact]
+ public async Task AddRemoteAsync_AddsRemote()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+
+ var result = await GitHelper.AddRemoteAsync(_tempDir, "https://github.com/test/repo.git", _logger);
+
+ Assert.True(result);
+
+ var url = await GitHelper.GetRemoteUrlAsync(_tempDir, _logger);
+ Assert.Equal("https://github.com/test/repo.git", url);
+ }
+
+ [Fact]
+ public async Task GetCurrentBranchAsync_ReturnsBranchName_AfterCommit()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+
+ // Configure git user for commit
+ await RunGitAsync(_tempDir, "config user.email test@test.com");
+ await RunGitAsync(_tempDir, "config user.name Test");
+
+ // Create a file and commit to establish a branch
+ await File.WriteAllTextAsync(Path.Combine(_tempDir, "README.md"), "test");
+ await GitHelper.AddAllAndCommitAsync(_tempDir, "Initial commit", _logger);
+
+ var branch = await GitHelper.GetCurrentBranchAsync(_tempDir, _logger);
+
+ Assert.NotNull(branch);
+ Assert.NotEmpty(branch);
+ }
+
+ [Fact]
+ public async Task AddAllAndCommitAsync_CommitsFiles()
+ {
+ await GitHelper.InitAsync(_tempDir, _logger);
+ await RunGitAsync(_tempDir, "config user.email test@test.com");
+ await RunGitAsync(_tempDir, "config user.name Test");
+
+ await File.WriteAllTextAsync(Path.Combine(_tempDir, "file.txt"), "hello");
+
+ var result = await GitHelper.AddAllAndCommitAsync(_tempDir, "Test commit", _logger);
+
+ Assert.True(result);
+
+ // Verify commit exists via log
+ var (exitCode, output) = await RunGitWithOutputAsync(_tempDir, "log --oneline");
+ Assert.Equal(0, exitCode);
+ Assert.Contains("Test commit", output);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+
+ private static async Task RunGitAsync(string workingDir, string args)
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo("git", args)
+ {
+ WorkingDirectory = workingDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ using var p = System.Diagnostics.Process.Start(psi)!;
+ await p.WaitForExitAsync();
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunGitWithOutputAsync(string workingDir, string args)
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo("git", args)
+ {
+ WorkingDirectory = workingDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ using var p = System.Diagnostics.Process.Start(psi)!;
+ var output = await p.StandardOutput.ReadToEndAsync();
+ await p.WaitForExitAsync();
+ return (p.ExitCode, output);
+ }
+}
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..9861064074f
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs
@@ -0,0 +1,176 @@
+// 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);
+ }
+
+ [Fact]
+ public void AddStage_CreatesStageWithCorrectName()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+
+ var stage = workflow.AddStage("build-stage");
+
+ Assert.Equal("build-stage", stage.Name);
+ Assert.Same(workflow, stage.Workflow);
+ Assert.Single(workflow.Stages);
+ }
+
+ [Fact]
+ public void AddStage_DuplicateName_Throws()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.AddStage("build-stage");
+
+ Assert.Throws(() => workflow.AddStage("build-stage"));
+ }
+
+ [Fact]
+ public void Stage_AddJob_CreatesJobOnWorkflow()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var stage = workflow.AddStage("build-stage");
+
+ var job = stage.AddJob("build");
+
+ Assert.Equal("build", job.Id);
+ Assert.Single(stage.Jobs);
+ Assert.Single(workflow.Jobs); // Job is also registered on the workflow
+ Assert.Same(job, workflow.Jobs[0]);
+ }
+
+ [Fact]
+ public void Stage_AddJob_MultipleStagesWithJobs()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildStage = workflow.AddStage("build-stage");
+ var deployStage = workflow.AddStage("deploy-stage");
+
+ var buildJob = buildStage.AddJob("build");
+ var deployJob = deployStage.AddJob("deploy");
+
+ Assert.Single(buildStage.Jobs);
+ Assert.Single(deployStage.Jobs);
+ Assert.Equal(2, workflow.Jobs.Count);
+ Assert.Same(buildJob, buildStage.Jobs[0]);
+ Assert.Same(deployJob, deployStage.Jobs[0]);
+ }
+
+ [Fact]
+ public void JobResource_ExtendsResource()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job = workflow.AddJob("build");
+
+ Assert.IsAssignableFrom(job);
+ }
+
+ [Fact]
+ public void StageResource_ExtendsResource()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var stage = workflow.AddStage("build-stage");
+
+ Assert.IsAssignableFrom(stage);
+ }
+}
diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs
new file mode 100644
index 00000000000..539691e9056
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs
@@ -0,0 +1,24 @@
+// 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.Tests;
+
+public class GitIgnoreTemplateTests
+{
+ [Fact]
+ public void Content_IsNotEmpty()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(GitIgnoreTemplate.Content));
+ }
+
+ [Theory]
+ [InlineData("bin/")]
+ [InlineData("obj/")]
+ [InlineData(".vs/")]
+ [InlineData("*.user")]
+ [InlineData("artifacts/")]
+ public void Content_ContainsExpectedPatterns(string pattern)
+ {
+ Assert.Contains(pattern, GitIgnoreTemplate.Content);
+ }
+}
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..b55525d71b6
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs
@@ -0,0 +1,531 @@
+// 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_UnscheduledStepsPulledToFirstJob()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+
+ var step1 = CreateStep("step1"); // No scheduledBy — pulled to first available job
+ var step2 = CreateStep("step2"); // No scheduledBy — pulled to first available job
+ var step3 = CreateStep("step3", scheduledBy: buildJob);
+
+ var result = SchedulingResolver.Resolve([step1, step2, step3], workflow);
+
+ // Orphan unscheduled steps (no consumer) go to the first available job
+ Assert.Same(buildJob, result.StepToJob["step1"]);
+ Assert.Same(buildJob, result.StepToJob["step2"]);
+ Assert.Same(buildJob, result.StepToJob["step3"]);
+ }
+
+ [Fact]
+ public void Resolve_MixedScheduledAndUnscheduled_PullsToConsumer()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var publishJob = workflow.AddJob("publish");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build"); // No scheduledBy → pulled to first consumer (publish)
+ var publishStep = CreateStep("publish", publishJob, "build");
+ var deployStep = CreateStep("deploy", deployJob, "publish");
+
+ var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow);
+
+ // build is pulled into publishJob because publish is the first thing that needs it
+ Assert.Same(publishJob, result.StepToJob["build"]);
+ Assert.Same(publishJob, result.StepToJob["publish"]);
+ Assert.Same(deployJob, result.StepToJob["deploy"]);
+
+ // No cross-job dependency for build→publish (same job)
+ // deploy depends on publish job
+ Assert.Contains("publish", result.JobDependencies["deploy"]);
+ Assert.DoesNotContain("default", result.JobDependencies.Keys);
+ }
+
+ [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);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByWorkflow_AutoCreatesDefaultJob()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+
+ var step = CreateStep("build-step", scheduledBy: workflow);
+
+ var result = SchedulingResolver.Resolve([step], workflow);
+
+ // Should auto-create a default stage + default job
+ Assert.Equal("default", result.DefaultJob!.Id);
+ Assert.Same(result.DefaultJob!, result.StepToJob["build-step"]);
+ Assert.Single(workflow.Stages);
+ Assert.Equal("default", workflow.Stages[0].Name);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByStage_AutoCreatesDefaultJobOnStage()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildStage = workflow.AddStage("build");
+
+ var step = CreateStep("build-step", scheduledBy: buildStage);
+
+ var result = SchedulingResolver.Resolve([step], workflow);
+
+ // Should auto-create a default job within the build stage (named "build-default")
+ var autoJob = result.StepToJob["build-step"];
+ Assert.Equal("build-default", autoJob.Id);
+ Assert.Single(buildStage.Jobs);
+ Assert.Same(autoJob, buildStage.Jobs[0]);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByStageAndJob_MixedTargets()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildStage = workflow.AddStage("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-step", scheduledBy: buildStage);
+ var deployStep = CreateStep("deploy-step", deployJob, "build-step");
+
+ var result = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+
+ // build-step should be on the stage's auto-created default job
+ Assert.Equal("build-default", result.StepToJob["build-step"].Id);
+ Assert.Same(deployJob, result.StepToJob["deploy-step"]);
+
+ // deploy job should depend on build-default job
+ Assert.Contains("build-default", result.JobDependencies["deploy"]);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByWorkflow_WithExplicitJobs_StillAutoCreates()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var explicitJob = workflow.AddJob("publish");
+
+ // Schedule one step to workflow (auto-default), one to explicit job
+ var step1 = CreateStep("setup", scheduledBy: workflow);
+ var step2 = CreateStep("publish", explicitJob, "setup");
+
+ var result = SchedulingResolver.Resolve([step1, step2], workflow);
+
+ Assert.Same(result.DefaultJob!, result.StepToJob["setup"]);
+ Assert.Same(explicitJob, result.StepToJob["publish"]);
+ Assert.NotSame(explicitJob, result.DefaultJob!);
+ Assert.Contains(result.DefaultJob!.Id, result.JobDependencies["publish"]);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByStageFromDifferentWorkflow_Throws()
+ {
+ var workflow1 = new GitHubActionsWorkflowResource("deploy");
+ var workflow2 = new GitHubActionsWorkflowResource("other");
+ var stage = workflow2.AddStage("build");
+
+ var step = CreateStep("step1", scheduledBy: stage);
+
+ var ex = Assert.Throws(
+ () => SchedulingResolver.Resolve([step], workflow1));
+
+ Assert.Contains("different workflow", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_ScheduledByDifferentWorkflow_Throws()
+ {
+ var workflow1 = new GitHubActionsWorkflowResource("deploy");
+ var workflow2 = new GitHubActionsWorkflowResource("other");
+
+ var step = CreateStep("step1", scheduledBy: workflow2);
+
+ var ex = Assert.Throws(
+ () => SchedulingResolver.Resolve([step], workflow1));
+
+ Assert.Contains("workflow", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_UnscheduledChain_PulledToFirstExplicitConsumer()
+ {
+ // A → B → C, where C is explicitly scheduled. A and B should be pulled into C's job.
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var publishJob = workflow.AddJob("publish");
+
+ var stepA = CreateStep("A");
+ var stepB = CreateStep("B", null, "A");
+ var stepC = CreateStep("C", publishJob, "B");
+
+ var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow);
+
+ Assert.Same(publishJob, result.StepToJob["A"]);
+ Assert.Same(publishJob, result.StepToJob["B"]);
+ Assert.Same(publishJob, result.StepToJob["C"]);
+ Assert.Empty(result.JobDependencies["publish"]);
+ }
+
+ [Fact]
+ public void Resolve_ExplicitStages_NoDefaultStageCreated()
+ {
+ // Two stages: publish and deploy. Unscheduled "build" depends on nothing,
+ // but publish depends on build → build is pulled into publish stage.
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var publishStage = workflow.AddStage("publish");
+ var deployStage = workflow.AddStage("deploy");
+
+ var buildStep = CreateStep("build");
+ var publishStep = CreateStep("publish", publishStage, "build");
+ var deployStep = CreateStep("deploy", deployStage, "publish");
+
+ var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow);
+
+ // build is pulled into publish stage's default job
+ Assert.Equal("publish-default", result.StepToJob["build"].Id);
+ Assert.Equal("publish-default", result.StepToJob["publish"].Id);
+ Assert.Equal("deploy-default", result.StepToJob["deploy"].Id);
+
+ // No default stage should have been created
+ Assert.DoesNotContain(workflow.Stages, s => s.Name == "default");
+
+ // deploy-default job should depend on publish-default job
+ Assert.True(result.JobDependencies.TryGetValue("deploy-default", out var deployDeps), "deploy-default should have job dependencies");
+ Assert.Contains("publish-default", deployDeps);
+ }
+
+ [Fact]
+ public void Resolve_FanOut_UnscheduledPulledToFirstConsumer()
+ {
+ // A is unscheduled, both B (job1) and C (job2) depend on A.
+ // A is pulled to the first consumer found (job1).
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var job1 = workflow.AddJob("job1");
+ var job2 = workflow.AddJob("job2");
+
+ var stepA = CreateStep("A");
+ var stepB = CreateStep("B", job1, "A");
+ var stepC = CreateStep("C", job2, "A");
+
+ var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow);
+
+ // A pulled to the first consumer's job (job1)
+ Assert.Same(job1, result.StepToJob["A"]);
+ }
+
+ [Fact]
+ public void Resolve_RequiredByWithTwoStages_NoCycleAfterNormalization()
+ {
+ // Simulates the docker-compose scenario:
+ // publish-env RequiredBy "publish" (scheduled to stage1)
+ // prepare-env DependsOn "publish", DependsOn "build"
+ // docker-compose-up-env DependsOn "prepare-env", RequiredBy "deploy" (scheduled to stage2)
+ //
+ // Before normalization, the RequiredBy edges are invisible to the scheduler,
+ // causing incorrect job assignments. After normalization, the scheduler should
+ // correctly see that "publish" depends on "publish-env" and "deploy" depends
+ // on "docker-compose-up-env".
+ var workflow = new GitHubActionsWorkflowResource("ci");
+ var stage1 = workflow.AddStage("build");
+ var stage2 = workflow.AddStage("deploy");
+
+ var publishEnv = CreateStepWithRequiredBy("publish-env", "publish");
+ var publish = CreateStep("publish", stage1);
+ var build = CreateStep("build", stage1);
+ var prepareEnv = CreateStep("prepare-env", scheduledBy: null, dependsOn: ["publish", "build"]);
+ var dockerComposeUp = CreateStepWithRequiredBy("docker-compose-up-env", "deploy");
+ dockerComposeUp.DependsOnSteps.Add("prepare-env");
+ var deploy = CreateStep("deploy", stage2);
+
+ var steps = new List { publishEnv, publish, build, prepareEnv, dockerComposeUp, deploy };
+
+ // Normalize RequiredBy→DependsOn (same as GitHubActionsWorkflowExtensions does)
+ var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal);
+ foreach (var step in steps)
+ {
+ foreach (var requiredByStepName in step.RequiredBySteps)
+ {
+ if (stepsByName.TryGetValue(requiredByStepName, out var requiredByStep) &&
+ !requiredByStep.DependsOnSteps.Contains(step.Name))
+ {
+ requiredByStep.DependsOnSteps.Add(step.Name);
+ }
+ }
+ }
+
+ // After normalization: publish depends on publish-env, deploy depends on docker-compose-up-env
+ Assert.Contains("publish-env", publish.DependsOnSteps);
+ Assert.Contains("docker-compose-up-env", deploy.DependsOnSteps);
+
+ // Should not throw SchedulingValidationException (no cycle)
+ var result = SchedulingResolver.Resolve(steps, workflow);
+
+ // publish and build are in stage1 (build-default job)
+ Assert.Equal("build-default", result.StepToJob["publish"].Id);
+ Assert.Equal("build-default", result.StepToJob["build"].Id);
+ Assert.Equal("build-default", result.StepToJob["publish-env"].Id);
+
+ // deploy is in stage2 (deploy-default job)
+ Assert.Equal("deploy-default", result.StepToJob["deploy"].Id);
+
+ // deploy-default should depend on build-default (not the other way around)
+ Assert.True(result.JobDependencies.TryGetValue("deploy-default", out var deployDeps));
+ Assert.Contains("build-default", deployDeps);
+ }
+
+ // 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
+ };
+ }
+
+ private static PipelineStep CreateStepWithRequiredBy(string name, string requiredBy, IPipelineStepTarget? scheduledBy = null)
+ {
+ var step = new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = scheduledBy
+ };
+ step.RequiredBy(requiredBy);
+ return step;
+ }
+}
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..00e98d2ab43
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt
@@ -0,0 +1,52 @@
+name: deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-deploy-default-stage-default-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-default
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
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..ece7f0623ea
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt
@@ -0,0 +1,53 @@
+name: build-windows
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-build-windows-default-stage-build-win-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-build-win
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
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..e19776bc504
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt
@@ -0,0 +1,166 @@
+name: ci-cd
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-ci-cd-default-stage-build-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-build
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
+
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-do-state-build
+ path: aspire-state-staging
+ - name: Restore pipeline state
+ run: |
+ if [ -d "aspire-state-staging/deployments" ]; then
+ mkdir -p $HOME/.aspire/deployments
+ cp -r aspire-state-staging/deployments/. $HOME/.aspire/deployments/
+ fi
+ if [ -d "aspire-state-staging/output" ]; then
+ mkdir -p aspire-output
+ cp -r aspire-state-staging/output/. aspire-output/
+ fi
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-ci-cd-default-stage-test-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-test
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
+
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-do-state-build
+ path: aspire-state-staging
+ - name: Download state from test
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-do-state-test
+ path: aspire-state-staging
+ - name: Restore pipeline state
+ run: |
+ if [ -d "aspire-state-staging/deployments" ]; then
+ mkdir -p $HOME/.aspire/deployments
+ cp -r aspire-state-staging/deployments/. $HOME/.aspire/deployments/
+ fi
+ if [ -d "aspire-state-staging/output" ]; then
+ mkdir -p aspire-output
+ cp -r aspire-state-staging/output/. aspire-output/
+ fi
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-ci-cd-default-stage-deploy-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-deploy
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
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..f85bb4f3738
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt
@@ -0,0 +1,107 @@
+name: deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-deploy-default-stage-build-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-build
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
+
+ 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: |
+ curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+ - name: Download state from build
+ uses: actions/download-artifact@v4
+ with:
+ name: aspire-do-state-build
+ path: aspire-state-staging
+ - name: Restore pipeline state
+ run: |
+ if [ -d "aspire-state-staging/deployments" ]; then
+ mkdir -p $HOME/.aspire/deployments
+ cp -r aspire-state-staging/deployments/. $HOME/.aspire/deployments/
+ fi
+ if [ -d "aspire-state-staging/output" ]; then
+ mkdir -p aspire-output
+ cp -r aspire-state-staging/output/. aspire-output/
+ fi
+ - name: Run pipeline steps
+ env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ run: aspire do gha-deploy-default-stage-deploy-job --output-path aspire-output
+ - name: Stage pipeline state
+ run: |
+ mkdir -p aspire-state-staging
+ if [ -d "$HOME/.aspire/deployments" ]; then
+ mkdir -p aspire-state-staging/deployments
+ cp -r $HOME/.aspire/deployments/. aspire-state-staging/deployments/
+ fi
+ if [ -d "aspire-output" ]; then
+ mkdir -p aspire-state-staging/output
+ cp -r aspire-output/. aspire-state-staging/output/
+ fi
+ echo "Staged files:" && find aspire-state-staging -type f 2>/dev/null || echo "No files staged"
+ - name: Upload state
+ uses: actions/upload-artifact@v4
+ with:
+ name: aspire-do-state-deploy
+ path: aspire-state-staging
+ if-no-files-found: ignore
+ include-hidden-files: true
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..7c83c7a1af5
--- /dev/null
+++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs
@@ -0,0 +1,485 @@
+// 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 is not null && s.Run.Contains("aspire do gha-deploy-default-stage-default-job"));
+ }
+
+ [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", yamlString);
+ Assert.DoesNotContain("--job", 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, string[]? tags = null)
+ {
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ ScheduledBy = scheduledBy,
+ Tags = tags is not null ? [.. tags] : []
+ };
+ }
+
+ // Heuristic step emission tests
+
+ [Fact]
+ public void Generate_StepWithDotNetTag_EmitsSetupDotNet()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.Contains(job.Steps, s => s.Name == "Setup .NET" && s.Uses == "actions/setup-dotnet@v4");
+ }
+
+ [Fact]
+ public void Generate_StepWithNodeJsTag_EmitsSetupNode()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-ts", tags: [WellKnownDependencyTags.NodeJs]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.Contains(job.Steps, s => s.Name == "Setup Node.js" && s.Uses == "actions/setup-node@v4");
+ }
+
+ [Fact]
+ public void Generate_StepWithAzureCliTag_EmitsAzureLogin()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("provision-infra", tags: [WellKnownDependencyTags.AzureCli]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.Contains(job.Steps, s => s.Name == "Azure login" && s.Uses == "azure/login@v2");
+ }
+
+ [Fact]
+ public void Generate_StepWithoutNodeJsTag_DoesNotEmitSetupNode()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.DoesNotContain(job.Steps, s => s.Name == "Setup Node.js");
+ }
+
+ [Fact]
+ public void Generate_StepWithoutAzureCliTag_DoesNotEmitAzureLogin()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.DoesNotContain(job.Steps, s => s.Name == "Azure login");
+ }
+
+ [Fact]
+ public void Generate_MultipleTagsOnStep_EmitsAllSetupSteps()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("deploy-all", tags: [WellKnownDependencyTags.DotNet, WellKnownDependencyTags.NodeJs, WellKnownDependencyTags.AzureCli]);
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ Assert.Contains(job.Steps, s => s.Name == "Setup .NET");
+ Assert.Contains(job.Steps, s => s.Name == "Setup Node.js");
+ Assert.Contains(job.Steps, s => s.Name == "Azure login");
+ Assert.Contains(job.Steps, s => s.Name == "Install Aspire CLI");
+ }
+
+ [Fact]
+ public void Generate_TagsAcrossJobs_IndependentSetupSteps()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var buildJob = workflow.AddJob("build");
+ var deployJob = workflow.AddJob("deploy");
+
+ var buildStep = CreateStep("build-app", buildJob, tags: [WellKnownDependencyTags.DotNet, WellKnownDependencyTags.Docker]);
+ var deployStep = new PipelineStep
+ {
+ Name = "deploy-app",
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = ["build-app"],
+ ScheduledBy = deployJob,
+ Tags = [WellKnownDependencyTags.AzureCli]
+ };
+
+ var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ // Build job: has .NET setup but NOT Azure login
+ var buildJobYaml = yaml.Jobs["build"];
+ Assert.Contains(buildJobYaml.Steps, s => s.Name == "Setup .NET");
+ Assert.DoesNotContain(buildJobYaml.Steps, s => s.Name == "Azure login");
+
+ // Deploy job: has Azure login but does NOT need .NET for its own steps
+ // (it still gets .NET because aspire do needs it)
+ var deployJobYaml = yaml.Jobs["deploy"];
+ Assert.Contains(deployJobYaml.Steps, s => s.Name == "Azure login");
+ }
+
+ // Build channel detection tests (version-based)
+
+ [Theory]
+ [InlineData(null, null, false)]
+ [InlineData("", null, false)]
+ [InlineData("13.3.0", null, false)]
+ [InlineData("13.3.0+abc123", null, false)]
+ [InlineData("13.3.0-preview.1.25608.1", null, true)]
+ [InlineData("13.3.0-preview.1.25608.1+abc123", null, true)]
+ [InlineData("13.3.0-dev.25180.1", null, true)]
+ [InlineData("13.3.0-pr.15643.g8a1b2c3d", 15643, true)]
+ [InlineData("13.3.0-pr.15643.g8a1b2c3d+abc123def456", 15643, true)]
+ [InlineData("13.3.0-pr.999", 999, true)]
+ public void ParseBuildChannel_DetectsCorrectly(string? version, int? expectedPr, bool expectedPrerelease)
+ {
+ var channel = WorkflowYamlGenerator.ParseBuildChannel(version);
+
+ Assert.Equal(expectedPr, channel.PrNumber);
+ Assert.Equal(expectedPrerelease, channel.IsPrerelease);
+ }
+
+ [Fact]
+ public void GenerateInstallStep_StableBuild_UsesDefaultInstallScript()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ var step = CreateStep("build-app");
+
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ var job = yaml.Jobs["default"];
+ var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI");
+ // Running in tests → local dev build → stable install (no -q flag)
+ Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run);
+ Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run);
+ }
+
+ // ConfigureWorkflow callback tests
+
+ [Fact]
+ public void ConfigureWorkflow_CallbackModifiesYaml()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml =>
+ {
+ foreach (var job in yaml.Jobs.Values)
+ {
+ job.Steps.Insert(0, new StepYaml
+ {
+ Name = "Custom step",
+ Run = "echo 'hello'"
+ });
+ }
+ }));
+
+ var step = CreateStep("build-app");
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ // Apply customization (simulating what the extension does)
+ foreach (var customization in workflow.Annotations.OfType())
+ {
+ customization.Callback(yamlModel);
+ }
+
+ var job = yamlModel.Jobs["default"];
+ Assert.Equal("Custom step", job.Steps[0].Name);
+ }
+
+ [Fact]
+ public void ConfigureWorkflow_MultipleCallbacks_AppliedInOrder()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml =>
+ {
+ foreach (var job in yaml.Jobs.Values)
+ {
+ job.Steps.Add(new StepYaml { Name = "First callback" });
+ }
+ }));
+ workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml =>
+ {
+ foreach (var job in yaml.Jobs.Values)
+ {
+ job.Steps.Add(new StepYaml { Name = "Second callback" });
+ }
+ }));
+
+ var step = CreateStep("build-app");
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ foreach (var customization in workflow.Annotations.OfType())
+ {
+ customization.Callback(yamlModel);
+ }
+
+ var job = yamlModel.Jobs["default"];
+ var lastTwo = job.Steps.TakeLast(2).ToArray();
+ Assert.Equal("First callback", lastTwo[0].Name);
+ Assert.Equal("Second callback", lastTwo[1].Name);
+ }
+
+ [Fact]
+ public void ConfigureWorkflow_CanAddEnvVarsToAllJobs()
+ {
+ var workflow = new GitHubActionsWorkflowResource("deploy");
+ workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml =>
+ {
+ foreach (var job in yaml.Jobs.Values)
+ {
+ job.Steps.Add(new StepYaml
+ {
+ Name = "Secret step",
+ Env = new Dictionary
+ {
+ ["MY_SECRET"] = "${{ secrets.MY_SECRET }}"
+ },
+ Run = "echo $MY_SECRET"
+ });
+ }
+ }));
+
+ var step = CreateStep("build-app");
+ var scheduling = SchedulingResolver.Resolve([step], workflow);
+ var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow);
+
+ foreach (var customization in workflow.Annotations.OfType())
+ {
+ customization.Callback(yamlModel);
+ }
+
+ var job = yamlModel.Jobs["default"];
+ var secretStep = Assert.Single(job.Steps, s => s.Name == "Secret step");
+ Assert.Contains("MY_SECRET", secretStep.Env!.Keys);
+ }
+
+ // Helper for creating steps with tags and dependsOn
+ private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn, params string[] moreDependsOn)
+ {
+ var deps = new List { dependsOn };
+ deps.AddRange(moreDependsOn);
+ return new PipelineStep
+ {
+ Name = name,
+ Action = _ => Task.CompletedTask,
+ DependsOnSteps = deps,
+ ScheduledBy = scheduledBy
+ };
+ }
+
+ private sealed class TempDirectory : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName());
+
+ public TempDirectory()
+ {
+ Directory.CreateDirectory(Path);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+ }
+}
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/ContinuationStateManagerTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs
new file mode 100644
index 00000000000..511f42b1869
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs
@@ -0,0 +1,172 @@
+// 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
+#pragma warning disable ASPIREPIPELINES002
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Aspire.Hosting.Pipelines;
+using Aspire.Hosting.Pipelines.Internal;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.Tests.Pipelines;
+
+[Trait("Partition", "4")]
+public class ContinuationStateManagerTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public ContinuationStateManagerTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"aspire-state-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task AcquireSection_EmptyDirectory_ReturnsEmptySection()
+ {
+ var manager = CreateManager("run-1", "build");
+
+ var section = await manager.AcquireSectionAsync("MySection");
+
+ Assert.NotNull(section);
+ Assert.Equal("MySection", section.SectionName);
+ Assert.Empty(section.Data);
+ }
+
+ [Fact]
+ public async Task SaveAndAcquire_RoundTrips()
+ {
+ var manager = CreateManager("run-1", "build");
+
+ var section = await manager.AcquireSectionAsync("Config");
+ section.Data["key1"] = JsonValue.Create("value1");
+ await manager.SaveSectionAsync(section);
+
+ var restored = await manager.AcquireSectionAsync("Config");
+ Assert.Equal("value1", restored.Data["key1"]?.GetValue());
+ }
+
+ [Fact]
+ public async Task WritesOnlyToScopeFile()
+ {
+ var manager = CreateManager("run-1", "build");
+
+ var section = await manager.AcquireSectionAsync("Data");
+ section.Data["x"] = JsonValue.Create("y");
+ await manager.SaveSectionAsync(section);
+
+ var expectedFile = Path.Combine(_tempDir, "run-1", "build.json");
+ Assert.True(File.Exists(expectedFile));
+
+ // Only one file should exist in the run directory
+ var files = Directory.GetFiles(Path.Combine(_tempDir, "run-1"), "*.json");
+ Assert.Single(files);
+ }
+
+ [Fact]
+ public async Task MergesMultipleScopeFiles()
+ {
+ var runDir = Path.Combine(_tempDir, "run-1");
+ Directory.CreateDirectory(runDir);
+
+ // Write state from "build" scope
+ var buildState = new JsonObject { ["BuildOutput"] = new JsonObject { ["artifact"] = JsonValue.Create("app.zip") } };
+ await File.WriteAllTextAsync(
+ Path.Combine(runDir, "build.json"),
+ JsonFlattener.FlattenJsonObject(buildState).ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+
+ // Write state from "test" scope
+ var testState = new JsonObject { ["TestResults"] = new JsonObject { ["passed"] = JsonValue.Create("42") } };
+ await File.WriteAllTextAsync(
+ Path.Combine(runDir, "test.json"),
+ JsonFlattener.FlattenJsonObject(testState).ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+
+ // Now read as "deploy" scope — should see merged state
+ var deployManager = CreateManager("run-1", "deploy");
+
+ var buildSection = await deployManager.AcquireSectionAsync("BuildOutput");
+ Assert.Equal("app.zip", buildSection.Data["artifact"]?.GetValue());
+
+ var testSection = await deployManager.AcquireSectionAsync("TestResults");
+ Assert.Equal("42", testSection.Data["passed"]?.GetValue());
+ }
+
+ [Fact]
+ public async Task ConcurrentRunsDontInterfere()
+ {
+ // Run 1 writes "build" scope
+ var run1Manager = CreateManager("run-1", "build");
+ var section1 = await run1Manager.AcquireSectionAsync("Data");
+ section1.Data["from"] = JsonValue.Create("run1");
+ await run1Manager.SaveSectionAsync(section1);
+
+ // Run 2 writes "build" scope (same job name, different run)
+ var run2Manager = CreateManager("run-2", "build");
+ var section2 = await run2Manager.AcquireSectionAsync("Data");
+ section2.Data["from"] = JsonValue.Create("run2");
+ await run2Manager.SaveSectionAsync(section2);
+
+ // Verify isolation: each run's state is in its own directory
+ var run1File = Path.Combine(_tempDir, "run-1", "build.json");
+ var run2File = Path.Combine(_tempDir, "run-2", "build.json");
+ Assert.True(File.Exists(run1File));
+ Assert.True(File.Exists(run2File));
+
+ // Read back run 1 — should see "run1"
+ var run1ReadManager = CreateManager("run-1", "deploy");
+ var run1Section = await run1ReadManager.AcquireSectionAsync("Data");
+ Assert.Equal("run1", run1Section.Data["from"]?.GetValue());
+
+ // Read back run 2 — should see "run2"
+ var run2ReadManager = CreateManager("run-2", "deploy");
+ var run2Section = await run2ReadManager.AcquireSectionAsync("Data");
+ Assert.Equal("run2", run2Section.Data["from"]?.GetValue());
+ }
+
+ [Fact]
+ public async Task DeleteSection_RemovesSectionFromState()
+ {
+ var manager = CreateManager("run-1", "build");
+
+ var section = await manager.AcquireSectionAsync("ToDelete");
+ section.Data["key"] = JsonValue.Create("val");
+ await manager.SaveSectionAsync(section);
+
+ // Verify it's there
+ var check = await manager.AcquireSectionAsync("ToDelete");
+ Assert.NotEmpty(check.Data);
+
+ // Delete it
+ await manager.DeleteSectionAsync(check);
+
+ // Re-read — should be empty
+ var afterDelete = await manager.AcquireSectionAsync("ToDelete");
+ Assert.Empty(afterDelete.Data);
+ }
+
+ [Fact]
+ public void StateFilePath_ReturnsWritePath()
+ {
+ var manager = CreateManager("run-42", "build");
+
+ Assert.Equal(Path.Combine(_tempDir, "run-42", "build.json"), manager.StateFilePath);
+ }
+
+ private ContinuationStateManager CreateManager(string runId, string jobId)
+ {
+ return new ContinuationStateManager(
+ NullLogger.Instance,
+ new PipelineScopeResult { RunId = runId, JobId = jobId },
+ _tempDir);
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
index 8bd14023074..164b201edb3 100644
--- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
+++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
@@ -1425,7 +1425,7 @@ public async Task ExecuteAsync_WithConfigurationCallback_ExecutesCallback()
await pipeline.ExecuteAsync(context).DefaultTimeout();
Assert.True(callbackExecuted);
- Assert.Equal(12, capturedSteps.Count); // Updated to account for all default steps including process-parameters, push, push-prereq
+ Assert.Equal(13, capturedSteps.Count); // Updated to account for all default steps including process-parameters, push, push-prereq, pipeline-init
Assert.Contains(capturedSteps, s => s.Name == "deploy");
Assert.Contains(capturedSteps, s => s.Name == "process-parameters");
Assert.Contains(capturedSteps, s => s.Name == "deploy-prereq");
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/PipelineScopeAnnotationTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.cs
new file mode 100644
index 00000000000..1a2fcb64ac9
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.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
+#pragma warning disable ASPIREPIPELINES002
+
+using Aspire.Hosting.Pipelines;
+
+namespace Aspire.Hosting.Tests.Pipelines;
+
+[Trait("Partition", "4")]
+public class PipelineScopeAnnotationTests
+{
+ [Fact]
+ public async Task ResolveAsync_ReturnsNull_WhenNotInCI()
+ {
+ var annotation = new PipelineScopeAnnotation(_ =>
+ {
+ // Simulate not being in a CI environment
+ return Task.FromResult(null);
+ });
+
+ var context = new PipelineScopeContext { CancellationToken = CancellationToken.None };
+ var result = await annotation.ResolveAsync(context);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_ReturnsScope_WhenInCI()
+ {
+ var annotation = new PipelineScopeAnnotation(_ =>
+ {
+ return Task.FromResult(new PipelineScopeResult
+ {
+ RunId = "12345-1",
+ JobId = "build"
+ });
+ });
+
+ var context = new PipelineScopeContext { CancellationToken = CancellationToken.None };
+ var result = await annotation.ResolveAsync(context);
+
+ Assert.NotNull(result);
+ Assert.Equal("12345-1", result.RunId);
+ Assert.Equal("build", result.JobId);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_GitHubActionsPattern_ComputesPredictableScope()
+ {
+ // Simulate the GitHub Actions pattern: GITHUB_RUN_ID + GITHUB_RUN_ATTEMPT + GITHUB_JOB
+ var annotation = CreateGitHubActionsScopeAnnotation(
+ runId: "98765",
+ runAttempt: "2",
+ jobId: "deploy");
+
+ var context = new PipelineScopeContext { CancellationToken = CancellationToken.None };
+ var result = await annotation.ResolveAsync(context);
+
+ Assert.NotNull(result);
+ Assert.Equal("98765-2", result.RunId);
+ Assert.Equal("deploy", result.JobId);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_GitHubActionsPattern_DefaultsRunAttemptTo1()
+ {
+ var annotation = CreateGitHubActionsScopeAnnotation(
+ runId: "12345",
+ runAttempt: null,
+ jobId: "build");
+
+ var context = new PipelineScopeContext { CancellationToken = CancellationToken.None };
+ var result = await annotation.ResolveAsync(context);
+
+ Assert.NotNull(result);
+ Assert.Equal("12345-1", result.RunId);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_GitHubActionsPattern_ReturnsNull_WhenRunIdMissing()
+ {
+ var annotation = CreateGitHubActionsScopeAnnotation(
+ runId: null,
+ runAttempt: "1",
+ jobId: "build");
+
+ var context = new PipelineScopeContext { CancellationToken = CancellationToken.None };
+ var result = await annotation.ResolveAsync(context);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void ScopeMapAnnotation_MapsJobIdsToSteps()
+ {
+ var map = new PipelineScopeMapAnnotation(new Dictionary>
+ {
+ ["build"] = ["compile", "unit-test"],
+ ["deploy"] = ["push-image", "deploy-app"]
+ });
+
+ Assert.Equal(2, map.ScopeToSteps.Count);
+ Assert.Equal(["compile", "unit-test"], map.ScopeToSteps["build"]);
+ Assert.Equal(["push-image", "deploy-app"], map.ScopeToSteps["deploy"]);
+ }
+
+ [Fact]
+ public void ScopeMapAnnotation_EmptyMap_IsValid()
+ {
+ var map = new PipelineScopeMapAnnotation(new Dictionary>());
+
+ Assert.Empty(map.ScopeToSteps);
+ }
+
+ ///
+ /// Simulates the GitHub Actions scope annotation pattern with injectable env var values.
+ ///
+ private static PipelineScopeAnnotation CreateGitHubActionsScopeAnnotation(
+ string? runId, string? runAttempt, string? jobId)
+ {
+ return new PipelineScopeAnnotation(_ =>
+ {
+ if (string.IsNullOrEmpty(runId) || string.IsNullOrEmpty(jobId))
+ {
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(new PipelineScopeResult
+ {
+ RunId = $"{runId}-{runAttempt ?? "1"}",
+ JobId = jobId
+ });
+ });
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs
new file mode 100644
index 00000000000..cba3ca8f6db
--- /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, dependsOn: null, requiredBy: null, 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/ScopeFilteringTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs
new file mode 100644
index 00000000000..87d1b98cdf4
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs
@@ -0,0 +1,182 @@
+// 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 ScopeFilteringTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ public async Task ContinuationMode_InScopeStepsExecute_OutOfScopeSkipped()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(
+ DistributedApplicationOperation.Publish, step: null)
+ .WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+
+ // Add a fake pipeline environment with scope annotation
+ var envResource = new FakePipelineEnvironment("test-env");
+ envResource.Annotations.Add(new PipelineEnvironmentCheckAnnotation(
+ _ => Task.FromResult(true)));
+ envResource.Annotations.Add(new PipelineScopeAnnotation(
+ _ => Task.FromResult(new PipelineScopeResult
+ {
+ RunId = "run-1",
+ JobId = "deploy"
+ })));
+ envResource.Annotations.Add(new PipelineScopeMapAnnotation(
+ new Dictionary>
+ {
+ ["build"] = ["step-build"],
+ ["deploy"] = ["step-deploy"]
+ }));
+ builder.AddResource(envResource);
+
+ var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var pipeline = new DistributedApplicationPipeline(model);
+
+ var executedSteps = new List();
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step-build",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-build"); } await Task.CompletedTask; }
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step-deploy",
+ Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-deploy"); } await Task.CompletedTask; },
+ DependsOnSteps = ["step-build"]
+ });
+
+ var context = CreateContext(app);
+ await pipeline.ExecuteAsync(context).DefaultTimeout();
+
+ // step-build is out-of-scope (belongs to "build" job), should be skipped
+ Assert.DoesNotContain("step-build", executedSteps);
+ // step-deploy is in-scope, should execute
+ Assert.Contains("step-deploy", executedSteps);
+ }
+
+ [Fact]
+ public async Task ContinuationMode_OutOfScopeWithRestore_RestoresNotExecutes()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(
+ DistributedApplicationOperation.Publish, step: null)
+ .WithTestAndResourceLogging(testOutputHelper);
+ builder.Services.AddSingleton(testOutputHelper);
+ builder.Services.AddSingleton();
+
+ var envResource = new FakePipelineEnvironment("test-env");
+ envResource.Annotations.Add(new PipelineEnvironmentCheckAnnotation(
+ _ => Task.FromResult(true)));
+ envResource.Annotations.Add(new PipelineScopeAnnotation(
+ _ => Task.FromResult