From a112a419e1849bacdb8c5f568e0b016014fe14c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 16:12:05 +1100 Subject: [PATCH 01/15] spike: Pipeline generation core abstractions and GitHub Actions scheduling Add core pipeline environment abstractions (IPipelineEnvironment, IPipelineStepTarget, PipelineEnvironmentCheckAnnotation) to Aspire.Hosting, along with a new Aspire.Hosting.Pipelines.GitHubActions package implementing workflow resource model and scheduling resolver. Key changes: - IPipelineEnvironment marker interface for CI/CD environments - IPipelineStepTarget for scheduling steps onto workflow jobs - PipelineStep.ScheduledBy property for step-to-job assignment - GetEnvironmentAsync() with annotation-based environment resolution - GitHubActionsWorkflowResource and GitHubActionsJob types - SchedulingResolver projecting step DAG onto job dependency graph - 29 unit tests covering environment resolution and scheduling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Aspire.slnx | 2 + docs/specs/pipeline-generation.md | 392 ++++++++++++++++++ ...ire.Hosting.Pipelines.GitHubActions.csproj | 19 + .../GitHubActionsJob.cs | 74 ++++ .../GitHubActionsWorkflowExtensions.cs | 43 ++ .../GitHubActionsWorkflowResource.cs | 48 +++ .../SchedulingResolver.cs | 262 ++++++++++++ .../SchedulingValidationException.cs | 27 ++ .../DistributedApplicationBuilder.cs | 4 +- .../DistributedApplicationPipeline.cs | 53 ++- .../IDistributedApplicationPipeline.cs | 18 +- .../Pipelines/IPipelineEnvironment.cs | 22 + .../Pipelines/IPipelineStepTarget.cs | 30 ++ .../Pipelines/LocalPipelineEnvironment.cs | 21 + .../PipelineEnvironmentCheckAnnotation.cs | 27 ++ .../PipelineEnvironmentCheckContext.cs | 18 + src/Aspire.Hosting/Pipelines/PipelineStep.cs | 11 + ...sting.Pipelines.GitHubActions.Tests.csproj | 12 + .../GitHubActionsWorkflowResourceTests.cs | 106 +++++ .../SchedulingResolverTests.cs | 291 +++++++++++++ .../Pipelines/PipelineEnvironmentTests.cs | 137 ++++++ 21 files changed, 1612 insertions(+), 5 deletions(-) create mode 100644 docs/specs/pipeline-generation.md create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs create mode 100644 src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs create mode 100644 src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs create mode 100644 src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs 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..8eb868f00c1 --- /dev/null +++ b/docs/specs/pipeline-generation.md @@ -0,0 +1,392 @@ +# 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 (Future) + +The YAML generator (not yet implemented) would produce: + +```yaml +name: deploy +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + run: aspire do --continue --job publish + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-publish + path: .aspire/state/ + + deploy: + runs-on: ubuntu-latest + needs: [publish] + steps: + - uses: actions/checkout@v4 + - name: Download state + uses: actions/download-artifact@v4 + with: + name: aspire-state-publish + path: .aspire/state/ + - name: Setup .NET + uses: actions/setup-dotnet@v4 + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + run: aspire do --continue --job deploy +``` + +### `--continue` and `--job` Semantics (Future) + +When `aspire do --continue --job ` is invoked: + +1. The AppHost starts and builds the pipeline as usual. +2. It reads the job ID from the CLI argument. +3. It runs only the steps assigned to that job (per the scheduling resolver). +4. State from previous jobs is already available via downloaded artifacts. + +## State Management (Future) + +Inter-job state is managed through CI/CD artifacts: + +- **State directory**: `.aspire/state/` +- **Upload**: Each job uploads its state after execution +- **Download**: Each job downloads state from its dependency jobs before execution +- **Content**: Serialized pipeline context, resource connection strings, provisioned resource metadata +- **Security**: No secrets in artifacts — secrets use CI/CD native secret management + +## 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 + +## 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 | + +### Tests + +| File | Description | +|------|-------------| +| `tests/Aspire.Hosting.Tests/Pipelines/PipelineEnvironmentTests.cs` | Environment resolution tests | +| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs` | Workflow model tests | +| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs` | Scheduling validation tests | + +### Modified + +| File | Change | +|------|--------| +| `src/Aspire.Hosting/Pipelines/PipelineStep.cs` | Added `ScheduledBy` property | +| `src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs` | Added `scheduledBy` to `AddStep()`, added `GetEnvironmentAsync()` | +| `src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs` | Constructor takes model, implements `GetEnvironmentAsync()` | +| `src/Aspire.Hosting/DistributedApplicationBuilder.cs` | Pipeline initialized with model | diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj b/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj new file mode 100644 index 00000000000..2a0e79fb1e1 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Aspire.Hosting.Pipelines.GitHubActions.csproj @@ -0,0 +1,19 @@ + + + + $(DefaultTargetFramework) + true + true + aspire hosting pipelines github-actions ci-cd + GitHub Actions pipeline generation for Aspire. + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs new file mode 100644 index 00000000000..ce302309074 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Represents a job within a GitHub Actions workflow. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class GitHubActionsJob : IPipelineStepTarget +{ + private readonly List _dependsOnJobs = []; + + internal GitHubActionsJob(string id, GitHubActionsWorkflowResource workflow) + { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentNullException.ThrowIfNull(workflow); + + Id = id; + Workflow = workflow; + } + + /// + /// Gets the unique identifier for this job within the workflow. + /// + public string Id { get; } + + /// + /// Gets or sets the human-readable display name for this job. + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets the runner label for this job (defaults to "ubuntu-latest"). + /// + public string RunsOn { get; set; } = "ubuntu-latest"; + + /// + /// Gets the IDs of jobs that this job depends on (maps to the needs: key in the workflow YAML). + /// + public IReadOnlyList DependsOnJobs => _dependsOnJobs; + + /// + /// Gets the workflow that owns this job. + /// + public GitHubActionsWorkflowResource Workflow { get; } + + /// + IPipelineEnvironment IPipelineStepTarget.Environment => Workflow; + + /// + /// Declares that this job depends on another job. + /// + /// The ID of the job this job depends on. + public void DependsOn(string jobId) + { + ArgumentException.ThrowIfNullOrEmpty(jobId); + _dependsOnJobs.Add(jobId); + } + + /// + /// Declares that this job depends on another job. + /// + /// The job this job depends on. + public void DependsOn(GitHubActionsJob job) + { + ArgumentNullException.ThrowIfNull(job); + _dependsOnJobs.Add(job.Id); + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs new file mode 100644 index 00000000000..81320913c24 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Extension methods for adding GitHub Actions workflow resources to a distributed application. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public static class GitHubActionsWorkflowExtensions +{ + /// + /// Adds a GitHub Actions workflow resource to the application model. + /// + /// The distributed application builder. + /// The name of the workflow resource. This also becomes the workflow filename (e.g., "deploy" → "deploy.yml"). + /// A resource builder for the workflow resource. + [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")] + public static IResourceBuilder AddGitHubActionsWorkflow( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var resource = new GitHubActionsWorkflowResource(name); + + resource.Annotations.Add(new PipelineEnvironmentCheckAnnotation(context => + { + // This environment is relevant when running inside GitHub Actions + var isGitHubActions = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + return Task.FromResult(isGitHubActions); + })); + + return builder.AddResource(resource) + .ExcludeFromManifest(); + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs new file mode 100644 index 00000000000..7a9eb6d206c --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Represents a GitHub Actions workflow as a pipeline environment resource. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipelineEnvironment +{ + private readonly List _jobs = []; + + /// + /// Gets the filename for the generated workflow YAML file (e.g., "deploy.yml"). + /// + public string WorkflowFileName => $"{Name}.yml"; + + /// + /// Gets the jobs declared in this workflow. + /// + public IReadOnlyList Jobs => _jobs; + + /// + /// Adds a job to this workflow. + /// + /// The unique job identifier within the workflow. + /// The created . + public GitHubActionsJob AddJob(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + if (_jobs.Any(j => j.Id == id)) + { + throw new InvalidOperationException( + $"A job with the ID '{id}' has already been added to the workflow '{Name}'."); + } + + var job = new GitHubActionsJob(id, this); + _jobs.Add(job); + return job; + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs new file mode 100644 index 00000000000..e3eceb6c000 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Resolves pipeline step scheduling onto workflow jobs, validating that step-to-job +/// assignments are consistent with the step dependency graph. +/// +internal static class SchedulingResolver +{ + /// + /// Resolves step-to-job assignments and computes job dependencies. + /// + /// The pipeline steps to resolve. + /// The workflow resource containing the declared jobs. + /// The resolved scheduling result. + /// + /// Thrown when the step-to-job assignments create circular job dependencies or are otherwise invalid. + /// + public static SchedulingResult Resolve(IReadOnlyList steps, GitHubActionsWorkflowResource workflow) + { + ArgumentNullException.ThrowIfNull(steps); + ArgumentNullException.ThrowIfNull(workflow); + + var defaultJob = GetOrCreateDefaultJob(workflow); + + // Build step-to-job mapping + var stepToJob = new Dictionary(StringComparer.Ordinal); + + foreach (var step in steps) + { + if (step.ScheduledBy is GitHubActionsJob job) + { + if (job.Workflow != workflow) + { + throw new SchedulingValidationException( + $"Step '{step.Name}' is scheduled on job '{job.Id}' from a different workflow. " + + $"Steps can only be scheduled on jobs within the same workflow."); + } + stepToJob[step.Name] = job; + } + else if (step.ScheduledBy is not null) + { + throw new SchedulingValidationException( + $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy.GetType().Name}' " + + $"which is not a GitHubActionsJob."); + } + else + { + stepToJob[step.Name] = defaultJob; + } + } + + // Build step lookup + var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal); + + // Project step DAG onto job dependency graph + var jobDependencies = new Dictionary>(StringComparer.Ordinal); + + foreach (var step in steps) + { + var currentJob = stepToJob[step.Name]; + + if (!jobDependencies.ContainsKey(currentJob.Id)) + { + jobDependencies[currentJob.Id] = []; + } + + foreach (var depName in step.DependsOnSteps) + { + if (!stepToJob.TryGetValue(depName, out var depJob)) + { + // Dependency is not in our step list — skip (might be a well-known step) + continue; + } + + if (depJob.Id != currentJob.Id) + { + jobDependencies[currentJob.Id].Add(depJob.Id); + } + } + } + + // Ensure all jobs are in the dependency graph + foreach (var job in workflow.Jobs) + { + if (!jobDependencies.ContainsKey(job.Id)) + { + jobDependencies[job.Id] = []; + } + } + + if (!jobDependencies.ContainsKey(defaultJob.Id)) + { + jobDependencies[defaultJob.Id] = []; + } + + // Also include any explicitly declared job dependencies + foreach (var job in workflow.Jobs) + { + foreach (var dep in job.DependsOnJobs) + { + jobDependencies[job.Id].Add(dep); + } + } + + // Validate: job dependency graph must be a DAG (detect cycles) + ValidateNoCycles(jobDependencies); + + // Group steps by job + var stepsPerJob = new Dictionary>(StringComparer.Ordinal); + foreach (var step in steps) + { + var job = stepToJob[step.Name]; + if (!stepsPerJob.TryGetValue(job.Id, out var list)) + { + list = []; + stepsPerJob[job.Id] = list; + } + list.Add(step); + } + + return new SchedulingResult + { + StepToJob = stepToJob, + JobDependencies = jobDependencies.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlySet)kvp.Value, + StringComparer.Ordinal), + StepsPerJob = stepsPerJob.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value, + StringComparer.Ordinal), + DefaultJob = defaultJob + }; + } + + private static GitHubActionsJob GetOrCreateDefaultJob(GitHubActionsWorkflowResource workflow) + { + // If the workflow has no jobs, create a default one + if (workflow.Jobs.Count == 0) + { + return workflow.AddJob("default"); + } + + // If there's exactly one job, use it as the default + if (workflow.Jobs.Count == 1) + { + return workflow.Jobs[0]; + } + + // If there are multiple jobs, check if a "default" job exists + var defaultJob = workflow.Jobs.FirstOrDefault(j => j.Id == "default"); + if (defaultJob is not null) + { + return defaultJob; + } + + // Use the first job as the default + return workflow.Jobs[0]; + } + + private static void ValidateNoCycles(Dictionary> jobDependencies) + { + // DFS-based cycle detection with three-state visiting + var visited = new Dictionary(StringComparer.Ordinal); + var cyclePath = new List(); + + foreach (var jobId in jobDependencies.Keys) + { + visited[jobId] = VisitState.Unvisited; + } + + foreach (var jobId in jobDependencies.Keys) + { + if (visited[jobId] == VisitState.Unvisited) + { + if (HasCycleDfs(jobId, jobDependencies, visited, cyclePath)) + { + cyclePath.Reverse(); + var cycleDescription = string.Join(" → ", cyclePath); + throw new SchedulingValidationException( + $"Pipeline step scheduling creates a circular dependency between jobs: {cycleDescription}. " + + $"This typically happens when step A depends on step B, but their job assignments " + + $"create a cycle in the job dependency graph."); + } + } + } + } + + private static bool HasCycleDfs( + string jobId, + Dictionary> jobDependencies, + Dictionary visited, + List cyclePath) + { + visited[jobId] = VisitState.Visiting; + + if (jobDependencies.TryGetValue(jobId, out var deps)) + { + foreach (var dep in deps) + { + if (!visited.TryGetValue(dep, out var state)) + { + continue; + } + + if (state == VisitState.Visiting) + { + cyclePath.Add(dep); + cyclePath.Add(jobId); + return true; + } + + if (state == VisitState.Unvisited && HasCycleDfs(dep, jobDependencies, visited, cyclePath)) + { + cyclePath.Add(jobId); + return true; + } + } + } + + visited[jobId] = VisitState.Visited; + return false; + } + + private enum VisitState + { + Unvisited, + Visiting, + Visited + } +} + +/// +/// The result of resolving pipeline step scheduling onto workflow jobs. +/// +internal sealed class SchedulingResult +{ + /// + /// Gets the mapping of step names to their assigned jobs. + /// + public required Dictionary StepToJob { get; init; } + + /// + /// Gets the computed job dependency graph (job ID → set of job IDs it depends on). + /// + public required Dictionary> JobDependencies { get; init; } + + /// + /// Gets the steps grouped by their assigned job. + /// + public required Dictionary> StepsPerJob { get; init; } + + /// + /// Gets the default job used for unscheduled steps. + /// + public required GitHubActionsJob DefaultJob { get; init; } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs new file mode 100644 index 00000000000..33a77693ab1 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingValidationException.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Exception thrown when pipeline step scheduling onto workflow jobs is invalid. +/// +public class SchedulingValidationException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message describing the scheduling violation. + public SchedulingValidationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message describing the scheduling violation. + /// The inner exception. + public SchedulingValidationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Aspire.Hosting/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..f0ff7a9f725 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -25,12 +25,15 @@ internal sealed class DistributedApplicationPipeline : IDistributedApplicationPi { private readonly List _steps = []; private readonly List> _configurationCallbacks = []; + private readonly DistributedApplicationModel _model; // Store resolved pipeline data for diagnostics private List? _lastResolvedSteps; - public DistributedApplicationPipeline() + public DistributedApplicationPipeline(DistributedApplicationModel model) { + _model = model; + // Dependency order // {verb} -> {user steps} -> {verb}-prereq @@ -266,12 +269,21 @@ public DistributedApplicationPipeline() }); } + /// + /// Initializes a new instance of the class with an empty model. + /// Used for testing scenarios where the model is not needed. + /// + public DistributedApplicationPipeline() : this(new DistributedApplicationModel(Array.Empty())) + { + } + public bool HasSteps => _steps.Count > 0; public void AddStep(string name, Func action, object? dependsOn = null, - object? requiredBy = null) + object? requiredBy = null, + IPipelineStepTarget? scheduledBy = null) { if (_steps.Any(s => s.Name == name)) { @@ -282,7 +294,8 @@ public void AddStep(string name, var step = new PipelineStep { Name = name, - Action = action + Action = action, + ScheduledBy = scheduledBy }; if (dependsOn != null) @@ -357,6 +370,40 @@ public void AddPipelineConfiguration(Func ca _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); diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index d5845632e08..706ad494c02 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -20,10 +20,12 @@ public interface IDistributedApplicationPipeline /// The action to execute for this step. /// The name of the step this step depends on, or a list of step names. /// The name of the step that requires this step, or a list of step names. + /// The pipeline step target to schedule this step onto (e.g., a CI/CD job). void AddStep(string name, Func action, object? dependsOn = null, - object? requiredBy = null); + object? requiredBy = null, + IPipelineStepTarget? scheduledBy = null); /// /// Adds a deployment step to the pipeline. @@ -43,4 +45,18 @@ void AddStep(string name, /// The pipeline context for the execution. /// A task representing the asynchronous operation. Task ExecuteAsync(PipelineContext context); + + /// + /// Resolves the active pipeline environment for the current invocation. + /// + /// A token to cancel the operation. + /// + /// The active . Returns a + /// if no declared environment passes its relevance check. Throws if multiple environments + /// report as relevant. + /// + /// + /// Thrown when multiple pipeline environments report as relevant for the current invocation. + /// + Task GetEnvironmentAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs new file mode 100644 index 00000000000..72de5785bc1 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineEnvironment.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents an execution environment for pipeline steps, such as local execution, +/// GitHub Actions, Azure DevOps, or other CI/CD systems. +/// +/// +/// Pipeline environment resources are added to the distributed application model to indicate +/// where pipeline steps should be executed. Use +/// to register a relevance check that determines whether this environment is active for the +/// current invocation. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IPipelineEnvironment : IResource +{ +} diff --git a/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs b/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs new file mode 100644 index 00000000000..bd6d6444c8c --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineStepTarget.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents a target that pipeline steps can be scheduled onto, such as a job +/// in a CI/CD workflow. +/// +/// +/// When a pipeline step has a value, it indicates +/// that the step should execute in the context of the specified target (e.g., a specific +/// job in a GitHub Actions workflow). The scheduling resolver validates that step-to-target +/// assignments are consistent with the step dependency graph. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IPipelineStepTarget +{ + /// + /// Gets the unique identifier for this target within its pipeline environment. + /// + string Id { get; } + + /// + /// Gets the pipeline environment that owns this target. + /// + IPipelineEnvironment Environment { get; } +} diff --git a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs new file mode 100644 index 00000000000..761a9e51fcb --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents the local execution environment for pipeline steps. +/// +/// +/// This is the implicit fallback environment returned by +/// +/// when no declared resource passes its relevance check. +/// It is not added to the application model. +/// +internal sealed class LocalPipelineEnvironment() : Resource("local"), IPipelineEnvironment +{ +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs new file mode 100644 index 00000000000..5d16943a223 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckAnnotation.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// An annotation that provides a relevance check for a pipeline environment resource. +/// +/// +/// Apply this annotation to an resource to indicate +/// under what conditions the environment is active for the current invocation. For example, +/// a GitHub Actions environment might check for the GITHUB_ACTIONS environment variable. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineEnvironmentCheckAnnotation( + Func> checkAsync) : IResourceAnnotation +{ + /// + /// Evaluates whether the pipeline environment is relevant for the current invocation. + /// + /// The context for the check. + /// A task that resolves to true if this environment is relevant; otherwise, false. + public Task CheckAsync(PipelineEnvironmentCheckContext context) => checkAsync(context); +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs new file mode 100644 index 00000000000..403bfc615ab --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineEnvironmentCheckContext.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Provides context for a pipeline environment relevance check. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineEnvironmentCheckContext +{ + /// + /// Gets the cancellation token for the check operation. + /// + public required CancellationToken CancellationToken { get; init; } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index 7cd6ab2b744..ed243ea85b9 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -57,6 +57,17 @@ 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; } + /// /// Adds a dependency on another step. /// diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj new file mode 100644 index 00000000000..36d1ea6ef39 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultTargetFramework) + + + + + + + + diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs new file mode 100644 index 00000000000..af47ee730f6 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +[Trait("Partition", "4")] +public class GitHubActionsWorkflowResourceTests +{ + [Fact] + public void WorkflowFileName_MatchesResourceName() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + + Assert.Equal("deploy.yml", workflow.WorkflowFileName); + } + + [Fact] + public void AddJob_CreatesJobWithCorrectId() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job = workflow.AddJob("build"); + + Assert.Equal("build", job.Id); + Assert.Same(workflow, job.Workflow); + } + + [Fact] + public void AddJob_MultipleJobs_AllTracked() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var build = workflow.AddJob("build"); + var test = workflow.AddJob("test"); + var deploy = workflow.AddJob("deploy"); + + Assert.Equal(3, workflow.Jobs.Count); + Assert.Same(build, workflow.Jobs[0]); + Assert.Same(test, workflow.Jobs[1]); + Assert.Same(deploy, workflow.Jobs[2]); + } + + [Fact] + public void AddJob_DuplicateId_Throws() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + workflow.AddJob("build"); + + var ex = Assert.Throws(() => workflow.AddJob("build")); + Assert.Contains("build", ex.Message); + } + + [Fact] + public void Job_DependsOn_ById() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + workflow.AddJob("build"); + var deploy = workflow.AddJob("deploy"); + + deploy.DependsOn("build"); + + Assert.Single(deploy.DependsOnJobs); + Assert.Equal("build", deploy.DependsOnJobs[0]); + } + + [Fact] + public void Job_DependsOn_ByReference() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var build = workflow.AddJob("build"); + var deploy = workflow.AddJob("deploy"); + + deploy.DependsOn(build); + + Assert.Single(deploy.DependsOnJobs); + Assert.Equal("build", deploy.DependsOnJobs[0]); + } + + [Fact] + public void Job_DefaultRunsOn_IsUbuntuLatest() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job = workflow.AddJob("build"); + + Assert.Equal("ubuntu-latest", job.RunsOn); + } + + [Fact] + public void Job_IPipelineStepTarget_EnvironmentIsWorkflow() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job = workflow.AddJob("build"); + + IPipelineStepTarget target = job; + + Assert.Same(workflow, target.Environment); + } + + [Fact] + public void Workflow_ImplementsIPipelineEnvironment() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + + Assert.IsAssignableFrom(workflow); + } +} diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs new file mode 100644 index 00000000000..e2ed3d1c715 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs @@ -0,0 +1,291 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +[Trait("Partition", "4")] +public class SchedulingResolverTests +{ + [Fact] + public void Resolve_TwoStepsTwoJobs_ValidDependency_CorrectNeeds() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build-step", scheduledBy: buildJob); + var deployStep = CreateStep("deploy-step", deployJob, "build-step"); + + var result = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + + Assert.Same(buildJob, result.StepToJob["build-step"]); + Assert.Same(deployJob, result.StepToJob["deploy-step"]); + Assert.Contains("build", result.JobDependencies["deploy"]); + } + + [Fact] + public void Resolve_FanOut_OneStepDependsOnThreeAcrossThreeJobs() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + var job3 = workflow.AddJob("job3"); + var collectJob = workflow.AddJob("collect"); + + var step1 = CreateStep("step1", scheduledBy: job1); + var step2 = CreateStep("step2", scheduledBy: job2); + var step3 = CreateStep("step3", scheduledBy: job3); + var collectStep = CreateStep("collect-step", scheduledBy: collectJob, + dependsOn: ["step1", "step2", "step3"]); + + var result = SchedulingResolver.Resolve([step1, step2, step3, collectStep], workflow); + + var collectDeps = result.JobDependencies["collect"]; + Assert.Contains("job1", collectDeps); + Assert.Contains("job2", collectDeps); + Assert.Contains("job3", collectDeps); + } + + [Fact] + public void Resolve_FanIn_ThreeStepsDependOnOne() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var setupJob = workflow.AddJob("setup"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + var job3 = workflow.AddJob("job3"); + + var setupStep = CreateStep("setup-step", scheduledBy: setupJob); + var step1 = CreateStep("step1", job1, "setup-step"); + var step2 = CreateStep("step2", job2, "setup-step"); + var step3 = CreateStep("step3", job3, "setup-step"); + + var result = SchedulingResolver.Resolve([setupStep, step1, step2, step3], workflow); + + Assert.Contains("setup", result.JobDependencies["job1"]); + Assert.Contains("setup", result.JobDependencies["job2"]); + Assert.Contains("setup", result.JobDependencies["job3"]); + } + + [Fact] + public void Resolve_Diamond_ValidDagAcrossJobs() + { + // A → B, A → C, B → D, C → D + var workflow = new GitHubActionsWorkflowResource("deploy"); + var jobA = workflow.AddJob("jobA"); + var jobB = workflow.AddJob("jobB"); + var jobC = workflow.AddJob("jobC"); + var jobD = workflow.AddJob("jobD"); + + var stepA = CreateStep("A", scheduledBy: jobA); + var stepB = CreateStep("B", jobB, "A"); + var stepC = CreateStep("C", jobC, "A"); + var stepD = CreateStep("D", jobD, ["B", "C"]); + + var result = SchedulingResolver.Resolve([stepA, stepB, stepC, stepD], workflow); + + Assert.Contains("jobA", result.JobDependencies["jobB"]); + Assert.Contains("jobA", result.JobDependencies["jobC"]); + Assert.Contains("jobB", result.JobDependencies["jobD"]); + Assert.Contains("jobC", result.JobDependencies["jobD"]); + } + + [Fact] + public void Resolve_Cycle_ThrowsSchedulingValidationException() + { + // Step A on job1 depends on Step B on job2 depends on Step C on job1 depends on Step A + // This creates job1 → job2 → job1 cycle + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + + var stepA = CreateStep("A", job1, "C"); + var stepB = CreateStep("B", job2, "A"); + var stepC = CreateStep("C", job1, "B"); + + var ex = Assert.Throws( + () => SchedulingResolver.Resolve([stepA, stepB, stepC], workflow)); + + Assert.Contains("circular dependency", ex.Message); + } + + [Fact] + public void Resolve_DefaultJob_UnscheduledStepsGrouped() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + + var step1 = CreateStep("step1"); // No scheduledBy — goes to default job + var step2 = CreateStep("step2"); // No scheduledBy — goes to default job + var step3 = CreateStep("step3", scheduledBy: buildJob); + + var result = SchedulingResolver.Resolve([step1, step2, step3], workflow); + + // step1 and step2 should be on the default job (first job = build) + Assert.Same(buildJob, result.StepToJob["step1"]); + Assert.Same(buildJob, result.StepToJob["step2"]); + Assert.Same(buildJob, result.StepToJob["step3"]); + } + + [Fact] + public void Resolve_MixedScheduledAndUnscheduled() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var publishJob = workflow.AddJob("publish"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build"); // No scheduledBy → default (first job = publish) + var publishStep = CreateStep("publish", publishJob, "build"); + var deployStep = CreateStep("deploy", deployJob, "publish"); + + var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow); + + // build goes to default job (publish, the first job) + Assert.Same(publishJob, result.StepToJob["build"]); + Assert.Same(publishJob, result.StepToJob["publish"]); + Assert.Same(deployJob, result.StepToJob["deploy"]); + + // deploy depends on publish job (since deploy-step depends on publish-step which is on publish) + Assert.Contains("publish", result.JobDependencies["deploy"]); + } + + [Fact] + public void Resolve_SingleJob_AllStepsOnSameJob_NoJobDependencies() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job = workflow.AddJob("main"); + + var step1 = CreateStep("step1", scheduledBy: job); + var step2 = CreateStep("step2", job, "step1"); + var step3 = CreateStep("step3", job, "step2"); + + var result = SchedulingResolver.Resolve([step1, step2, step3], workflow); + + // All on same job, so no cross-job dependencies + Assert.Empty(result.JobDependencies["main"]); + } + + [Fact] + public void Resolve_NoJobs_CreatesDefaultJob() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + + var step1 = CreateStep("step1"); + var step2 = CreateStep("step2", null, "step1"); + + var result = SchedulingResolver.Resolve([step1, step2], workflow); + + Assert.Equal("default", result.DefaultJob.Id); + Assert.Same(result.DefaultJob, result.StepToJob["step1"]); + Assert.Same(result.DefaultJob, result.StepToJob["step2"]); + } + + [Fact] + public void Resolve_StepsGroupedPerJob_Correctly() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + + var stepA = CreateStep("A", scheduledBy: job1); + var stepB = CreateStep("B", scheduledBy: job1); + var stepC = CreateStep("C", scheduledBy: job2); + + var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow); + + Assert.Equal(2, result.StepsPerJob["job1"].Count); + Assert.Contains(result.StepsPerJob["job1"], s => s.Name == "A"); + Assert.Contains(result.StepsPerJob["job1"], s => s.Name == "B"); + Assert.Single(result.StepsPerJob["job2"]); + Assert.Equal("C", result.StepsPerJob["job2"][0].Name); + } + + [Fact] + public void Resolve_ExplicitJobDependency_Preserved() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var setupJob = workflow.AddJob("setup"); + var deployJob = workflow.AddJob("deploy"); + + // Explicit job-level dependency (not from steps) + deployJob.DependsOn(setupJob); + + var stepA = CreateStep("A", scheduledBy: setupJob); + var stepB = CreateStep("B", scheduledBy: deployJob); + + var result = SchedulingResolver.Resolve([stepA, stepB], workflow); + + Assert.Contains("setup", result.JobDependencies["deploy"]); + } + + [Fact] + public void Resolve_StepFromDifferentWorkflow_Throws() + { + var workflow1 = new GitHubActionsWorkflowResource("deploy"); + var workflow2 = new GitHubActionsWorkflowResource("other"); + var job = workflow2.AddJob("build"); + + var step = CreateStep("step1", scheduledBy: job); + + var ex = Assert.Throws( + () => SchedulingResolver.Resolve([step], workflow1)); + + Assert.Contains("different workflow", ex.Message); + } + + [Fact] + public void Resolve_ExplicitJobDependency_CreatesCycle_Throws() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + + // Explicit cycle: job1 → job2 → job1 + job1.DependsOn(job2); + job2.DependsOn(job1); + + var stepA = CreateStep("A", scheduledBy: job1); + var stepB = CreateStep("B", scheduledBy: job2); + + var ex = Assert.Throws( + () => SchedulingResolver.Resolve([stepA, stepB], workflow)); + + Assert.Contains("circular dependency", ex.Message); + } + + // Helper methods + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + ScheduledBy = scheduledBy + }; + } + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + DependsOnSteps = [dependsOn], + ScheduledBy = scheduledBy + }; + } + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string[] dependsOn) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + DependsOnSteps = [.. dependsOn], + ScheduledBy = scheduledBy + }; + } +} diff --git a/tests/Aspire.Hosting.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 + { + } +} From faa840279c2d4ef2466d8e4f8cba26d04e4ee307 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 21:34:38 +1100 Subject: [PATCH 02/15] Phase 2: YAML generation, state restore, and snapshot tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WorkflowYaml model types (WorkflowYaml, JobYaml, StepYaml, etc.) - Add hand-rolled WorkflowYamlSerializer (no external dependencies) - Add WorkflowYamlGenerator: scheduling result → complete workflow YAML - Boilerplate steps: checkout, setup-dotnet, install Aspire CLI - State artifact upload/download between dependent jobs - Per-job aspire do --continue --job execution - Add TryRestoreStepAsync on PipelineStep for CI/CD state restore - Executor calls restore callback before Action - If restore returns true, step is skipped (already completed) - Add 9 YAML generator unit tests - Add 4 Verify snapshot tests with complete YAML output - Add 5 step state restore integration tests - Update spec doc with YAML generation and state restore design Total: 47 tests passing (12 hosting + 35 GitHub Actions) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/pipeline-generation.md | 186 +++++++++++--- .../WorkflowYamlGenerator.cs | 144 +++++++++++ .../Yaml/WorkflowYaml.cs | 88 +++++++ .../Yaml/WorkflowYamlSerializer.cs | 236 ++++++++++++++++++ .../DistributedApplicationPipeline.cs | 12 + src/Aspire.Hosting/Pipelines/PipelineStep.cs | 18 ++ ...sting.Pipelines.GitHubActions.Tests.csproj | 1 + ...BareWorkflow_SingleDefaultJob.verified.txt | 34 +++ ...Tests.CustomRunsOn_WindowsJob.verified.txt | 35 +++ ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 98 ++++++++ ...TwoJobPipeline_BuildAndDeploy.verified.txt | 64 +++++ .../WorkflowYamlGeneratorTests.cs | 223 +++++++++++++++++ .../WorkflowYamlSnapshotTests.cs | 123 +++++++++ .../Pipelines/StepStateRestoreTests.cs | 174 +++++++++++++ 14 files changed, 1405 insertions(+), 31 deletions(-) create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md index 8eb868f00c1..bb3d9ee99f7 100644 --- a/docs/specs/pipeline-generation.md +++ b/docs/specs/pipeline-generation.md @@ -222,68 +222,140 @@ The resolver computes: - **`publish` job**: `build-images` → `push-images` (no `needs:`) - **`deploy` job**: `deploy-infra` → `deploy-apps` (`needs: publish`) -## Generated Workflow Structure (Future) +## Generated Workflow Structure -The YAML generator (not yet implemented) would produce: +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: - publish: + build: + name: 'Build & Publish' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - 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 - run: aspire do --continue --job publish + 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-publish + name: aspire-state-build path: .aspire/state/ + if-no-files-found: ignore deploy: + name: Deploy to Azure runs-on: ubuntu-latest - needs: [publish] + needs: build steps: - - uses: actions/checkout@v4 - - name: Download state - uses: actions/download-artifact@v4 - with: - name: aspire-state-publish - path: .aspire/state/ + - 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 ``` -### `--continue` and `--job` Semantics (Future) +### 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 | -When `aspire do --continue --job ` is invoked: +A hand-rolled `WorkflowYamlSerializer` converts the model to YAML strings without external dependencies. -1. The AppHost starts and builds the pipeline as usual. -2. It reads the job ID from the CLI argument. -3. It runs only the steps assigned to that job (per the scheduling resolver). -4. State from previous jobs is already available via downloaded artifacts. +### `WorkflowYamlGenerator` -## State Management (Future) +`WorkflowYamlGenerator.Generate()` takes a `SchedulingResult` and `GitHubActionsWorkflowResource` and produces a `WorkflowYaml`: -Inter-job state is managed through CI/CD artifacts: +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 -- **State directory**: `.aspire/state/` -- **Upload**: Each job uploads its state after execution -- **Download**: Each job downloads state from its dependency jobs before execution -- **Content**: Serialized pipeline context, resource connection strings, provisioned resource metadata -- **Security**: No secrets in artifacts — secrets use CI/CD native secret management +## 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 @@ -348,6 +420,52 @@ Environment resolution tests cover: - 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? @@ -373,20 +491,26 @@ Environment resolution tests cover: | `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 | -| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs` | Workflow model tests | -| `tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs` | Scheduling validation tests | +| `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` property | +| `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()` | +| `src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs` | Constructor takes model, implements `GetEnvironmentAsync()`, `TryRestoreStepAsync` in executor | | `src/Aspire.Hosting/DistributedApplicationBuilder.cs` | Pipeline initialized with model | diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs new file mode 100644 index 00000000000..d3f96efbca1 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Generates a from a scheduling result and workflow resource. +/// +internal static class WorkflowYamlGenerator +{ + private const string StateArtifactPrefix = "aspire-state-"; + private const string StatePath = ".aspire/state/"; + + /// + /// Generates a workflow YAML model from the scheduling result. + /// + public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow) + { + ArgumentNullException.ThrowIfNull(scheduling); + ArgumentNullException.ThrowIfNull(workflow); + + var workflowYaml = new WorkflowYaml + { + Name = workflow.Name, + On = new WorkflowTriggers + { + WorkflowDispatch = true, + Push = new PushTrigger + { + Branches = ["main"] + } + }, + Permissions = new Dictionary + { + ["contents"] = "read", + ["id-token"] = "write" + } + }; + + // Generate a YAML job for each workflow job + foreach (var job in workflow.Jobs) + { + var jobYaml = GenerateJob(job, scheduling); + workflowYaml.Jobs[job.Id] = jobYaml; + } + + return workflowYaml; + } + + private static JobYaml GenerateJob(GitHubActionsJob job, SchedulingResult scheduling) + { + var steps = new List(); + + // Boilerplate: checkout + steps.Add(new StepYaml + { + Name = "Checkout code", + Uses = "actions/checkout@v4" + }); + + // Boilerplate: setup .NET + steps.Add(new StepYaml + { + Name = "Setup .NET", + Uses = "actions/setup-dotnet@v4", + With = new Dictionary + { + ["dotnet-version"] = "10.0.x" + } + }); + + // Boilerplate: install Aspire CLI + steps.Add(new StepYaml + { + Name = "Install Aspire CLI", + Run = "dotnet tool install -g aspire" + }); + + // Download state artifacts from dependency jobs + var jobDeps = scheduling.JobDependencies.GetValueOrDefault(job.Id); + if (jobDeps is { Count: > 0 }) + { + foreach (var depJobId in jobDeps) + { + steps.Add(new StepYaml + { + Name = $"Download state from {depJobId}", + Uses = "actions/download-artifact@v4", + With = new Dictionary + { + ["name"] = $"{StateArtifactPrefix}{depJobId}", + ["path"] = StatePath + } + }); + } + } + + // TODO: Auth/setup steps will be added here when PipelineSetupRequirementAnnotation is implemented. + // For now, users should add cloud-specific authentication steps manually. + + // Run aspire do for this job's steps + steps.Add(new StepYaml + { + Name = "Run pipeline steps", + Run = $"aspire do --continue --job {job.Id}", + Env = new Dictionary + { + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" + } + }); + + // Upload state artifacts for downstream jobs + steps.Add(new StepYaml + { + Name = "Upload state", + Uses = "actions/upload-artifact@v4", + With = new Dictionary + { + ["name"] = $"{StateArtifactPrefix}{job.Id}", + ["path"] = StatePath, + ["if-no-files-found"] = "ignore" + } + }); + + // Build needs list from scheduling result + List? needs = null; + if (scheduling.JobDependencies.TryGetValue(job.Id, out var deps) && deps.Count > 0) + { + needs = [.. deps]; + } + + return new JobYaml + { + Name = job.DisplayName, + RunsOn = job.RunsOn, + Needs = needs, + Steps = steps + }; + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs new file mode 100644 index 00000000000..f8b336a0737 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +/// +/// Represents a complete GitHub Actions workflow YAML document. +/// +internal sealed class WorkflowYaml +{ + public required string Name { get; init; } + + public WorkflowTriggers On { get; init; } = new(); + + public Dictionary? Permissions { get; init; } + + public Dictionary Jobs { get; init; } = new(StringComparer.Ordinal); +} + +/// +/// Represents the trigger configuration for a workflow. +/// +internal sealed class WorkflowTriggers +{ + public bool WorkflowDispatch { get; init; } = true; + + public PushTrigger? Push { get; init; } +} + +/// +/// Represents the push trigger configuration. +/// +internal sealed class PushTrigger +{ + public List Branches { get; init; } = []; +} + +/// +/// Represents a job in the workflow. +/// +internal sealed class JobYaml +{ + public string? Name { get; init; } + + public string RunsOn { get; init; } = "ubuntu-latest"; + + public string? If { get; init; } + + public string? Environment { get; init; } + + public List? Needs { get; init; } + + public Dictionary? Permissions { get; init; } + + public Dictionary? Env { get; init; } + + public ConcurrencyYaml? Concurrency { get; init; } + + public List Steps { get; init; } = []; +} + +/// +/// Represents a step within a job. +/// +internal sealed class StepYaml +{ + public string? Name { get; init; } + + public string? Uses { get; init; } + + public string? Run { get; init; } + + public Dictionary? With { get; init; } + + public Dictionary? Env { get; init; } + + public string? Id { get; init; } +} + +/// +/// Represents concurrency configuration for a job. +/// +internal sealed class ConcurrencyYaml +{ + public required string Group { get; init; } + + public bool CancelInProgress { get; init; } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs new file mode 100644 index 00000000000..52a0d2f64fd --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs @@ -0,0 +1,236 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; + +namespace Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +/// +/// Serializes to a YAML string. +/// +internal static class WorkflowYamlSerializer +{ + public static string Serialize(WorkflowYaml workflow) + { + var sb = new StringBuilder(); + + sb.AppendLine(CultureInfo.InvariantCulture, $"name: {workflow.Name}"); + sb.AppendLine(); + + WriteOn(sb, workflow.On); + + if (workflow.Permissions is { Count: > 0 }) + { + sb.AppendLine(); + sb.AppendLine("permissions:"); + foreach (var (key, value) in workflow.Permissions) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {value}"); + } + } + + sb.AppendLine(); + sb.AppendLine("jobs:"); + + var firstJob = true; + foreach (var (jobId, job) in workflow.Jobs) + { + if (!firstJob) + { + sb.AppendLine(); + } + firstJob = false; + + WriteJob(sb, jobId, job); + } + + return sb.ToString(); + } + + private static void WriteOn(StringBuilder sb, WorkflowTriggers triggers) + { + sb.AppendLine("on:"); + + if (triggers.WorkflowDispatch) + { + sb.AppendLine(" workflow_dispatch:"); + } + + if (triggers.Push is not null) + { + sb.AppendLine(" push:"); + if (triggers.Push.Branches.Count > 0) + { + sb.AppendLine(" branches:"); + foreach (var branch in triggers.Push.Branches) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - {branch}"); + } + } + } + } + + private static void WriteJob(StringBuilder sb, string jobId, JobYaml job) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {jobId}:"); + + if (job.Name is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" name: {YamlQuote(job.Name)}"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $" runs-on: {job.RunsOn}"); + + if (job.If is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" if: {job.If}"); + } + + if (job.Environment is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" environment: {job.Environment}"); + } + + if (job.Needs is { Count: > 0 }) + { + if (job.Needs.Count == 1) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" needs: {job.Needs[0]}"); + } + else + { + sb.AppendLine(CultureInfo.InvariantCulture, $" needs: [{string.Join(", ", job.Needs)}]"); + } + } + + if (job.Concurrency is not null) + { + sb.AppendLine(" concurrency:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" group: {job.Concurrency.Group}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" cancel-in-progress: {(job.Concurrency.CancelInProgress ? "true" : "false")}"); + } + + if (job.Permissions is { Count: > 0 }) + { + sb.AppendLine(" permissions:"); + foreach (var (key, value) in job.Permissions) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {value}"); + } + } + + if (job.Env is { Count: > 0 }) + { + sb.AppendLine(" env:"); + foreach (var (key, value) in job.Env) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}"); + } + } + + if (job.Steps.Count > 0) + { + sb.AppendLine(" steps:"); + foreach (var step in job.Steps) + { + WriteStep(sb, step); + } + } + } + + private static void WriteStep(StringBuilder sb, StepYaml step) + { + // First property determines the leading dash + if (step.Name is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - name: {YamlQuote(step.Name)}"); + } + else if (step.Uses is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - uses: {step.Uses}"); + } + else if (step.Run is not null) + { + WriteRunStep(sb, step, leadWithDash: true); + return; + } + else + { + return; + } + + if (step.Id is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" id: {step.Id}"); + } + + if (step.Uses is not null && step.Name is not null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" uses: {step.Uses}"); + } + + if (step.With is { Count: > 0 }) + { + sb.AppendLine(" with:"); + foreach (var (key, value) in step.With) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}"); + } + } + + if (step.Env is { Count: > 0 }) + { + sb.AppendLine(" env:"); + foreach (var (key, value) in step.Env) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" {key}: {YamlQuote(value)}"); + } + } + + if (step.Run is not null) + { + WriteRunStep(sb, step, leadWithDash: false); + } + } + + private static void WriteRunStep(StringBuilder sb, StepYaml step, bool leadWithDash) + { + var indent = leadWithDash ? " " : " "; + var prefix = leadWithDash ? "- " : ""; + + if (step.Run!.Contains('\n')) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}{prefix}run: |"); + foreach (var line in step.Run.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) + { + sb.AppendLine(); + } + else + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent} {line}"); + } + } + } + else + { + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent}{prefix}run: {step.Run}"); + } + } + + private static string YamlQuote(string value) + { + if (value.Contains('\'') || value.Contains('"') || value.Contains(':') || + value.Contains('#') || value.Contains('{') || value.Contains('}') || + value.Contains('[') || value.Contains(']') || value.Contains('&') || + value.Contains('*') || value.Contains('!') || value.Contains('|') || + value.Contains('>') || value.Contains('%') || value.Contains('@')) + { + return $"'{value.Replace("'", "''")}'"; + } + + return value; + } +} diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index f0ff7a9f725..1c454fc2b64 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -886,6 +886,18 @@ private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContex { try { + // If the step has a restore callback, try it first. If it returns true, + // the step is considered already complete (e.g., restored from CI/CD state + // persisted by a previous job) and its Action is not invoked. + if (step.TryRestoreStepAsync is not null) + { + var restored = await step.TryRestoreStepAsync(stepContext).ConfigureAwait(false); + if (restored) + { + return; + } + } + await step.Action(stepContext).ConfigureAwait(false); } catch (DistributedApplicationException) diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index ed243ea85b9..2f16030468c 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -68,6 +68,24 @@ public class PipelineStep /// public IPipelineStepTarget? ScheduledBy { get; set; } + /// + /// Gets or initializes an optional callback that attempts to restore this step from prior state. + /// + /// + /// + /// When set, the pipeline executor calls this callback before executing the step's . + /// If the callback returns true, the step is considered already complete and its + /// is not invoked. If it returns false, the step executes normally. + /// + /// + /// This enables CI/CD scenarios where pipeline execution is distributed across multiple jobs + /// or machines. A step that ran in a previous job can persist its outputs (e.g., via + /// ), and when the pipeline resumes on a different machine, + /// the callback restores that state and signals that re-execution is unnecessary. + /// + /// + public Func>? TryRestoreStepAsync { get; init; } + /// /// Adds a dependency on another step. /// diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Aspire.Hosting.Pipelines.GitHubActions.Tests.csproj index 36d1ea6ef39..8286d54c982 100644 --- 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 @@ -5,6 +5,7 @@ + diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt new file mode 100644 index 00000000000..88e3439d227 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.BareWorkflow_SingleDefaultJob.verified.txt @@ -0,0 +1,34 @@ +name: deploy + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + default: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job default + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-default + path: .aspire/state/ + if-no-files-found: ignore diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt new file mode 100644 index 00000000000..e3996654679 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.CustomRunsOn_WindowsJob.verified.txt @@ -0,0 +1,35 @@ +name: build-windows + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + build-win: + name: Build on Windows + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job build-win + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-build-win + path: .aspire/state/ + if-no-files-found: ignore diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt new file mode 100644 index 00000000000..9a95c438337 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.ThreeJobDiamond_FanOutAndIn.verified.txt @@ -0,0 +1,98 @@ +name: ci-cd + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job build + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-build + path: .aspire/state/ + if-no-files-found: ignore + + test: + name: Run Tests + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Download state from build + uses: actions/download-artifact@v4 + with: + name: aspire-state-build + path: .aspire/state/ + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job test + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-test + path: .aspire/state/ + if-no-files-found: ignore + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [build, test] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Download state from build + uses: actions/download-artifact@v4 + with: + name: aspire-state-build + path: .aspire/state/ + - name: Download state from test + uses: actions/download-artifact@v4 + with: + name: aspire-state-test + path: .aspire/state/ + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job deploy + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-deploy + path: .aspire/state/ + if-no-files-found: ignore diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt new file mode 100644 index 00000000000..96bcb88a3a3 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/Snapshots/WorkflowYamlSnapshotTests.TwoJobPipeline_BuildAndDeploy.verified.txt @@ -0,0 +1,64 @@ +name: deploy + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + build: + name: 'Build & Publish' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job build + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-build + path: .aspire/state/ + if-no-files-found: ignore + + deploy: + name: Deploy to Azure + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Install Aspire CLI + run: dotnet tool install -g aspire + - name: Download state from build + uses: actions/download-artifact@v4 + with: + name: aspire-state-build + path: .aspire/state/ + - name: Run pipeline steps + env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + run: aspire do --continue --job deploy + - name: Upload state + uses: actions/upload-artifact@v4 + with: + name: aspire-state-deploy + path: .aspire/state/ + if-no-files-found: ignore diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs new file mode 100644 index 00000000000..3dc7ed4dc6b --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +[Trait("Partition", "4")] +public class WorkflowYamlGeneratorTests +{ + [Fact] + public void Generate_BareWorkflow_CreatesDefaultJobWithBoilerplate() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.Equal("deploy", yaml.Name); + Assert.Single(yaml.Jobs); + Assert.True(yaml.Jobs.ContainsKey("default")); + + var job = yaml.Jobs["default"]; + Assert.Contains(job.Steps, s => s.Name == "Checkout code"); + Assert.Contains(job.Steps, s => s.Name == "Setup .NET"); + Assert.Contains(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Contains(job.Steps, s => s.Run?.Contains("aspire do --continue --job default") == true); + } + + [Fact] + public void Generate_TwoJobs_CorrectNeedsDependencies() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build-app", buildJob); + var deployStep = CreateStep("deploy-app", deployJob, "build-app"); + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.Equal(2, yaml.Jobs.Count); + Assert.Null(yaml.Jobs["build"].Needs); + Assert.NotNull(yaml.Jobs["deploy"].Needs); + Assert.Contains("build", yaml.Jobs["deploy"].Needs!); + } + + [Fact] + public void Generate_MultipleJobDeps_NeedsContainsAll() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("build"); + var job2 = workflow.AddJob("test"); + var job3 = workflow.AddJob("deploy"); + + var step1 = CreateStep("build-app", job1); + var step2 = CreateStep("run-tests", job2); + var step3 = CreateStep("deploy-app", job3, ["build-app", "run-tests"]); + + var scheduling = SchedulingResolver.Resolve([step1, step2, step3], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.NotNull(yaml.Jobs["deploy"].Needs); + Assert.Contains("build", yaml.Jobs["deploy"].Needs!); + Assert.Contains("test", yaml.Jobs["deploy"].Needs!); + } + + [Fact] + public void Generate_DependentJobs_HasStateDownloadSteps() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build-app", buildJob); + var deployStep = CreateStep("deploy-app", deployJob, "build-app"); + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + // deploy job should download state from build + var deployJobYaml = yaml.Jobs["deploy"]; + Assert.Contains(deployJobYaml.Steps, s => + s.Name == "Download state from build" && + s.Uses == "actions/download-artifact@v4"); + } + + [Fact] + public void Generate_AllJobs_HaveStateUploadStep() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build-app", buildJob); + var deployStep = CreateStep("deploy-app", deployJob, "build-app"); + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + foreach (var (_, jobYaml) in yaml.Jobs) + { + Assert.Contains(jobYaml.Steps, s => + s.Name == "Upload state" && + s.Uses == "actions/upload-artifact@v4"); + } + } + + [Fact] + public void Generate_JobRunsOn_MatchesJobConfiguration() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + buildJob.RunsOn = "windows-latest"; + + var step = CreateStep("build-app", buildJob); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.Equal("windows-latest", yaml.Jobs["build"].RunsOn); + } + + [Fact] + public void Generate_JobDisplayName_IsPreserved() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + buildJob.DisplayName = "Build Application"; + + var step = CreateStep("build-app", buildJob); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.Equal("Build Application", yaml.Jobs["build"].Name); + } + + [Fact] + public void Generate_DefaultTriggers_WorkflowDispatchAndPush() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + Assert.True(yaml.On.WorkflowDispatch); + Assert.NotNull(yaml.On.Push); + Assert.Contains("main", yaml.On.Push!.Branches); + } + + [Fact] + public void SerializeRoundTrip_ProducesValidYaml() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + buildJob.DisplayName = "Build & Publish"; + + var buildStep = CreateStep("build-app", buildJob); + var deployStep = CreateStep("deploy-app", deployJob, "build-app"); + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + var yamlString = WorkflowYamlSerializer.Serialize(yamlModel); + + // Verify key structural elements + Assert.Contains("name: deploy", yamlString); + Assert.Contains("workflow_dispatch:", yamlString); + Assert.Contains("push:", yamlString); + Assert.Contains("branches:", yamlString); + Assert.Contains("- main", yamlString); + Assert.Contains(" build:", yamlString); + Assert.Contains(" deploy:", yamlString); + Assert.Contains("needs:", yamlString); + Assert.Contains("actions/checkout@v4", yamlString); + Assert.Contains("actions/setup-dotnet@v4", yamlString); + Assert.Contains("aspire do --continue --job build", yamlString); + Assert.Contains("aspire do --continue --job deploy", yamlString); + Assert.Contains("actions/upload-artifact@v4", yamlString); + Assert.Contains("actions/download-artifact@v4", yamlString); + Assert.Contains("'Build & Publish'", yamlString); // Quoted because of & + } + + // Helpers + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + ScheduledBy = scheduledBy + }; + } + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + DependsOnSteps = [dependsOn], + ScheduledBy = scheduledBy + }; + } + + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string[] dependsOn) + { + return new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + DependsOnSteps = [.. dependsOn], + ScheduledBy = scheduledBy + }; + } +} diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs new file mode 100644 index 00000000000..ef5bfa7e264 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlSnapshotTests.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +[Trait("Partition", "4")] +public class WorkflowYamlSnapshotTests +{ + [Fact] + public Task BareWorkflow_SingleDefaultJob() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = new PipelineStep + { + Name = "build-app", + Action = _ => Task.CompletedTask + }; + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + var output = WorkflowYamlSerializer.Serialize(yaml); + + return Verify(output).UseDirectory("Snapshots"); + } + + [Fact] + public Task TwoJobPipeline_BuildAndDeploy() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + buildJob.DisplayName = "Build & Publish"; + var deployJob = workflow.AddJob("deploy"); + deployJob.DisplayName = "Deploy to Azure"; + + var buildStep = new PipelineStep + { + Name = "build-app", + Action = _ => Task.CompletedTask, + ScheduledBy = buildJob + }; + + var deployStep = new PipelineStep + { + Name = "deploy-app", + Action = _ => Task.CompletedTask, + DependsOnSteps = ["build-app"], + ScheduledBy = deployJob + }; + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + var output = WorkflowYamlSerializer.Serialize(yaml); + + return Verify(output).UseDirectory("Snapshots"); + } + + [Fact] + public Task ThreeJobDiamond_FanOutAndIn() + { + var workflow = new GitHubActionsWorkflowResource("ci-cd"); + var buildJob = workflow.AddJob("build"); + buildJob.DisplayName = "Build"; + var testJob = workflow.AddJob("test"); + testJob.DisplayName = "Run Tests"; + var deployJob = workflow.AddJob("deploy"); + deployJob.DisplayName = "Deploy"; + + var buildStep = new PipelineStep + { + Name = "build-app", + Action = _ => Task.CompletedTask, + ScheduledBy = buildJob + }; + + var testStep = new PipelineStep + { + Name = "run-tests", + Action = _ => Task.CompletedTask, + DependsOnSteps = ["build-app"], + ScheduledBy = testJob + }; + + var deployStep = new PipelineStep + { + Name = "deploy-app", + Action = _ => Task.CompletedTask, + DependsOnSteps = ["build-app", "run-tests"], + ScheduledBy = deployJob + }; + + var scheduling = SchedulingResolver.Resolve([buildStep, testStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + var output = WorkflowYamlSerializer.Serialize(yaml); + + return Verify(output).UseDirectory("Snapshots"); + } + + [Fact] + public Task CustomRunsOn_WindowsJob() + { + var workflow = new GitHubActionsWorkflowResource("build-windows"); + var winJob = workflow.AddJob("build-win"); + winJob.DisplayName = "Build on Windows"; + winJob.RunsOn = "windows-latest"; + + var step = new PipelineStep + { + Name = "build-app", + Action = _ => Task.CompletedTask, + ScheduledBy = winJob + }; + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + var output = WorkflowYamlSerializer.Serialize(yaml); + + return Verify(output).UseDirectory("Snapshots"); + } +} diff --git a/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs new file mode 100644 index 00000000000..fe8243388d8 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/StepStateRestoreTests.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CS0618 // Type or member is obsolete +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECOMPUTE001 +#pragma warning disable ASPIRECOMPUTE003 + +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Tests.Pipelines; + +[Trait("Partition", "4")] +public class StepStateRestoreTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task ExecuteAsync_StepWithSuccessfulRestore_SkipsExecution() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + var pipeline = new DistributedApplicationPipeline(); + + var actionExecuted = false; + pipeline.AddStep(new PipelineStep + { + Name = "restorable-step", + Action = async (_) => { actionExecuted = true; await Task.CompletedTask; }, + TryRestoreStepAsync = _ => Task.FromResult(true) + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + Assert.False(actionExecuted, "Step action should not execute when restore succeeds"); + } + + [Fact] + public async Task ExecuteAsync_StepWithFailedRestore_ExecutesNormally() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + var pipeline = new DistributedApplicationPipeline(); + + var actionExecuted = false; + pipeline.AddStep(new PipelineStep + { + Name = "non-restorable-step", + Action = async (_) => { actionExecuted = true; await Task.CompletedTask; }, + TryRestoreStepAsync = _ => Task.FromResult(false) + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + Assert.True(actionExecuted, "Step action should execute when restore fails"); + } + + [Fact] + public async Task ExecuteAsync_StepWithoutRestoreFunc_AlwaysExecutes() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + var pipeline = new DistributedApplicationPipeline(); + + var actionExecuted = false; + pipeline.AddStep(new PipelineStep + { + Name = "plain-step", + Action = async (_) => { actionExecuted = true; await Task.CompletedTask; } + // No TryRestoreStepAsync set + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + Assert.True(actionExecuted, "Step action should execute when no restore func is set"); + } + + [Fact] + public async Task ExecuteAsync_MixedRestoredAndFresh_CorrectBehavior() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep(new PipelineStep + { + Name = "step1", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step1"); } await Task.CompletedTask; }, + TryRestoreStepAsync = _ => Task.FromResult(true) // Restorable — will be skipped + }); + + pipeline.AddStep(new PipelineStep + { + Name = "step2", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step2"); } await Task.CompletedTask; }, + DependsOnSteps = ["step1"] + }); + + pipeline.AddStep(new PipelineStep + { + Name = "step3", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step3"); } await Task.CompletedTask; } + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + Assert.DoesNotContain("step1", executedSteps); + Assert.Contains("step2", executedSteps); + Assert.Contains("step3", executedSteps); + } + + [Fact] + public async Task ExecuteAsync_RestoredStep_DependentsStillRun() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep(new PipelineStep + { + Name = "A", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("A"); } await Task.CompletedTask; }, + TryRestoreStepAsync = _ => Task.FromResult(true) // Restored + }); + + pipeline.AddStep(new PipelineStep + { + Name = "B", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("B"); } await Task.CompletedTask; }, + DependsOnSteps = ["A"] + }); + + pipeline.AddStep(new PipelineStep + { + Name = "C", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("C"); } await Task.CompletedTask; }, + DependsOnSteps = ["B"] + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + Assert.DoesNotContain("A", executedSteps); + Assert.Contains("B", executedSteps); + Assert.Contains("C", executedSteps); + } + + private static PipelineContext CreateDeployingContext(DistributedApplication app) + { + return new PipelineContext( + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services, + app.Services.GetRequiredService>(), + CancellationToken.None); + } +} From 16b6c5ffab23170b0112d23c75d32d5e4e8e3209 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 22:07:07 +1100 Subject: [PATCH 03/15] Add ScheduleStep API for scheduling existing/built-in steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ScheduleStep(string stepName, IPipelineStepTarget target) to IDistributedApplicationPipeline. This allows consumers to schedule built-in steps (e.g., WellKnownPipelineSteps.Build) onto CI/CD jobs without having to create them — useful when integrations register steps and the AppHost just needs to assign them to workflow jobs. - Interface and implementation in Aspire.Hosting - 7 unit tests: basic scheduling, override, built-in steps, null guards, error on missing step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationPipeline.cs | 13 ++ .../IDistributedApplicationPipeline.cs | 12 ++ .../Pipelines/ScheduleStepTests.cs | 126 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 1c454fc2b64..f7eea973378 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -364,6 +364,19 @@ 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); diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index 706ad494c02..1bc939a5b49 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -33,6 +33,18 @@ void AddStep(string name, /// The pipeline step to add. void AddStep(PipelineStep step); + /// + /// Schedules an existing pipeline step onto a specific target (e.g., a CI/CD job). + /// This is useful for scheduling built-in steps that are already registered by + /// integrations or the core platform. + /// + /// The name of the existing step to schedule. + /// The pipeline step target to schedule the step onto. + /// + /// Thrown when no step with the specified name exists in the pipeline. + /// + void ScheduleStep(string stepName, IPipelineStepTarget target); + /// /// Registers a callback to be executed during the pipeline configuration phase. /// diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs new file mode 100644 index 00000000000..081dfebfa1f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Tests.Pipelines; + +[Trait("Partition", "4")] +public class ScheduleStepTests +{ + [Fact] + public void ScheduleStep_ExistingStep_SetsScheduledBy() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var target = new TestStepTarget("build-job"); + + pipeline.AddStep("build-app", _ => Task.CompletedTask); + pipeline.ScheduleStep("build-app", target); + + var steps = GetSteps(pipeline); + var step = steps.Single(s => s.Name == "build-app"); + Assert.Same(target, step.ScheduledBy); + } + + [Fact] + public void ScheduleStep_NonExistentStep_Throws() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var target = new TestStepTarget("build-job"); + + var ex = Assert.Throws( + () => pipeline.ScheduleStep("does-not-exist", target)); + Assert.Contains("does-not-exist", ex.Message); + } + + [Fact] + public void ScheduleStep_MultipleStepsOnDifferentTargets_AllScheduled() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var buildTarget = new TestStepTarget("build-job"); + var deployTarget = new TestStepTarget("deploy-job"); + + pipeline.AddStep("build-app", _ => Task.CompletedTask); + pipeline.AddStep("deploy-app", _ => Task.CompletedTask, dependsOn: "build-app"); + + pipeline.ScheduleStep("build-app", buildTarget); + pipeline.ScheduleStep("deploy-app", deployTarget); + + var steps = GetSteps(pipeline); + Assert.Same(buildTarget, steps.Single(s => s.Name == "build-app").ScheduledBy); + Assert.Same(deployTarget, steps.Single(s => s.Name == "deploy-app").ScheduledBy); + } + + [Fact] + public void ScheduleStep_OverridesPreviousScheduling() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var target1 = new TestStepTarget("job1"); + var target2 = new TestStepTarget("job2"); + + pipeline.AddStep("my-step", _ => Task.CompletedTask, scheduledBy: target1); + pipeline.ScheduleStep("my-step", target2); + + var steps = GetSteps(pipeline); + Assert.Same(target2, steps.Single(s => s.Name == "my-step").ScheduledBy); + } + + [Fact] + public void ScheduleStep_NullStepName_ThrowsArgumentNull() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var target = new TestStepTarget("build-job"); + + Assert.Throws(() => pipeline.ScheduleStep(null!, target)); + } + + [Fact] + public void ScheduleStep_NullTarget_ThrowsArgumentNull() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + + pipeline.AddStep("my-step", _ => Task.CompletedTask); + + Assert.Throws(() => pipeline.ScheduleStep("my-step", null!)); + } + + [Fact] + public void ScheduleStep_BuiltInSteps_CanBeScheduled() + { + var model = new DistributedApplicationModel(new ResourceCollection()); + var pipeline = new DistributedApplicationPipeline(model); + var buildTarget = new TestStepTarget("build-job"); + var deployTarget = new TestStepTarget("deploy-job"); + + // Built-in steps already exist from constructor + pipeline.ScheduleStep(WellKnownPipelineSteps.Build, buildTarget); + pipeline.ScheduleStep(WellKnownPipelineSteps.Deploy, deployTarget); + + var steps = GetSteps(pipeline); + Assert.Same(buildTarget, steps.Single(s => s.Name == WellKnownPipelineSteps.Build).ScheduledBy); + Assert.Same(deployTarget, steps.Single(s => s.Name == WellKnownPipelineSteps.Deploy).ScheduledBy); + } + + private static List GetSteps(DistributedApplicationPipeline pipeline) + { + var field = typeof(DistributedApplicationPipeline) + .GetField("_steps", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + return (List)field.GetValue(pipeline)!; + } + + private sealed class TestStepTarget(string id) : IPipelineStepTarget + { + public string Id => id; + public IPipelineEnvironment Environment { get; } = new StubPipelineEnvironment("test-env"); + } + + private sealed class StubPipelineEnvironment(string name) : Resource(name), IPipelineEnvironment; +} From e11cc507277b63b6b77af2d7551ddf32899b3d5e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 22:55:19 +1100 Subject: [PATCH 04/15] Retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/pipeline-generation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md index bb3d9ee99f7..331e16d7834 100644 --- a/docs/specs/pipeline-generation.md +++ b/docs/specs/pipeline-generation.md @@ -2,6 +2,7 @@ ## Status + **Stage:** Spike / Proof of Concept **Authors:** Aspire Team **Date:** 2025 From 38b169d21ab80cb98f1ad84dd3542c2f1537885d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 15:50:04 +1100 Subject: [PATCH 05/15] Fix API baseline breakage: keep new members on concrete class only Revert IDistributedApplicationPipeline to its original signature to preserve binary compatibility. The interface method AddStep retains its original 4-parameter signature. New members (ScheduleStep, GetEnvironmentAsync, and the 5-param AddStep overload with scheduledBy) are on DistributedApplicationPipeline only. Adding an optional parameter to an interface method changes the IL signature, which is a binary breaking change even though it's source compatible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationPipeline.cs | 20 +++++++++++-- .../IDistributedApplicationPipeline.cs | 30 +------------------ .../Pipelines/LocalPipelineEnvironment.cs | 2 +- .../Pipelines/ScheduleStepTests.cs | 2 +- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index f7eea973378..a322bd5c34c 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -282,8 +282,24 @@ public DistributedApplicationPipeline() : this(new DistributedApplicationModel(A public void AddStep(string name, Func action, object? dependsOn = null, - object? requiredBy = null, - IPipelineStepTarget? scheduledBy = 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)) { diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index 1bc939a5b49..d5845632e08 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -20,12 +20,10 @@ public interface IDistributedApplicationPipeline /// The action to execute for this step. /// The name of the step this step depends on, or a list of step names. /// The name of the step that requires this step, or a list of step names. - /// The pipeline step target to schedule this step onto (e.g., a CI/CD job). void AddStep(string name, Func action, object? dependsOn = null, - object? requiredBy = null, - IPipelineStepTarget? scheduledBy = null); + object? requiredBy = null); /// /// Adds a deployment step to the pipeline. @@ -33,18 +31,6 @@ void AddStep(string name, /// The pipeline step to add. void AddStep(PipelineStep step); - /// - /// Schedules an existing pipeline step onto a specific target (e.g., a CI/CD job). - /// This is useful for scheduling built-in steps that are already registered by - /// integrations or the core platform. - /// - /// The name of the existing step to schedule. - /// The pipeline step target to schedule the step onto. - /// - /// Thrown when no step with the specified name exists in the pipeline. - /// - void ScheduleStep(string stepName, IPipelineStepTarget target); - /// /// Registers a callback to be executed during the pipeline configuration phase. /// @@ -57,18 +43,4 @@ void AddStep(string name, /// The pipeline context for the execution. /// A task representing the asynchronous operation. Task ExecuteAsync(PipelineContext context); - - /// - /// Resolves the active pipeline environment for the current invocation. - /// - /// A token to cancel the operation. - /// - /// The active . Returns a - /// if no declared environment passes its relevance check. Throws if multiple environments - /// report as relevant. - /// - /// - /// Thrown when multiple pipeline environments report as relevant for the current invocation. - /// - Task GetEnvironmentAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs index 761a9e51fcb..42c4dcd3d88 100644 --- a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs +++ b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.Pipelines; /// /// /// This is the implicit fallback environment returned by -/// +/// /// when no declared resource passes its relevance check. /// It is not added to the application model. /// diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs index 081dfebfa1f..cba3ca8f6db 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/ScheduleStepTests.cs @@ -64,7 +64,7 @@ public void ScheduleStep_OverridesPreviousScheduling() var target1 = new TestStepTarget("job1"); var target2 = new TestStepTarget("job2"); - pipeline.AddStep("my-step", _ => Task.CompletedTask, scheduledBy: target1); + pipeline.AddStep("my-step", _ => Task.CompletedTask, dependsOn: null, requiredBy: null, scheduledBy: target1); pipeline.ScheduleStep("my-step", target2); var steps = GetSteps(pipeline); From b64d4fb29334f7e0d07f99f6e25d0277a3cc92af Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 15:56:51 +1100 Subject: [PATCH 06/15] Fix markdown lint: remove double blank line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/pipeline-generation.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md index 331e16d7834..bb3d9ee99f7 100644 --- a/docs/specs/pipeline-generation.md +++ b/docs/specs/pipeline-generation.md @@ -2,7 +2,6 @@ ## Status - **Stage:** Spike / Proof of Concept **Authors:** Aspire Team **Date:** 2025 From 3a360ae88e146b717a3911b90003d489e492e050 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 16:03:03 +1100 Subject: [PATCH 07/15] Trigger full CI build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs index 42c4dcd3d88..3127eb9a19f 100644 --- a/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs +++ b/src/Aspire.Hosting/Pipelines/LocalPipelineEnvironment.cs @@ -14,7 +14,7 @@ namespace Aspire.Hosting.Pipelines; /// This is the implicit fallback environment returned by /// /// when no declared resource passes its relevance check. -/// It is not added to the application model. +/// It is not added to the application model. /// internal sealed class LocalPipelineEnvironment() : Resource("local"), IPipelineEnvironment { From 78e9d2561b22ef0f12ffc7bf6670ed08e8785c0d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 17:55:58 +1100 Subject: [PATCH 08/15] Add GitHubActionsStageResource and rename Job to GitHubActionsJobResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHubActionsJob → GitHubActionsJobResource (extends Resource) - New GitHubActionsStageResource (extends Resource) for logical job grouping - workflow.AddStage("build-stage") → stage.AddJob("build") - workflow.AddJob("build") still works for direct job creation - Jobs created via stages are also registered on the workflow - 6 new tests for stage/resource APIs - Total: 60 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsJob.cs | 8 ++- .../GitHubActionsStageResource.cs | 45 ++++++++++++ .../GitHubActionsWorkflowResource.cs | 36 ++++++++-- .../SchedulingResolver.cs | 12 ++-- .../WorkflowYamlGenerator.cs | 2 +- .../GitHubActionsWorkflowResourceTests.cs | 70 +++++++++++++++++++ 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs index ce302309074..d3187b2f0cc 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsJob.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREPIPELINES001 using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Pipelines.GitHubActions; @@ -11,11 +12,12 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; /// Represents a job within a GitHub Actions workflow. /// [Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public class GitHubActionsJob : IPipelineStepTarget +public class GitHubActionsJobResource : Resource, IPipelineStepTarget { private readonly List _dependsOnJobs = []; - internal GitHubActionsJob(string id, GitHubActionsWorkflowResource workflow) + internal GitHubActionsJobResource(string id, GitHubActionsWorkflowResource workflow) + : base(id) { ArgumentException.ThrowIfNullOrEmpty(id); ArgumentNullException.ThrowIfNull(workflow); @@ -66,7 +68,7 @@ public void DependsOn(string jobId) /// Declares that this job depends on another job. /// /// The job this job depends on. - public void DependsOn(GitHubActionsJob job) + 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..f85e5916d23 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs @@ -0,0 +1,45 @@ +// 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. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class GitHubActionsStageResource(string name, GitHubActionsWorkflowResource workflow) + : Resource(name) +{ + 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; + + /// + /// 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; + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs index 7a9eb6d206c..ff9abec08ff 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs @@ -14,7 +14,8 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; [Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipelineEnvironment { - private readonly List _jobs = []; + private readonly List _jobs = []; + private readonly List _stages = []; /// /// Gets the filename for the generated workflow YAML file (e.g., "deploy.yml"). @@ -24,14 +25,39 @@ public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipel /// /// Gets the jobs declared in this workflow. /// - public IReadOnlyList Jobs => _jobs; + public IReadOnlyList Jobs => _jobs; + + /// + /// Gets the stages declared in this workflow. + /// + public IReadOnlyList Stages => _stages; + + /// + /// 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 GitHubActionsJob AddJob(string id) + /// The created . + public GitHubActionsJobResource AddJob(string id) { ArgumentException.ThrowIfNullOrEmpty(id); @@ -41,7 +67,7 @@ public GitHubActionsJob AddJob(string id) $"A job with the ID '{id}' has already been added to the workflow '{Name}'."); } - var job = new GitHubActionsJob(id, this); + var job = new GitHubActionsJobResource(id, this); _jobs.Add(job); return job; } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs index e3eceb6c000..06ebe22e09e 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -28,11 +28,11 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub var defaultJob = GetOrCreateDefaultJob(workflow); // Build step-to-job mapping - var stepToJob = new Dictionary(StringComparer.Ordinal); + var stepToJob = new Dictionary(StringComparer.Ordinal); foreach (var step in steps) { - if (step.ScheduledBy is GitHubActionsJob job) + if (step.ScheduledBy is GitHubActionsJobResource job) { if (job.Workflow != workflow) { @@ -46,7 +46,7 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub { throw new SchedulingValidationException( $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy.GetType().Name}' " + - $"which is not a GitHubActionsJob."); + $"which is not a GitHubActionsJobResource."); } else { @@ -138,7 +138,7 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub }; } - private static GitHubActionsJob GetOrCreateDefaultJob(GitHubActionsWorkflowResource workflow) + private static GitHubActionsJobResource GetOrCreateDefaultJob(GitHubActionsWorkflowResource workflow) { // If the workflow has no jobs, create a default one if (workflow.Jobs.Count == 0) @@ -243,7 +243,7 @@ internal sealed class SchedulingResult /// /// Gets the mapping of step names to their assigned jobs. /// - public required Dictionary StepToJob { get; init; } + public required Dictionary StepToJob { get; init; } /// /// Gets the computed job dependency graph (job ID → set of job IDs it depends on). @@ -258,5 +258,5 @@ internal sealed class SchedulingResult /// /// Gets the default job used for unscheduled steps. /// - public required GitHubActionsJob DefaultJob { get; init; } + public required GitHubActionsJobResource DefaultJob { get; init; } } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index d3f96efbca1..ad582bb00a5 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -51,7 +51,7 @@ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWo return workflowYaml; } - private static JobYaml GenerateJob(GitHubActionsJob job, SchedulingResult scheduling) + private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling) { var steps = new List(); diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs index af47ee730f6..9861064074f 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHubActionsWorkflowResourceTests.cs @@ -103,4 +103,74 @@ public void Workflow_ImplementsIPipelineEnvironment() 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); + } } From 0df178d0298728a5e3ce35e3c387dd8a1d2f442d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:07:35 +1100 Subject: [PATCH 09/15] Support scheduling steps to workflows and stages with auto-generated jobs - GitHubActionsWorkflowResource and GitHubActionsStageResource now implement IPipelineStepTarget, so steps can be scheduled onto them - When a step targets a workflow, a default stage + default job are auto-created to host it - When a step targets a stage, a default job is auto-created within that stage (named '{stage}-default') - SchedulingResolver.ResolveJobForStep uses pattern matching to resolve workflow/stage/job targets down to concrete jobs - GetOrAddDefaultStage() on workflow, GetOrAddDefaultJob() on stage - DefaultJob in SchedulingResult is now nullable (not created when all steps are explicitly scheduled) - 6 new resolver tests covering workflow/stage scheduling targets - Total: 66 tests passing (19 hosting + 47 GH Actions) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsStageResource.cs | 32 ++++- .../GitHubActionsWorkflowResource.cs | 40 +++++- .../SchedulingResolver.cs | 89 ++++++------ .../SchedulingResolverTests.cs | 128 ++++++++++++++++-- 4 files changed, 227 insertions(+), 62 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs index f85e5916d23..f707bdb993f 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsStageResource.cs @@ -13,9 +13,14 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; /// 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) + : Resource(name), IPipelineStepTarget { private readonly List _jobs = []; @@ -29,6 +34,12 @@ public class GitHubActionsStageResource(string name, GitHubActionsWorkflowResour /// public IReadOnlyList Jobs => _jobs; + /// + string IPipelineStepTarget.Id => Name; + + /// + IPipelineEnvironment IPipelineStepTarget.Environment => Workflow; + /// /// Adds a job to this stage. /// @@ -42,4 +53,23 @@ public GitHubActionsJobResource AddJob(string 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/GitHubActionsWorkflowResource.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs index ff9abec08ff..b887c52c923 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowResource.cs @@ -11,8 +11,13 @@ 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 +public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipelineEnvironment, IPipelineStepTarget { private readonly List _jobs = []; private readonly List _stages = []; @@ -32,6 +37,12 @@ public class GitHubActionsWorkflowResource(string name) : Resource(name), IPipel /// 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. /// @@ -71,4 +82,31 @@ public GitHubActionsJobResource AddJob(string id) _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/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs index 06ebe22e09e..92d356b3821 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -25,33 +25,12 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub ArgumentNullException.ThrowIfNull(steps); ArgumentNullException.ThrowIfNull(workflow); - var defaultJob = GetOrCreateDefaultJob(workflow); - - // Build step-to-job mapping + // Build step-to-job mapping, resolving workflow/stage targets to concrete jobs var stepToJob = new Dictionary(StringComparer.Ordinal); foreach (var step in steps) { - if (step.ScheduledBy is GitHubActionsJobResource job) - { - if (job.Workflow != workflow) - { - throw new SchedulingValidationException( - $"Step '{step.Name}' is scheduled on job '{job.Id}' from a different workflow. " + - $"Steps can only be scheduled on jobs within the same workflow."); - } - stepToJob[step.Name] = job; - } - else if (step.ScheduledBy is not null) - { - throw new SchedulingValidationException( - $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy.GetType().Name}' " + - $"which is not a GitHubActionsJobResource."); - } - else - { - stepToJob[step.Name] = defaultJob; - } + stepToJob[step.Name] = ResolveJobForStep(step, workflow); } // Build step lookup @@ -93,11 +72,6 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub } } - if (!jobDependencies.ContainsKey(defaultJob.Id)) - { - jobDependencies[defaultJob.Id] = []; - } - // Also include any explicitly declared job dependencies foreach (var job in workflow.Jobs) { @@ -123,6 +97,18 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub list.Add(step); } + // 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, @@ -138,29 +124,36 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub }; } - private static GitHubActionsJobResource GetOrCreateDefaultJob(GitHubActionsWorkflowResource workflow) + private static GitHubActionsJobResource ResolveJobForStep(PipelineStep step, GitHubActionsWorkflowResource workflow) { - // If the workflow has no jobs, create a default one - if (workflow.Jobs.Count == 0) + return step.ScheduledBy switch { - return workflow.AddJob("default"); - } + 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."), - // If there's exactly one job, use it as the default - if (workflow.Jobs.Count == 1) - { - return workflow.Jobs[0]; - } + GitHubActionsJobResource job => job, - // If there are multiple jobs, check if a "default" job exists - var defaultJob = workflow.Jobs.FirstOrDefault(j => j.Id == "default"); - if (defaultJob is not null) - { - return defaultJob; - } + 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}'."), - // Use the first job as the default - return workflow.Jobs[0]; + GitHubActionsWorkflowResource w => w.GetOrAddDefaultJob(), + + null => workflow.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).") + }; } private static void ValidateNoCycles(Dictionary> jobDependencies) @@ -256,7 +249,7 @@ internal sealed class SchedulingResult public required Dictionary> StepsPerJob { get; init; } /// - /// Gets the default job used for unscheduled steps. + /// Gets the default job used for unscheduled steps, or null if all steps were explicitly scheduled. /// - public required GitHubActionsJobResource DefaultJob { get; init; } + public GitHubActionsJobResource? DefaultJob { get; init; } } diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs index e2ed3d1c715..ed58ebd9cf2 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs @@ -117,16 +117,18 @@ public void Resolve_DefaultJob_UnscheduledStepsGrouped() var workflow = new GitHubActionsWorkflowResource("deploy"); var buildJob = workflow.AddJob("build"); - var step1 = CreateStep("step1"); // No scheduledBy — goes to default job - var step2 = CreateStep("step2"); // No scheduledBy — goes to default job + var step1 = CreateStep("step1"); // No scheduledBy — goes to auto-created default job + var step2 = CreateStep("step2"); // No scheduledBy — goes to auto-created default job var step3 = CreateStep("step3", scheduledBy: buildJob); var result = SchedulingResolver.Resolve([step1, step2, step3], workflow); - // step1 and step2 should be on the default job (first job = build) - Assert.Same(buildJob, result.StepToJob["step1"]); - Assert.Same(buildJob, result.StepToJob["step2"]); + // step1 and step2 should be on the auto-created default job, separate from buildJob + Assert.Same(result.DefaultJob!, result.StepToJob["step1"]); + Assert.Same(result.DefaultJob!, result.StepToJob["step2"]); Assert.Same(buildJob, result.StepToJob["step3"]); + Assert.NotSame(buildJob, result.DefaultJob!); + Assert.Equal("default", result.DefaultJob!.Id); } [Fact] @@ -136,18 +138,20 @@ public void Resolve_MixedScheduledAndUnscheduled() var publishJob = workflow.AddJob("publish"); var deployJob = workflow.AddJob("deploy"); - var buildStep = CreateStep("build"); // No scheduledBy → default (first job = publish) + var buildStep = CreateStep("build"); // No scheduledBy → auto-created default job var publishStep = CreateStep("publish", publishJob, "build"); var deployStep = CreateStep("deploy", deployJob, "publish"); var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow); - // build goes to default job (publish, the first job) - Assert.Same(publishJob, result.StepToJob["build"]); + // build goes to the auto-created default job + Assert.Same(result.DefaultJob!, result.StepToJob["build"]); Assert.Same(publishJob, result.StepToJob["publish"]); Assert.Same(deployJob, result.StepToJob["deploy"]); - // deploy depends on publish job (since deploy-step depends on publish-step which is on publish) + // publish depends on default job (build-step is on default, publish-step depends on build-step) + Assert.Contains(result.DefaultJob!.Id, result.JobDependencies["publish"]); + // deploy depends on publish job Assert.Contains("publish", result.JobDependencies["deploy"]); } @@ -177,9 +181,9 @@ public void Resolve_NoJobs_CreatesDefaultJob() 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"]); + Assert.Equal("default", result.DefaultJob!.Id); + Assert.Same(result.DefaultJob!, result.StepToJob["step1"]); + Assert.Same(result.DefaultJob!, result.StepToJob["step2"]); } [Fact] @@ -255,6 +259,106 @@ public void Resolve_ExplicitJobDependency_CreatesCycle_Throws() 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); + } + // Helper methods private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) From bd343dc8d75481e5ffe1c1638bf9454417dd0c3f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:25:50 +1100 Subject: [PATCH 10/15] Add 'aspire pipeline init' CLI command - PipelineCommand: group command (like 'mcp') at 'aspire pipeline' - PipelineInitCommand: subcommand extending PipelineCommandBase - Passes --operation publish --step pipeline-init to AppHost - Follows same pattern as deploy/publish commands - Added WellKnownPipelineSteps.PipelineInit constant - Registered in DI (Program.cs) and RootCommand - Resource strings (.resx + .Designer.cs) with localization xlf files - HelpGroup.Deployment for discoverability Usage: aspire pipeline init [--apphost ] [--output-path ] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/PipelineCommand.cs | 40 ++++++ .../Commands/PipelineInitCommand.cs | 69 +++++++++ src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Program.cs | 2 + .../PipelineCommandStrings.Designer.cs | 72 ++++++++++ .../Resources/PipelineCommandStrings.resx | 123 ++++++++++++++++ .../PipelineInitCommandStrings.Designer.cs | 108 ++++++++++++++ .../Resources/PipelineInitCommandStrings.resx | 135 ++++++++++++++++++ .../xlf/PipelineCommandStrings.cs.xlf | 12 ++ .../xlf/PipelineCommandStrings.de.xlf | 12 ++ .../xlf/PipelineCommandStrings.es.xlf | 12 ++ .../xlf/PipelineCommandStrings.fr.xlf | 12 ++ .../xlf/PipelineCommandStrings.it.xlf | 12 ++ .../xlf/PipelineCommandStrings.ja.xlf | 12 ++ .../xlf/PipelineCommandStrings.ko.xlf | 12 ++ .../xlf/PipelineCommandStrings.pl.xlf | 12 ++ .../xlf/PipelineCommandStrings.pt-BR.xlf | 12 ++ .../xlf/PipelineCommandStrings.ru.xlf | 12 ++ .../xlf/PipelineCommandStrings.tr.xlf | 12 ++ .../xlf/PipelineCommandStrings.zh-Hans.xlf | 12 ++ .../xlf/PipelineCommandStrings.zh-Hant.xlf | 12 ++ .../xlf/PipelineInitCommandStrings.cs.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.de.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.es.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.fr.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.it.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.ja.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.ko.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.pl.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.pt-BR.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.ru.xlf | 32 +++++ .../xlf/PipelineInitCommandStrings.tr.xlf | 32 +++++ .../PipelineInitCommandStrings.zh-Hans.xlf | 32 +++++ .../PipelineInitCommandStrings.zh-Hant.xlf | 32 +++++ .../Pipelines/WellKnownPipelineSteps.cs | 5 + 35 files changed, 1128 insertions(+) create mode 100644 src/Aspire.Cli/Commands/PipelineCommand.cs create mode 100644 src/Aspire.Cli/Commands/PipelineInitCommand.cs create mode 100644 src/Aspire.Cli/Resources/PipelineCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/PipelineCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/PipelineInitCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/PipelineInitCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineCommandStrings.zh-Hant.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/PipelineInitCommandStrings.zh-Hant.xlf 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/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/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"; } From 1e52221f1b5c06d917b86c765f2dd118357c725e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:30:32 +1100 Subject: [PATCH 11/15] Trigger full CI build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/pipeline-generation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md index bb3d9ee99f7..79d9b48a575 100644 --- a/docs/specs/pipeline-generation.md +++ b/docs/specs/pipeline-generation.md @@ -514,3 +514,4 @@ CLI command that: | `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 | + From bdc92638e0581f2ad580c2e72cc40adc83793088 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:51:10 +1100 Subject: [PATCH 12/15] Register PipelineCommand in CLI test DI container Fix DI resolution failure: 'Unable to resolve service for type PipelineCommand while attempting to activate RootCommand'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 ++ 1 file changed, 2 insertions(+) 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(); From 2e30a259f3a514849cbf35ec90809bdb79216c67 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:56:04 +1100 Subject: [PATCH 13/15] Fix markdown lint: remove trailing blank line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/pipeline-generation.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/specs/pipeline-generation.md b/docs/specs/pipeline-generation.md index 79d9b48a575..bb3d9ee99f7 100644 --- a/docs/specs/pipeline-generation.md +++ b/docs/specs/pipeline-generation.md @@ -514,4 +514,3 @@ CLI command that: | `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 | - From b4afe6816a52f44f6be318e1403d5cd4e8a12835 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 18:59:35 +1100 Subject: [PATCH 14/15] Trigger full CI build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From f4eddf12d10aee7c309a67c2e31e551137745f47 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 21:03:30 +1100 Subject: [PATCH 15/15] Implement pipeline-init step registration and execution Register a built-in 'pipeline-init' step in DistributedApplicationPipeline that discovers IPipelineEnvironment resources and calls their PipelineWorkflowGeneratorAnnotation to generate CI/CD workflow files. - Add PipelineWorkflowGeneratorAnnotation and PipelineWorkflowGenerationContext - Register generator annotation in AddGitHubActionsWorkflow extension - Pipeline-init step iterates environments and invokes generators Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 27 ++++++++ .../DistributedApplicationPipeline.cs | 49 +++++++++++++++ .../PipelineWorkflowGeneratorAnnotation.cs | 63 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index 81320913c24..e4a38290889 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -5,6 +5,8 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines.GitHubActions.Yaml; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Pipelines.GitHubActions; @@ -37,6 +39,31 @@ public static IResourceBuilder AddGitHubActionsWo return Task.FromResult(isGitHubActions); })); + resource.Annotations.Add(new PipelineWorkflowGeneratorAnnotation(async context => + { + var workflow = (GitHubActionsWorkflowResource)context.Environment; + var logger = context.StepContext.Logger; + + // Resolve scheduling (which steps run in which jobs) + var scheduling = SchedulingResolver.Resolve(context.Steps.ToList(), workflow); + + // Generate the YAML model + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + + // Serialize to YAML string + var yamlContent = WorkflowYamlSerializer.Serialize(yamlModel); + + // Write to .github/workflows/{name}.yml + var outputDir = Path.Combine(context.OutputDirectory, ".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); + })); + return builder.AddResource(resource) .ExcludeFromManifest(); } diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index a322bd5c34c..8e0aeaa69ad 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -267,6 +267,14 @@ public DistributedApplicationPipeline(DistributedApplicationModel model) 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 + }); } /// @@ -1301,4 +1309,45 @@ 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; + } + + 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 = _steps, + }; + + foreach (var generator in generators) + { + await generator.GenerateAsync(generationContext).ConfigureAwait(false); + } + } + } } diff --git a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs new file mode 100644 index 00000000000..bee915d5cc2 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs @@ -0,0 +1,63 @@ +// 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 output directory for generated files. Defaults to the current directory. + /// + public string OutputDirectory { get; set; } = Directory.GetCurrentDirectory(); + + /// + /// Gets the cancellation token. + /// + public CancellationToken CancellationToken => StepContext.CancellationToken; +}