From a112a419e1849bacdb8c5f568e0b016014fe14c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 27 Mar 2026 16:12:05 +1100 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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; +} From 3935e3d669a14cf283d49c8cc152c461d6b31aab Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 21:50:45 +1100 Subject: [PATCH 16/39] Add repo root detection for pipeline init Detect repository root via git rev-parse, fall back to walking up directories for aspire.config.json, and confirm with the user via IInteractionService. The resolved root is passed to generators as RepositoryRootDirectory (replaces OutputDirectory). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 4 +- .../DistributedApplicationPipeline.cs | 144 ++++++++++++++++++ .../PipelineWorkflowGeneratorAnnotation.cs | 10 +- 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index e4a38290889..70f8ff6c189 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -53,8 +53,8 @@ public static IResourceBuilder AddGitHubActionsWo // Serialize to YAML string var yamlContent = WorkflowYamlSerializer.Serialize(yamlModel); - // Write to .github/workflows/{name}.yml - var outputDir = Path.Combine(context.OutputDirectory, ".github", "workflows"); + // Write to .github/workflows/{name}.yml relative to the repo root + var outputDir = Path.Combine(context.RepositoryRootDirectory, ".github", "workflows"); Directory.CreateDirectory(outputDir); var outputPath = Path.Combine(outputDir, workflow.WorkflowFileName); diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 8e0aeaa69ad..e5c0400fe68 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -1323,6 +1323,17 @@ private async Task ExecutePipelineInitAsync(PipelineStepContext context) return; } + // Detect the repository root directory + var repoRoot = await DetectRepositoryRootAsync(context).ConfigureAwait(false); + + if (repoRoot is null) + { + context.Logger.LogError("Could not determine the repository root directory. Pipeline init cannot continue."); + return; + } + + context.Logger.LogInformation("Using repository root: {RepoRoot}", repoRoot); + foreach (var env in environments) { var resource = (IResource)env; @@ -1342,6 +1353,7 @@ private async Task ExecutePipelineInitAsync(PipelineStepContext context) StepContext = context, Environment = env, Steps = _steps, + RepositoryRootDirectory = repoRoot, }; foreach (var generator in generators) @@ -1350,4 +1362,136 @@ private async Task ExecutePipelineInitAsync(PipelineStepContext context) } } } + + /// + /// Detects the repository root directory by trying git, then aspire.config.json, + /// and confirms the result with the user via the interaction service. + /// + private static async Task DetectRepositoryRootAsync(PipelineStepContext context) + { + var ct = context.CancellationToken; + var logger = context.Logger; + + // Strategy 1: Try git rev-parse --show-toplevel + var gitRoot = await TryGetGitRootAsync(logger, ct).ConfigureAwait(false); + + // Strategy 2: Walk up directories looking for aspire.config.json + var configRoot = TryGetAspireConfigRoot(logger); + + // Determine the best candidate + string? detectedRoot = gitRoot ?? configRoot; + + if (detectedRoot is null) + { + logger.LogWarning("Could not auto-detect repository root via git or aspire.config.json."); + } + + // Confirm with the user via interaction service, allowing override + var interactionService = context.Services.GetService(); + + if (interactionService is null) + { + // No interaction service available (e.g., non-interactive mode) — use what we detected + return detectedRoot; + } + + var prompt = detectedRoot is not null + ? $"Detected repository root: {detectedRoot}" + : "Could not auto-detect the repository root."; + + var result = await interactionService.PromptInputAsync( + "Repository Root", + prompt + " Enter the path or press OK to confirm.", + new InteractionInput + { + Name = "repoRoot", + Label = "Repository root directory", + InputType = InputType.Text, + Value = detectedRoot ?? Directory.GetCurrentDirectory(), + Required = true, + }, + cancellationToken: ct).ConfigureAwait(false); + + if (result.Canceled) + { + logger.LogWarning("User cancelled repository root selection."); + return null; + } + + var selectedPath = result.Data?.Value; + if (string.IsNullOrWhiteSpace(selectedPath)) + { + return detectedRoot; + } + + var fullPath = Path.GetFullPath(selectedPath); + if (!Directory.Exists(fullPath)) + { + logger.LogError("The specified repository root does not exist: {Path}", fullPath); + return null; + } + + return fullPath; + } + + private static async Task TryGetGitRootAsync(ILogger logger, CancellationToken ct) + { + try + { + var startInfo = new ProcessStartInfo("git", "rev-parse --show-toplevel") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + logger.LogDebug("git rev-parse returned non-zero exit code {ExitCode}.", process.ExitCode); + return null; + } + + var output = (await outputTask.ConfigureAwait(false)).Trim(); + if (!string.IsNullOrEmpty(output) && Directory.Exists(output)) + { + logger.LogDebug("Detected git repository root: {GitRoot}", output); + return Path.GetFullPath(output); + } + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Git is not installed or not accessible."); + } + + return null; + } + + private static string? TryGetAspireConfigRoot(ILogger logger) + { + const string configFileName = "aspire.config.json"; + + var directory = new DirectoryInfo(Directory.GetCurrentDirectory()); + + while (directory is not null) + { + var configPath = Path.Combine(directory.FullName, configFileName); + if (File.Exists(configPath)) + { + logger.LogDebug("Found {ConfigFile} at: {Directory}", configFileName, directory.FullName); + return directory.FullName; + } + + directory = directory.Parent; + } + + logger.LogDebug("Could not find {ConfigFile} walking up from current directory.", configFileName); + return null; + } } diff --git a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs index bee915d5cc2..6ff814ea42c 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs @@ -52,9 +52,15 @@ public sealed class PipelineWorkflowGenerationContext public required IReadOnlyList Steps { get; init; } /// - /// Gets or sets the output directory for generated files. Defaults to the current directory. + /// Gets the root directory of the repository. This is used as the base path for + /// writing generated workflow files (e.g., .github/workflows/). /// - public string OutputDirectory { get; set; } = Directory.GetCurrentDirectory(); + /// + /// The directory is resolved during aspire pipeline init by detecting the git + /// repository root, falling back to the location of aspire.config.json, and + /// confirmed by the user via the interaction service. + /// + public required string RepositoryRootDirectory { get; init; } /// /// Gets the cancellation token. From 5aa197ee6f9c6c922e3a709e24f7b816d23d057a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 22:35:55 +1100 Subject: [PATCH 17/39] Add ScheduleStep to IDistributedApplicationPipeline interface Move ScheduleStep from concrete class only to the interface so it is discoverable via builder.Pipeline. Add convenience AddStep extension method with scheduledBy parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompatibilitySuppressions.xml | 7 ++++ ...istributedApplicationPipelineExtensions.cs | 39 +++++++++++++++++++ .../IDistributedApplicationPipeline.cs | 7 ++++ 3 files changed, 53 insertions(+) create mode 100644 src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 091fae9ffc4..cb55a5ff414 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -85,6 +85,13 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0006 + M:Aspire.Hosting.Pipelines.IDistributedApplicationPipeline.ScheduleStep(System.String,Aspire.Hosting.Pipelines.IPipelineStepTarget) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0021 M:Aspire.Hosting.ResourceBuilderExtensions.WithImagePushOptions``1(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Action{Aspire.Hosting.ApplicationModel.ContainerImagePushOptionsCallbackContext})``0:T:Aspire.Hosting.ApplicationModel.IComputeResource diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs new file mode 100644 index 00000000000..955bc67aa3d --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipelineExtensions.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Extension methods for . +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public static class DistributedApplicationPipelineExtensions +{ + /// + /// Adds a step to the pipeline and schedules it to run on the specified target. + /// + /// The pipeline. + /// The unique name of the step. + /// The action to execute for this step. + /// The target to schedule the step on. + /// The name of the step this step depends on, or a list of step names. + /// The name of the step that requires this step, or a list of step names. + public static void AddStep( + this IDistributedApplicationPipeline pipeline, + string name, + Func action, + IPipelineStepTarget scheduledBy, + object? dependsOn = null, + object? requiredBy = null) + { + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentNullException.ThrowIfNull(scheduledBy); + + pipeline.AddStep(name, action, dependsOn, requiredBy); + pipeline.ScheduleStep(name, scheduledBy); + } +} diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index d5845632e08..6cf20fc3cd0 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -37,6 +37,13 @@ void AddStep(string name, /// The callback function to execute during the configuration phase. void AddPipelineConfiguration(Func callback); + /// + /// Schedules a pipeline step to run on a specific pipeline step target (e.g., a workflow job or stage). + /// + /// The name of the step to schedule. + /// The target to schedule the step on. + void ScheduleStep(string stepName, IPipelineStepTarget target); + /// /// Executes all steps in the pipeline in dependency order. /// From b0558b7004c449c09a6f2560c48a02883fb2dabe Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sat, 28 Mar 2026 23:25:06 +1100 Subject: [PATCH 18/39] Add AddStage and AddJob extension methods for IResourceBuilder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index 70f8ff6c189..d341e8f015d 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -67,4 +67,38 @@ public static IResourceBuilder AddGitHubActionsWo return builder.AddResource(resource) .ExcludeFromManifest(); } + + /// + /// Adds a stage to the GitHub Actions workflow. Stages are a logical grouping of jobs. + /// + /// The workflow resource builder. + /// The unique stage name within the workflow. + /// The created . + [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")] + public static GitHubActionsStageResource AddStage( + this IResourceBuilder builder, + string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + return builder.Resource.AddStage(name); + } + + /// + /// Adds a job to the GitHub Actions workflow. + /// + /// The workflow resource builder. + /// The unique job identifier within the workflow. + /// The created . + [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")] + public static GitHubActionsJobResource AddJob( + this IResourceBuilder builder, + string id) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(id); + + return builder.Resource.AddJob(id); + } } From 5b9de7247554c8139ca7d9c682225dc4f23116e1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 00:19:29 +1100 Subject: [PATCH 19/39] Pull unscheduled steps into first consumer's target instead of creating default Unscheduled steps (ScheduledBy=null) are now resolved by BFS through reverse dependencies to find the nearest explicitly-scheduled consumer, and placed on that consumer's job. This eliminates unnecessary default stages/jobs when explicit stages exist. Fallback behavior: if no consumer is found and explicit targets exist, orphan steps go to the first available stage/job. If no explicit targets exist at all, the original default job behavior is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SchedulingResolver.cs | 119 ++++++++++++++++-- .../SchedulingResolverTests.cs | 88 ++++++++++--- .../DistributedApplicationPipelineTests.cs | 2 +- 3 files changed, 187 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs index 92d356b3821..9518fb34195 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -25,12 +25,58 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub ArgumentNullException.ThrowIfNull(steps); ArgumentNullException.ThrowIfNull(workflow); - // Build step-to-job mapping, resolving workflow/stage targets to concrete jobs - var stepToJob = new Dictionary(StringComparer.Ordinal); + // Build reverse dependency map: step name → steps that depend on it + var reverseDeps = new Dictionary>(StringComparer.Ordinal); foreach (var step in steps) { - stepToJob[step.Name] = ResolveJobForStep(step, workflow); + foreach (var dep in step.DependsOnSteps) + { + if (!reverseDeps.TryGetValue(dep, out var list)) + { + list = []; + reverseDeps[dep] = list; + } + + list.Add(step); + } + } + + // Phase 1: resolve all explicitly scheduled steps (ScheduledBy is set) + var explicitStepToJob = new Dictionary(StringComparer.Ordinal); + var hasExplicitTargets = false; + + foreach (var step in steps) + { + if (step.ScheduledBy is not null) + { + explicitStepToJob[step.Name] = ResolveExplicitTarget(step, workflow); + hasExplicitTargets = true; + } + } + + // Pre-existing jobs/stages on the workflow also count as explicit targets + hasExplicitTargets = hasExplicitTargets || workflow.Jobs.Count > 0 || workflow.Stages.Count > 0; + + // Phase 2: resolve unscheduled steps by pulling them into the first consumer's target + var stepToJob = new Dictionary(explicitStepToJob, StringComparer.Ordinal); + + foreach (var step in steps) + { + if (step.ScheduledBy is not null) + { + continue; + } + + if (hasExplicitTargets) + { + var consumerJob = FindFirstConsumerJob(step.Name, reverseDeps, explicitStepToJob); + stepToJob[step.Name] = consumerJob ?? GetFirstAvailableJob(workflow); + } + else + { + stepToJob[step.Name] = workflow.GetOrAddDefaultJob(); + } } // Build step lookup @@ -124,7 +170,10 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub }; } - private static GitHubActionsJobResource ResolveJobForStep(PipelineStep step, GitHubActionsWorkflowResource workflow) + /// + /// Resolves the job for a step that has an explicit target. + /// + private static GitHubActionsJobResource ResolveExplicitTarget(PipelineStep step, GitHubActionsWorkflowResource workflow) { return step.ScheduledBy switch { @@ -148,14 +197,70 @@ private static GitHubActionsJobResource ResolveJobForStep(PipelineStep step, Git GitHubActionsWorkflowResource w => w.GetOrAddDefaultJob(), - null => workflow.GetOrAddDefaultJob(), - _ => throw new SchedulingValidationException( - $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy.GetType().Name}' " + + $"Step '{step.Name}' has a ScheduledBy target of type '{step.ScheduledBy!.GetType().Name}' " + $"which is not a recognized GitHub Actions target (workflow, stage, or job).") }; } + /// + /// BFS through reverse dependencies to find the first explicitly-scheduled consumer's job. + /// This enables unscheduled steps to be "pulled into" the target of their nearest consumer. + /// + private static GitHubActionsJobResource? FindFirstConsumerJob( + string stepName, + Dictionary> reverseDeps, + Dictionary explicitStepToJob) + { + var visited = new HashSet(StringComparer.Ordinal) { stepName }; + var queue = new Queue(); + queue.Enqueue(stepName); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + + if (!reverseDeps.TryGetValue(current, out var consumers)) + { + continue; + } + + foreach (var consumer in consumers) + { + if (explicitStepToJob.TryGetValue(consumer.Name, out var job)) + { + return job; + } + + if (visited.Add(consumer.Name)) + { + queue.Enqueue(consumer.Name); + } + } + } + + return null; + } + + /// + /// Gets the first available job on the workflow for orphan unscheduled steps + /// (steps with no downstream consumer that has explicit scheduling). + /// + private static GitHubActionsJobResource GetFirstAvailableJob(GitHubActionsWorkflowResource workflow) + { + if (workflow.Stages.Count > 0) + { + return workflow.Stages[0].GetOrAddDefaultJob(); + } + + if (workflow.Jobs.Count > 0) + { + return workflow.Jobs[0]; + } + + return workflow.GetOrAddDefaultJob(); + } + private static void ValidateNoCycles(Dictionary> jobDependencies) { // DFS-based cycle detection with three-state visiting diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs index ed58ebd9cf2..fcdbb88e650 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs @@ -112,47 +112,45 @@ public void Resolve_Cycle_ThrowsSchedulingValidationException() } [Fact] - public void Resolve_DefaultJob_UnscheduledStepsGrouped() + public void Resolve_DefaultJob_UnscheduledStepsPulledToFirstJob() { var workflow = new GitHubActionsWorkflowResource("deploy"); var buildJob = workflow.AddJob("build"); - 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 step1 = CreateStep("step1"); // No scheduledBy — pulled to first available job + var step2 = CreateStep("step2"); // No scheduledBy — pulled to first available job var step3 = CreateStep("step3", scheduledBy: buildJob); var result = SchedulingResolver.Resolve([step1, step2, step3], workflow); - // 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"]); + // Orphan unscheduled steps (no consumer) go to the first available job + Assert.Same(buildJob, result.StepToJob["step1"]); + Assert.Same(buildJob, result.StepToJob["step2"]); Assert.Same(buildJob, result.StepToJob["step3"]); - Assert.NotSame(buildJob, result.DefaultJob!); - Assert.Equal("default", result.DefaultJob!.Id); } [Fact] - public void Resolve_MixedScheduledAndUnscheduled() + public void Resolve_MixedScheduledAndUnscheduled_PullsToConsumer() { var workflow = new GitHubActionsWorkflowResource("deploy"); var publishJob = workflow.AddJob("publish"); var deployJob = workflow.AddJob("deploy"); - var buildStep = CreateStep("build"); // No scheduledBy → auto-created default job + var buildStep = CreateStep("build"); // No scheduledBy → pulled to first consumer (publish) var publishStep = CreateStep("publish", publishJob, "build"); var deployStep = CreateStep("deploy", deployJob, "publish"); var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow); - // build goes to the auto-created default job - Assert.Same(result.DefaultJob!, result.StepToJob["build"]); + // build is pulled into publishJob because publish is the first thing that needs it + Assert.Same(publishJob, result.StepToJob["build"]); Assert.Same(publishJob, result.StepToJob["publish"]); Assert.Same(deployJob, result.StepToJob["deploy"]); - // publish depends on default job (build-step is on default, publish-step depends on build-step) - Assert.Contains(result.DefaultJob!.Id, result.JobDependencies["publish"]); + // No cross-job dependency for build→publish (same job) // deploy depends on publish job Assert.Contains("publish", result.JobDependencies["deploy"]); + Assert.DoesNotContain("default", result.JobDependencies.Keys); } [Fact] @@ -359,6 +357,68 @@ public void Resolve_ScheduledByDifferentWorkflow_Throws() Assert.Contains("workflow", ex.Message); } + [Fact] + public void Resolve_UnscheduledChain_PulledToFirstExplicitConsumer() + { + // A → B → C, where C is explicitly scheduled. A and B should be pulled into C's job. + var workflow = new GitHubActionsWorkflowResource("deploy"); + var publishJob = workflow.AddJob("publish"); + + var stepA = CreateStep("A"); + var stepB = CreateStep("B", null, "A"); + var stepC = CreateStep("C", publishJob, "B"); + + var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow); + + Assert.Same(publishJob, result.StepToJob["A"]); + Assert.Same(publishJob, result.StepToJob["B"]); + Assert.Same(publishJob, result.StepToJob["C"]); + Assert.Empty(result.JobDependencies["publish"]); + } + + [Fact] + public void Resolve_ExplicitStages_NoDefaultStageCreated() + { + // Two stages: publish and deploy. Unscheduled "build" depends on nothing, + // but publish depends on build → build is pulled into publish stage. + var workflow = new GitHubActionsWorkflowResource("deploy"); + var publishStage = workflow.AddStage("publish"); + var deployStage = workflow.AddStage("deploy"); + + var buildStep = CreateStep("build"); + var publishStep = CreateStep("publish", publishStage, "build"); + var deployStep = CreateStep("deploy", deployStage, "publish"); + + var result = SchedulingResolver.Resolve([buildStep, publishStep, deployStep], workflow); + + // build is pulled into publish stage's default job + Assert.Equal("publish-default", result.StepToJob["build"].Id); + Assert.Equal("publish-default", result.StepToJob["publish"].Id); + Assert.Equal("deploy-default", result.StepToJob["deploy"].Id); + + // No default stage should have been created + Assert.DoesNotContain(workflow.Stages, s => s.Name == "default"); + } + + [Fact] + public void Resolve_FanOut_UnscheduledPulledToFirstConsumer() + { + // A is unscheduled, both B (job1) and C (job2) depend on A. + // A is pulled to the first consumer found (job1). + var workflow = new GitHubActionsWorkflowResource("deploy"); + var job1 = workflow.AddJob("job1"); + var job2 = workflow.AddJob("job2"); + + var stepA = CreateStep("A"); + var stepB = CreateStep("B", job1, "A"); + var stepC = CreateStep("C", job2, "A"); + + var result = SchedulingResolver.Resolve([stepA, stepB, stepC], workflow); + + // A pulled to the first consumer's job (job1) + Assert.Same(job1, result.StepToJob["A"]); + } + // Helper methods private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 8bd14023074..164b201edb3 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -1425,7 +1425,7 @@ public async Task ExecuteAsync_WithConfigurationCallback_ExecutesCallback() await pipeline.ExecuteAsync(context).DefaultTimeout(); Assert.True(callbackExecuted); - Assert.Equal(12, capturedSteps.Count); // Updated to account for all default steps including process-parameters, push, push-prereq + Assert.Equal(13, capturedSteps.Count); // Updated to account for all default steps including process-parameters, push, push-prereq, pipeline-init Assert.Contains(capturedSteps, s => s.Name == "deploy"); Assert.Contains(capturedSteps, s => s.Name == "process-parameters"); Assert.Contains(capturedSteps, s => s.Name == "deploy-prereq"); From 920a3078d0e59dc51b58403067b977caff2541ca Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 20:24:02 +1100 Subject: [PATCH 20/39] Add provider-agnostic pipeline continuation with multi-file state - PipelineScopeAnnotation: auto-detects execution scope from CI env vars (GITHUB_RUN_ID + GITHUB_RUN_ATTEMPT for RunId, GITHUB_JOB for JobId) - PipelineScopeMapAnnotation: maps scope IDs to step names from scheduling - ContinuationStateManager: per-scope writes to .aspire/state/{RunId}/{JobId}.json, merged reads from all files in RunId directory for fan-in scenarios - Executor enters continuation mode automatically when scope is detected: in-scope steps execute, out-of-scope steps skip (or restore from state) - YAML generator simplified: 'aspire do' (no --continue --job flags), artifact names use 'aspire-do-state-' prefix, paths include run ID - 17 new tests: scope annotation, continuation state manager, scope filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 24 ++ .../WorkflowYamlGenerator.cs | 12 +- .../DistributedApplicationPipeline.cs | 87 ++++- .../Internal/ContinuationStateManager.cs | 327 ++++++++++++++++++ .../Pipelines/PipelineScopeAnnotation.cs | 38 ++ .../Pipelines/PipelineScopeContext.cs | 18 + .../Pipelines/PipelineScopeMapAnnotation.cs | 35 ++ .../Pipelines/PipelineScopeResult.cs | 33 ++ ...BareWorkflow_SingleDefaultJob.verified.txt | 6 +- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 6 +- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 30 +- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 16 +- .../WorkflowYamlGeneratorTests.cs | 6 +- .../ContinuationStateManagerTests.cs | 172 +++++++++ .../Pipelines/PipelineScopeAnnotationTests.cs | 137 ++++++++ .../Pipelines/ScopeFilteringTests.cs | 182 ++++++++++ 16 files changed, 1087 insertions(+), 42 deletions(-) create mode 100644 src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index d341e8f015d..afbbff7052d 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -39,6 +39,24 @@ public static IResourceBuilder AddGitHubActionsWo return Task.FromResult(isGitHubActions); })); + resource.Annotations.Add(new PipelineScopeAnnotation(context => + { + var runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"); + var runAttempt = Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") ?? "1"; + var jobId = Environment.GetEnvironmentVariable("GITHUB_JOB"); + + if (string.IsNullOrEmpty(runId) || string.IsNullOrEmpty(jobId)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new PipelineScopeResult + { + RunId = $"{runId}-{runAttempt}", + JobId = jobId + }); + })); + resource.Annotations.Add(new PipelineWorkflowGeneratorAnnotation(async context => { var workflow = (GitHubActionsWorkflowResource)context.Environment; @@ -47,6 +65,12 @@ public static IResourceBuilder AddGitHubActionsWo // Resolve scheduling (which steps run in which jobs) var scheduling = SchedulingResolver.Resolve(context.Steps.ToList(), workflow); + // Register scope map so the executor can filter steps in continuation mode + var scopeToSteps = scheduling.StepsPerJob.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.Select(s => s.Name).ToList()); + workflow.Annotations.Add(new PipelineScopeMapAnnotation(scopeToSteps)); + // Generate the YAML model var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index ad582bb00a5..42ccc69ff47 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -12,8 +12,8 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; /// internal static class WorkflowYamlGenerator { - private const string StateArtifactPrefix = "aspire-state-"; - private const string StatePath = ".aspire/state/"; + private const string StateArtifactPrefix = "aspire-do-state-"; + private const string StatePathExpression = ".aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/"; /// /// Generates a workflow YAML model from the scheduling result. @@ -93,7 +93,7 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul With = new Dictionary { ["name"] = $"{StateArtifactPrefix}{depJobId}", - ["path"] = StatePath + ["path"] = StatePathExpression } }); } @@ -102,11 +102,11 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul // 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 + // Run aspire do — scope is auto-detected from GITHUB_JOB env var steps.Add(new StepYaml { Name = "Run pipeline steps", - Run = $"aspire do --continue --job {job.Id}", + Run = "aspire do", Env = new Dictionary { ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" @@ -121,7 +121,7 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul With = new Dictionary { ["name"] = $"{StateArtifactPrefix}{job.Id}", - ["path"] = StatePath, + ["path"] = StatePathExpression, ["if-no-files-found"] = "ignore" } }); diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index e5c0400fe68..a212964a3bd 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -466,8 +466,13 @@ public async Task ExecuteAsync(PipelineContext context) var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context); + // Detect scope from the active pipeline environment. If we're running inside + // a CI job with a scope annotation, enter continuation mode: only in-scope steps + // execute, while out-of-scope steps are skipped (or have their TryRestoreStepAsync invoked). + var inScopeStepNames = await DetectScopeAsync(context).ConfigureAwait(false); + // Build dependency graph and execute with readiness-based scheduler - await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false); + await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context, inScopeStepNames).ConfigureAwait(false); } /// @@ -527,6 +532,69 @@ private static (List StepsToExecute, Dictionary + /// Detects the current execution scope from the active pipeline environment. + /// Returns the set of in-scope step names, or null if not in continuation mode. + /// + private async Task?> DetectScopeAsync(PipelineContext context) + { + // Find the active pipeline environment + var environment = await GetEnvironmentAsync(context.CancellationToken).ConfigureAwait(false); + + if (environment is not IResource environmentResource) + { + return null; + } + + // Check for scope annotation + if (!environmentResource.TryGetAnnotationsOfType(out var scopeAnnotations)) + { + return null; + } + + var scopeAnnotation = scopeAnnotations.FirstOrDefault(); + if (scopeAnnotation is null) + { + return null; + } + + var scopeContext = new PipelineScopeContext { CancellationToken = context.CancellationToken }; + var scopeResult = await scopeAnnotation.ResolveAsync(scopeContext).ConfigureAwait(false); + + if (scopeResult is null) + { + return null; + } + + // We have a scope — look up the scope map to find which steps are in this scope + if (!environmentResource.TryGetAnnotationsOfType(out var mapAnnotations)) + { + context.Logger.LogWarning( + "Scope detected (RunId={RunId}, JobId={JobId}) but no PipelineScopeMapAnnotation found. " + + "All steps will execute.", + scopeResult.RunId, + scopeResult.JobId); + return null; + } + + var scopeMap = mapAnnotations.FirstOrDefault(); + if (scopeMap is null || !scopeMap.ScopeToSteps.TryGetValue(scopeResult.JobId, out var stepNames)) + { + context.Logger.LogWarning( + "Scope '{JobId}' not found in scope map. All steps will execute.", + scopeResult.JobId); + return null; + } + + context.Logger.LogInformation( + "Continuation mode: RunId={RunId}, JobId={JobId}, executing {Count} step(s)", + scopeResult.RunId, + scopeResult.JobId, + stepNames.Count); + + return new HashSet(stepNames, StringComparer.Ordinal); + } + private static List ComputeTransitiveDependencies( PipelineStep step, Dictionary stepsByName) @@ -667,7 +735,8 @@ private static void ValidateSteps(IEnumerable steps) private static async Task ExecuteStepsAsTaskDag( List steps, Dictionary stepsByName, - PipelineContext context) + PipelineContext context, + HashSet? inScopeStepNames = null) { // Validate no cycles exist in the dependency graph ValidateDependencyGraph(steps, stepsByName); @@ -739,7 +808,7 @@ async Task ExecuteStepWithDependencies(PipelineStep step) { PipelineLoggerProvider.CurrentStep = reportingStep; - await ExecuteStepAsync(step, stepContext).ConfigureAwait(false); + await ExecuteStepAsync(step, stepContext, inScopeStepNames).ConfigureAwait(false); } catch (Exception ex) { @@ -919,10 +988,13 @@ void DetectCycles(string stepName, Stack path) } } - private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContext stepContext) + private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContext stepContext, HashSet? inScopeStepNames = null) { try { + // In continuation mode, out-of-scope steps are either restored or skipped. + var isInScope = inScopeStepNames is null || inScopeStepNames.Contains(step.Name); + // If the step has a restore callback, try it first. If it returns true, // the step is considered already complete (e.g., restored from CI/CD state // persisted by a previous job) and its Action is not invoked. @@ -935,6 +1007,13 @@ private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContex } } + // Out-of-scope steps that couldn't restore are auto-skipped in continuation mode. + // Their dependencies are still satisfied so downstream in-scope steps can proceed. + if (!isInScope) + { + return; + } + await step.Action(stepContext).ConfigureAwait(false); } catch (DistributedApplicationException) diff --git a/src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs b/src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs new file mode 100644 index 00000000000..dff7e1bd347 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/Internal/ContinuationStateManager.cs @@ -0,0 +1,327 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Pipelines.Internal; + +/// +/// State manager for continuation-mode pipeline execution. Reads merged state from +/// all scope files in a run-scoped directory; writes only to the current scope's file. +/// +internal sealed class ContinuationStateManager : IDeploymentStateManager +{ + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() + { + WriteIndented = true + }; + + private readonly ILogger _logger; + private readonly PipelineScopeResult _scope; + private readonly string _stateDirectory; + private readonly string _writeFilePath; + + private readonly SemaphoreSlim _stateLock = new(1, 1); + private readonly object _sectionsLock = new(); + private readonly Dictionary _sectionVersions = new(); + private JsonObject? _mergedState; + private bool _isLoaded; + + public ContinuationStateManager( + ILogger logger, + PipelineScopeResult scope, + string baseDirectory) + { + _logger = logger; + _scope = scope; + _stateDirectory = Path.Combine(baseDirectory, scope.RunId); + _writeFilePath = Path.Combine(_stateDirectory, $"{scope.JobId}.json"); + } + + /// + public string? StateFilePath => _writeFilePath; + + /// + /// Loads and merges all state files from the run-scoped directory. + /// + private async Task LoadMergedStateAsync(CancellationToken cancellationToken) + { + await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_isLoaded && _mergedState is not null) + { + return _mergedState; + } + + _mergedState = new JsonObject(); + + if (Directory.Exists(_stateDirectory)) + { + var jsonFiles = Directory.GetFiles(_stateDirectory, "*.json"); + _logger.LogDebug( + "Loading {Count} state file(s) from {Directory}", + jsonFiles.Length, + _stateDirectory); + + var jsonDocumentOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + foreach (var filePath in jsonFiles) + { + try + { + var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); + var flattenedState = JsonNode.Parse(content, documentOptions: jsonDocumentOptions)?.AsObject(); + if (flattenedState is null) + { + continue; + } + + var unflattenedState = JsonFlattener.UnflattenJsonObject(flattenedState); + MergeJsonObject(_mergedState, unflattenedState, Path.GetFileName(filePath)); + + _logger.LogDebug("Merged state from {File}", Path.GetFileName(filePath)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load state file {File}", filePath); + } + } + } + else + { + _logger.LogDebug("State directory {Directory} does not exist, starting with empty state", _stateDirectory); + } + + _isLoaded = true; + return _mergedState; + } + finally + { + _stateLock.Release(); + } + } + + /// + public async Task AcquireSectionAsync( + string sectionName, + CancellationToken cancellationToken = default) + { + var state = await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false); + + long version; + lock (_sectionsLock) + { + if (!_sectionVersions.TryGetValue(sectionName, out version)) + { + version = 0; + _sectionVersions[sectionName] = version; + } + } + + await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + JsonObject? data = null; + string? value = null; + + var sectionData = TryGetNestedPropertyValue(state, sectionName); + if (sectionData is JsonObject o) + { + data = o.DeepClone().AsObject(); + } + else if (sectionData is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String) + { + value = jsonValue.GetValue(); + } + + var section = new DeploymentStateSection(sectionName, data, version); + if (value is not null) + { + section.SetValue(value); + } + + return section; + } + finally + { + _stateLock.Release(); + } + } + + /// + public async Task SaveSectionAsync( + DeploymentStateSection section, + CancellationToken cancellationToken = default) + { + await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false); + + lock (_sectionsLock) + { + if (_sectionVersions.TryGetValue(section.SectionName, out var currentVersion) + && currentVersion != section.Version) + { + throw new InvalidOperationException( + $"Concurrency conflict in section '{section.SectionName}'. " + + $"Expected version {section.Version}, current is {currentVersion}."); + } + + _sectionVersions[section.SectionName] = section.Version + 1; + } + + section.Version++; + + await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Update merged state for in-memory reads + SetNestedPropertyValue(_mergedState!, section.SectionName, section.Data.DeepClone().AsObject()); + + // Write only the current scope's state file + await WriteScopeFileAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _stateLock.Release(); + } + } + + /// + public async Task DeleteSectionAsync( + DeploymentStateSection section, + CancellationToken cancellationToken = default) + { + await LoadMergedStateAsync(cancellationToken).ConfigureAwait(false); + + lock (_sectionsLock) + { + if (_sectionVersions.TryGetValue(section.SectionName, out var currentVersion) + && currentVersion != section.Version) + { + throw new InvalidOperationException( + $"Concurrency conflict in section '{section.SectionName}'. " + + $"Expected version {section.Version}, current is {currentVersion}."); + } + + _sectionVersions[section.SectionName] = section.Version + 1; + } + + section.Version++; + + await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + SetNestedPropertyValue(_mergedState!, section.SectionName, null); + await WriteScopeFileAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _stateLock.Release(); + } + } + + /// + /// Writes the current scope's portion of the merged state to its scope file. + /// Only sections modified by this scope are included (tracked by the merged state). + /// + private async Task WriteScopeFileAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(_stateDirectory); + + // We write the full merged state as the scope file. This is fine because + // each scope writes distinct sections in practice. The merge-on-read + // approach handles the recombination correctly. + var flattened = JsonFlattener.FlattenJsonObject(_mergedState!); + await File.WriteAllTextAsync( + _writeFilePath, + flattened.ToJsonString(s_jsonSerializerOptions), + cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("State saved to {Path}", _writeFilePath); + } + + /// + /// Deep-merges properties from into . + /// + private static void MergeJsonObject(JsonObject target, JsonObject source, string sourceFile) + { + foreach (var kvp in source) + { + if (kvp.Value is null) + { + continue; + } + + if (target.TryGetPropertyValue(kvp.Key, out var existingNode) && existingNode is JsonObject existingObj + && kvp.Value is JsonObject sourceObj) + { + MergeJsonObject(existingObj, sourceObj.DeepClone().AsObject(), sourceFile); + } + else + { + target[kvp.Key] = kvp.Value.DeepClone(); + } + } + } + + private static JsonNode? TryGetNestedPropertyValue(JsonObject? node, string path) + { + if (node is null) + { + return null; + } + + var segments = path.Split(':'); + JsonNode? current = node; + + foreach (var segment in segments) + { + if (current is not JsonObject currentObj || !currentObj.TryGetPropertyValue(segment, out var nextNode)) + { + return null; + } + current = nextNode; + } + + return current; + } + + private static void SetNestedPropertyValue(JsonObject root, string path, JsonObject? value) + { + var segments = path.Split(':'); + + var current = root; + for (var i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + if (!current.TryGetPropertyValue(segment, out var nextNode) || nextNode is not JsonObject nextObj) + { + if (value is null) + { + return; + } + nextObj = new JsonObject(); + current[segment] = nextObj; + } + current = nextObj; + } + + if (value is null) + { + current.Remove(segments[^1]); + } + else + { + current[segments[^1]] = value; + } + } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs new file mode 100644 index 00000000000..043c5dce9bf --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineScopeAnnotation.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// An annotation that resolves the current execution scope from the CI environment. +/// +/// +/// +/// Apply this annotation to an resource to enable automatic +/// scope detection during pipeline execution. When a scope is detected, the executor enters +/// continuation mode: only steps assigned to the current scope execute, while steps from other +/// scopes are restored from state. +/// +/// +/// Each pipeline provider supplies its own resolution logic. For example, a GitHub Actions provider +/// reads GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GITHUB_JOB to produce a +/// with run-level isolation and job-level step filtering. +/// +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineScopeAnnotation( + Func> resolveAsync) : IResourceAnnotation +{ + /// + /// Resolves the current pipeline execution scope from the environment. + /// + /// The context for the scope resolution. + /// + /// A containing the run and job identifiers if running + /// in a recognized CI environment; otherwise, null. + /// + public Task ResolveAsync(PipelineScopeContext context) => resolveAsync(context); +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs new file mode 100644 index 00000000000..dc90c788786 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineScopeContext.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Provides context for resolving the current pipeline execution scope. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineScopeContext +{ + /// + /// Gets the cancellation token for the scope resolution operation. + /// + public required CancellationToken CancellationToken { get; init; } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs new file mode 100644 index 00000000000..ec011bb4190 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineScopeMapAnnotation.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// An annotation that maps scope identifiers to the pipeline steps assigned to each scope. +/// +/// +/// +/// This annotation is populated by pipeline environment providers after scheduling resolution. +/// For example, the GitHub Actions provider maps job IDs (from GITHUB_JOB) to the step +/// names assigned to each job by the SchedulingResolver. +/// +/// +/// During continuation-mode execution, the pipeline executor uses this mapping to determine +/// which steps should execute in the current scope and which should be restored from state. +/// +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineScopeMapAnnotation( + IReadOnlyDictionary> scopeToSteps) : IResourceAnnotation +{ + /// + /// Gets the mapping from scope identifiers to the step names assigned to each scope. + /// + /// + /// A dictionary where the key is a scope identifier (e.g., a CI job ID) and the value + /// is the ordered list of step names that should execute within that scope. + /// + public IReadOnlyDictionary> ScopeToSteps { get; } = scopeToSteps; +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs b/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs new file mode 100644 index 00000000000..c9a02377599 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineScopeResult.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents the resolved scope for the current pipeline execution context, +/// identifying both the workflow run and the specific job within that run. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PipelineScopeResult +{ + /// + /// Gets the unique identifier for the current workflow run. + /// + /// + /// Used to isolate state directories so concurrent workflow runs on the same machine + /// (e.g., self-hosted CI runners) don't interfere with each other. For GitHub Actions, + /// this is typically composed from GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT. + /// + public required string RunId { get; init; } + + /// + /// Gets the unique identifier for the current job/scope within the workflow run. + /// + /// + /// Used for step filtering (determining which steps to execute vs. restore) and + /// state file naming. For GitHub Actions, this comes from GITHUB_JOB. + /// + public required string JobId { get; init; } +} diff --git a/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 index 88e3439d227..8ade43ee606 100644 --- 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 @@ -25,10 +25,10 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job default + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-default - path: .aspire/state/ + name: aspire-do-state-default + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' 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 index e3996654679..171b5320dd6 100644 --- 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 @@ -26,10 +26,10 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job build-win + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-build-win - path: .aspire/state/ + name: aspire-do-state-build-win + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' 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 index 9a95c438337..bf430024f26 100644 --- 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 @@ -26,12 +26,12 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job build + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-build - path: .aspire/state/ + name: aspire-do-state-build + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' if-no-files-found: ignore test: @@ -50,17 +50,17 @@ jobs: - name: Download state from build uses: actions/download-artifact@v4 with: - name: aspire-state-build - path: .aspire/state/ + name: aspire-do-state-build + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job test + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-test - path: .aspire/state/ + name: aspire-do-state-test + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' if-no-files-found: ignore deploy: @@ -79,20 +79,20 @@ jobs: - name: Download state from build uses: actions/download-artifact@v4 with: - name: aspire-state-build - path: .aspire/state/ + name: aspire-do-state-build + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' - name: Download state from test uses: actions/download-artifact@v4 with: - name: aspire-state-test - path: .aspire/state/ + name: aspire-do-state-test + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job deploy + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-deploy - path: .aspire/state/ + name: aspire-do-state-deploy + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' 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 index 96bcb88a3a3..923fc234a75 100644 --- 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 @@ -26,12 +26,12 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job build + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-build - path: .aspire/state/ + name: aspire-do-state-build + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' if-no-files-found: ignore deploy: @@ -50,15 +50,15 @@ jobs: - name: Download state from build uses: actions/download-artifact@v4 with: - name: aspire-state-build - path: .aspire/state/ + name: aspire-do-state-build + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do --continue --job deploy + run: aspire do - name: Upload state uses: actions/upload-artifact@v4 with: - name: aspire-state-deploy - path: .aspire/state/ + name: aspire-do-state-deploy + path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' 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 index 3dc7ed4dc6b..b56f675c7f3 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -27,7 +27,7 @@ public void Generate_BareWorkflow_CreatesDefaultJobWithBoilerplate() 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); + Assert.Contains(job.Steps, s => s.Run == "aspire do"); } [Fact] @@ -180,8 +180,8 @@ public void SerializeRoundTrip_ProducesValidYaml() 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("aspire do", yamlString); + Assert.DoesNotContain("--job", yamlString); Assert.Contains("actions/upload-artifact@v4", yamlString); Assert.Contains("actions/download-artifact@v4", yamlString); Assert.Contains("'Build & Publish'", yamlString); // Quoted because of & diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs new file mode 100644 index 00000000000..511f42b1869 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/ContinuationStateManagerTests.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 + +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Pipelines.Internal; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Pipelines; + +[Trait("Partition", "4")] +public class ContinuationStateManagerTests : IDisposable +{ + private readonly string _tempDir; + + public ContinuationStateManagerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"aspire-state-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + [Fact] + public async Task AcquireSection_EmptyDirectory_ReturnsEmptySection() + { + var manager = CreateManager("run-1", "build"); + + var section = await manager.AcquireSectionAsync("MySection"); + + Assert.NotNull(section); + Assert.Equal("MySection", section.SectionName); + Assert.Empty(section.Data); + } + + [Fact] + public async Task SaveAndAcquire_RoundTrips() + { + var manager = CreateManager("run-1", "build"); + + var section = await manager.AcquireSectionAsync("Config"); + section.Data["key1"] = JsonValue.Create("value1"); + await manager.SaveSectionAsync(section); + + var restored = await manager.AcquireSectionAsync("Config"); + Assert.Equal("value1", restored.Data["key1"]?.GetValue()); + } + + [Fact] + public async Task WritesOnlyToScopeFile() + { + var manager = CreateManager("run-1", "build"); + + var section = await manager.AcquireSectionAsync("Data"); + section.Data["x"] = JsonValue.Create("y"); + await manager.SaveSectionAsync(section); + + var expectedFile = Path.Combine(_tempDir, "run-1", "build.json"); + Assert.True(File.Exists(expectedFile)); + + // Only one file should exist in the run directory + var files = Directory.GetFiles(Path.Combine(_tempDir, "run-1"), "*.json"); + Assert.Single(files); + } + + [Fact] + public async Task MergesMultipleScopeFiles() + { + var runDir = Path.Combine(_tempDir, "run-1"); + Directory.CreateDirectory(runDir); + + // Write state from "build" scope + var buildState = new JsonObject { ["BuildOutput"] = new JsonObject { ["artifact"] = JsonValue.Create("app.zip") } }; + await File.WriteAllTextAsync( + Path.Combine(runDir, "build.json"), + JsonFlattener.FlattenJsonObject(buildState).ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + // Write state from "test" scope + var testState = new JsonObject { ["TestResults"] = new JsonObject { ["passed"] = JsonValue.Create("42") } }; + await File.WriteAllTextAsync( + Path.Combine(runDir, "test.json"), + JsonFlattener.FlattenJsonObject(testState).ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + // Now read as "deploy" scope — should see merged state + var deployManager = CreateManager("run-1", "deploy"); + + var buildSection = await deployManager.AcquireSectionAsync("BuildOutput"); + Assert.Equal("app.zip", buildSection.Data["artifact"]?.GetValue()); + + var testSection = await deployManager.AcquireSectionAsync("TestResults"); + Assert.Equal("42", testSection.Data["passed"]?.GetValue()); + } + + [Fact] + public async Task ConcurrentRunsDontInterfere() + { + // Run 1 writes "build" scope + var run1Manager = CreateManager("run-1", "build"); + var section1 = await run1Manager.AcquireSectionAsync("Data"); + section1.Data["from"] = JsonValue.Create("run1"); + await run1Manager.SaveSectionAsync(section1); + + // Run 2 writes "build" scope (same job name, different run) + var run2Manager = CreateManager("run-2", "build"); + var section2 = await run2Manager.AcquireSectionAsync("Data"); + section2.Data["from"] = JsonValue.Create("run2"); + await run2Manager.SaveSectionAsync(section2); + + // Verify isolation: each run's state is in its own directory + var run1File = Path.Combine(_tempDir, "run-1", "build.json"); + var run2File = Path.Combine(_tempDir, "run-2", "build.json"); + Assert.True(File.Exists(run1File)); + Assert.True(File.Exists(run2File)); + + // Read back run 1 — should see "run1" + var run1ReadManager = CreateManager("run-1", "deploy"); + var run1Section = await run1ReadManager.AcquireSectionAsync("Data"); + Assert.Equal("run1", run1Section.Data["from"]?.GetValue()); + + // Read back run 2 — should see "run2" + var run2ReadManager = CreateManager("run-2", "deploy"); + var run2Section = await run2ReadManager.AcquireSectionAsync("Data"); + Assert.Equal("run2", run2Section.Data["from"]?.GetValue()); + } + + [Fact] + public async Task DeleteSection_RemovesSectionFromState() + { + var manager = CreateManager("run-1", "build"); + + var section = await manager.AcquireSectionAsync("ToDelete"); + section.Data["key"] = JsonValue.Create("val"); + await manager.SaveSectionAsync(section); + + // Verify it's there + var check = await manager.AcquireSectionAsync("ToDelete"); + Assert.NotEmpty(check.Data); + + // Delete it + await manager.DeleteSectionAsync(check); + + // Re-read — should be empty + var afterDelete = await manager.AcquireSectionAsync("ToDelete"); + Assert.Empty(afterDelete.Data); + } + + [Fact] + public void StateFilePath_ReturnsWritePath() + { + var manager = CreateManager("run-42", "build"); + + Assert.Equal(Path.Combine(_tempDir, "run-42", "build.json"), manager.StateFilePath); + } + + private ContinuationStateManager CreateManager(string runId, string jobId) + { + return new ContinuationStateManager( + NullLogger.Instance, + new PipelineScopeResult { RunId = runId, JobId = jobId }, + _tempDir); + } +} diff --git a/tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.cs new file mode 100644 index 00000000000..1a2fcb64ac9 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineScopeAnnotationTests.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 + +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Tests.Pipelines; + +[Trait("Partition", "4")] +public class PipelineScopeAnnotationTests +{ + [Fact] + public async Task ResolveAsync_ReturnsNull_WhenNotInCI() + { + var annotation = new PipelineScopeAnnotation(_ => + { + // Simulate not being in a CI environment + return Task.FromResult(null); + }); + + var context = new PipelineScopeContext { CancellationToken = CancellationToken.None }; + var result = await annotation.ResolveAsync(context); + + Assert.Null(result); + } + + [Fact] + public async Task ResolveAsync_ReturnsScope_WhenInCI() + { + var annotation = new PipelineScopeAnnotation(_ => + { + return Task.FromResult(new PipelineScopeResult + { + RunId = "12345-1", + JobId = "build" + }); + }); + + var context = new PipelineScopeContext { CancellationToken = CancellationToken.None }; + var result = await annotation.ResolveAsync(context); + + Assert.NotNull(result); + Assert.Equal("12345-1", result.RunId); + Assert.Equal("build", result.JobId); + } + + [Fact] + public async Task ResolveAsync_GitHubActionsPattern_ComputesPredictableScope() + { + // Simulate the GitHub Actions pattern: GITHUB_RUN_ID + GITHUB_RUN_ATTEMPT + GITHUB_JOB + var annotation = CreateGitHubActionsScopeAnnotation( + runId: "98765", + runAttempt: "2", + jobId: "deploy"); + + var context = new PipelineScopeContext { CancellationToken = CancellationToken.None }; + var result = await annotation.ResolveAsync(context); + + Assert.NotNull(result); + Assert.Equal("98765-2", result.RunId); + Assert.Equal("deploy", result.JobId); + } + + [Fact] + public async Task ResolveAsync_GitHubActionsPattern_DefaultsRunAttemptTo1() + { + var annotation = CreateGitHubActionsScopeAnnotation( + runId: "12345", + runAttempt: null, + jobId: "build"); + + var context = new PipelineScopeContext { CancellationToken = CancellationToken.None }; + var result = await annotation.ResolveAsync(context); + + Assert.NotNull(result); + Assert.Equal("12345-1", result.RunId); + } + + [Fact] + public async Task ResolveAsync_GitHubActionsPattern_ReturnsNull_WhenRunIdMissing() + { + var annotation = CreateGitHubActionsScopeAnnotation( + runId: null, + runAttempt: "1", + jobId: "build"); + + var context = new PipelineScopeContext { CancellationToken = CancellationToken.None }; + var result = await annotation.ResolveAsync(context); + + Assert.Null(result); + } + + [Fact] + public void ScopeMapAnnotation_MapsJobIdsToSteps() + { + var map = new PipelineScopeMapAnnotation(new Dictionary> + { + ["build"] = ["compile", "unit-test"], + ["deploy"] = ["push-image", "deploy-app"] + }); + + Assert.Equal(2, map.ScopeToSteps.Count); + Assert.Equal(["compile", "unit-test"], map.ScopeToSteps["build"]); + Assert.Equal(["push-image", "deploy-app"], map.ScopeToSteps["deploy"]); + } + + [Fact] + public void ScopeMapAnnotation_EmptyMap_IsValid() + { + var map = new PipelineScopeMapAnnotation(new Dictionary>()); + + Assert.Empty(map.ScopeToSteps); + } + + /// + /// Simulates the GitHub Actions scope annotation pattern with injectable env var values. + /// + private static PipelineScopeAnnotation CreateGitHubActionsScopeAnnotation( + string? runId, string? runAttempt, string? jobId) + { + return new PipelineScopeAnnotation(_ => + { + if (string.IsNullOrEmpty(runId) || string.IsNullOrEmpty(jobId)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new PipelineScopeResult + { + RunId = $"{runId}-{runAttempt ?? "1"}", + JobId = jobId + }); + }); + } +} diff --git a/tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs new file mode 100644 index 00000000000..87d1b98cdf4 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/ScopeFilteringTests.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CS0618 // Type or member is obsolete +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES002 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECOMPUTE001 +#pragma warning disable ASPIRECOMPUTE003 + +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Tests.Pipelines; + +[Trait("Partition", "4")] +public class ScopeFilteringTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task ContinuationMode_InScopeStepsExecute_OutOfScopeSkipped() + { + using var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, step: null) + .WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + // Add a fake pipeline environment with scope annotation + var envResource = new FakePipelineEnvironment("test-env"); + envResource.Annotations.Add(new PipelineEnvironmentCheckAnnotation( + _ => Task.FromResult(true))); + envResource.Annotations.Add(new PipelineScopeAnnotation( + _ => Task.FromResult(new PipelineScopeResult + { + RunId = "run-1", + JobId = "deploy" + }))); + envResource.Annotations.Add(new PipelineScopeMapAnnotation( + new Dictionary> + { + ["build"] = ["step-build"], + ["deploy"] = ["step-deploy"] + })); + builder.AddResource(envResource); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var pipeline = new DistributedApplicationPipeline(model); + + var executedSteps = new List(); + + pipeline.AddStep(new PipelineStep + { + Name = "step-build", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-build"); } await Task.CompletedTask; } + }); + + pipeline.AddStep(new PipelineStep + { + Name = "step-deploy", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-deploy"); } await Task.CompletedTask; }, + DependsOnSteps = ["step-build"] + }); + + var context = CreateContext(app); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + // step-build is out-of-scope (belongs to "build" job), should be skipped + Assert.DoesNotContain("step-build", executedSteps); + // step-deploy is in-scope, should execute + Assert.Contains("step-deploy", executedSteps); + } + + [Fact] + public async Task ContinuationMode_OutOfScopeWithRestore_RestoresNotExecutes() + { + using var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, step: null) + .WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var envResource = new FakePipelineEnvironment("test-env"); + envResource.Annotations.Add(new PipelineEnvironmentCheckAnnotation( + _ => Task.FromResult(true))); + envResource.Annotations.Add(new PipelineScopeAnnotation( + _ => Task.FromResult(new PipelineScopeResult + { + RunId = "run-1", + JobId = "deploy" + }))); + envResource.Annotations.Add(new PipelineScopeMapAnnotation( + new Dictionary> + { + ["build"] = ["step-build"], + ["deploy"] = ["step-deploy"] + })); + builder.AddResource(envResource); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + var pipeline = new DistributedApplicationPipeline(model); + + var restoreCalled = false; + var actionCalled = false; + + pipeline.AddStep(new PipelineStep + { + Name = "step-build", + Action = async (_) => { actionCalled = true; await Task.CompletedTask; }, + TryRestoreStepAsync = _ => { restoreCalled = true; return Task.FromResult(true); } + }); + + pipeline.AddStep(new PipelineStep + { + Name = "step-deploy", + Action = _ => Task.CompletedTask, + DependsOnSteps = ["step-build"] + }); + + var context = CreateContext(app); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + // Restore should be called for the out-of-scope step + Assert.True(restoreCalled, "TryRestoreStepAsync should be called for out-of-scope steps"); + // But the action should not + Assert.False(actionCalled, "Action should not execute for out-of-scope steps"); + } + + [Fact] + public async Task NoScope_AllStepsExecute() + { + using var builder = TestDistributedApplicationBuilder.Create( + DistributedApplicationOperation.Publish, step: null) + .WithTestAndResourceLogging(testOutputHelper); + builder.Services.AddSingleton(testOutputHelper); + builder.Services.AddSingleton(); + + var app = builder.Build(); + // Parameterless constructor: no model → no environment → no scope → all steps execute + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep(new PipelineStep + { + Name = "step-build", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-build"); } await Task.CompletedTask; } + }); + + pipeline.AddStep(new PipelineStep + { + Name = "step-deploy", + Action = async (_) => { lock (executedSteps) { executedSteps.Add("step-deploy"); } await Task.CompletedTask; }, + DependsOnSteps = ["step-build"] + }); + + var context = CreateContext(app); + await pipeline.ExecuteAsync(context).DefaultTimeout(); + + // When no scope is detected, all steps execute normally + Assert.Contains("step-build", executedSteps); + Assert.Contains("step-deploy", executedSteps); + } + + private static PipelineContext CreateContext(DistributedApplication app) + { + return new PipelineContext( + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services, + app.Services.GetRequiredService>(), + CancellationToken.None); + } + + private sealed class FakePipelineEnvironment(string name) : Resource(name), IPipelineEnvironment + { + } +} From 378c6c2dcab81aad961066a20851e21411fdc015 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 21:14:27 +1100 Subject: [PATCH 21/39] Fix missing needs: in generated YAML and scheduling cycle for orphan steps Two issues fixed: 1. The pipeline-init step was passing only well-known steps (_steps) to the workflow generator, missing resource annotation steps that create the actual cross-job dependencies. Fixed by using _lastResolvedSteps which contains all steps after annotation collection and RequiredBy normalization. 2. The pull-based scheduling assigned orphan steps (no consumer chain to an explicit target) to the first available job, which could create spurious cross-job dependencies leading to cycles. For example, publish-prereq (orphan) went to publish-default but depended on process-parameters in deploy-default, while deploy-default already depended on publish-default. Fixed by adding Phase 2b: orphan steps are now co-located with their dependencies instead of the first available job. Uses iterative resolution to handle chains of orphan-to-orphan dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SchedulingResolver.cs | 68 ++++++++++++++++++- .../DistributedApplicationPipeline.cs | 2 +- .../SchedulingResolverTests.cs | 4 ++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs index 9518fb34195..8878e6edde7 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -60,6 +60,7 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub // Phase 2: resolve unscheduled steps by pulling them into the first consumer's target var stepToJob = new Dictionary(explicitStepToJob, StringComparer.Ordinal); + var orphanSteps = new List(); foreach (var step in steps) { @@ -71,7 +72,14 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub if (hasExplicitTargets) { var consumerJob = FindFirstConsumerJob(step.Name, reverseDeps, explicitStepToJob); - stepToJob[step.Name] = consumerJob ?? GetFirstAvailableJob(workflow); + if (consumerJob is not null) + { + stepToJob[step.Name] = consumerJob; + } + else + { + orphanSteps.Add(step); + } } else { @@ -79,6 +87,44 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub } } + // Phase 2b: resolve orphan steps (no consumer chain to an explicit target) by + // co-locating them with their dependencies. This avoids creating spurious cross-job + // dependencies that can introduce cycles in the job dependency graph. + // Iterate until stable to handle chains of orphan-to-orphan dependencies. + var remaining = orphanSteps; + while (remaining.Count > 0) + { + var unresolved = new List(); + var progress = false; + + foreach (var step in remaining) + { + var depJob = FindDependencyJob(step, stepToJob); + if (depJob is not null) + { + stepToJob[step.Name] = depJob; + progress = true; + } + else + { + unresolved.Add(step); + } + } + + if (!progress) + { + // No progress — assign remaining orphans to first available job + foreach (var step in unresolved) + { + stepToJob[step.Name] = GetFirstAvailableJob(workflow); + } + + break; + } + + remaining = unresolved; + } + // Build step lookup var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal); @@ -242,6 +288,26 @@ private static GitHubActionsJobResource ResolveExplicitTarget(PipelineStep step, return null; } + /// + /// Finds a job for an orphan step by looking at where its dependencies are assigned. + /// This co-locates orphan steps with their dependencies to avoid creating cross-job + /// dependencies that could introduce cycles in the job dependency graph. + /// + private static GitHubActionsJobResource? FindDependencyJob( + PipelineStep step, + Dictionary stepToJob) + { + foreach (var depName in step.DependsOnSteps) + { + if (stepToJob.TryGetValue(depName, out var job)) + { + return job; + } + } + + return null; + } + /// /// Gets the first available job on the workflow for orphan unscheduled steps /// (steps with no downstream consumer that has explicit scheduling). diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index a212964a3bd..f9d92628f8c 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -1431,7 +1431,7 @@ private async Task ExecutePipelineInitAsync(PipelineStepContext context) { StepContext = context, Environment = env, - Steps = _steps, + Steps = _lastResolvedSteps ?? _steps, RepositoryRootDirectory = repoRoot, }; diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs index fcdbb88e650..41ac7e40eb7 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs @@ -398,6 +398,10 @@ public void Resolve_ExplicitStages_NoDefaultStageCreated() // No default stage should have been created Assert.DoesNotContain(workflow.Stages, s => s.Name == "default"); + + // deploy-default job should depend on publish-default job + Assert.True(result.JobDependencies.TryGetValue("deploy-default", out var deployDeps), "deploy-default should have job dependencies"); + Assert.Contains("publish-default", deployDeps); } [Fact] From 314116a12a7576d2f27fb89a37adac93649731ea Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 21:47:02 +1100 Subject: [PATCH 22/39] Add heuristic job setup steps, channel-aware CLI install, and YAML customization - Add WellKnownDependencyTags class (requires-dotnet, requires-nodejs, requires-docker, requires-azure-cli, requires-aspire-cli) that declare what tooling a pipeline step needs on the CI machine - Tag existing step creation sites: ProjectResource and Container build/push steps get requires-dotnet/requires-docker, Azure steps get requires-azure-cli - WorkflowYamlGenerator now scans dependency tags per job and conditionally emits setup steps (setup-dotnet, setup-node, azure/login) only when needed - Channel-aware Aspire CLI install: reads aspire.config.json for channel (stable/preview/dev/daily) and emits curl|bash with quality arg - Make WorkflowYaml/JobYaml/StepYaml/etc public with XML docs and [Experimental] attributes for developer customization - Add ConfigureWorkflow extension method on IResourceBuilder with WorkflowCustomizationAnnotation for post-generation YAML mutation - Add 14 new tests: tag collection, conditional emission, channel install, ConfigureWorkflow callback tests - Update 4 snapshot tests for new install command format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppResource.cs | 2 +- .../AzureContainerRegistryResource.cs | 2 +- .../AzureBicepResource.cs | 2 +- .../AzureEnvironmentResource.cs | 4 +- .../GitHubActionsWorkflowExtensions.cs | 40 ++- .../WorkflowCustomizationAnnotation.cs | 21 ++ .../WorkflowYamlGenerator.cs | 140 ++++++-- .../Yaml/WorkflowYaml.cs | 92 +++++- .../Yaml/WorkflowYamlSerializer.cs | 2 + .../ApplicationModel/ProjectResource.cs | 4 +- .../ContainerResourceBuilderExtensions.cs | 4 +- .../Pipelines/WellKnownDependencyTags.cs | 44 +++ ...BareWorkflow_SingleDefaultJob.verified.txt | 2 +- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 2 +- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 6 +- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 4 +- .../WorkflowYamlGeneratorTests.cs | 312 +++++++++++++++++- 17 files changed, 631 insertions(+), 52 deletions(-) create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.cs create mode 100644 src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs index 97de4cb0ee9..360c434d5af 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs @@ -71,7 +71,7 @@ public AzureContainerAppResource(string name, Action Task.CompletedTask, - Tags = [WellKnownPipelineTags.DeployCompute] + Tags = [WellKnownPipelineTags.DeployCompute, WellKnownDependencyTags.AzureCli] }; deployStep.DependsOn(printResourceSummary); diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs index f1bda8bf8a8..b6572c42f9b 100644 --- a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs @@ -30,7 +30,7 @@ public AzureContainerRegistryResource(string name, Action AzureContainerRegistryHelpers.LoginToRegistryAsync(this, context), - Tags = ["acr-login"], + Tags = ["acr-login", WellKnownDependencyTags.AzureCli, WellKnownDependencyTags.Docker], RequiredBySteps = [WellKnownPipelineSteps.PushPrereq], Resource = this }; diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index b944b8711db..431e769f972 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -51,7 +51,7 @@ public AzureBicepResource(string name, string? templateFile = null, string? temp Name = $"provision-{name}", Description = $"Provisions the Azure Bicep resource {name} using Azure infrastructure.", Action = async ctx => await ProvisionAzureBicepResourceAsync(ctx, this).ConfigureAwait(false), - Tags = [WellKnownPipelineTags.ProvisionInfrastructure] + Tags = [WellKnownPipelineTags.ProvisionInfrastructure, WellKnownDependencyTags.AzureCli] }; provisionStep.RequiredBy(AzureEnvironmentResource.ProvisionInfrastructureStepName); provisionStep.DependsOn(AzureEnvironmentResource.CreateProvisioningContextStepName); diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 9211021b1b5..45f635a4c92 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -82,6 +82,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Name = "validate-azure-login", Description = "Validates Azure CLI authentication before deployment.", Action = ctx => ValidateAzureLoginAsync(ctx), + Tags = [WellKnownDependencyTags.AzureCli], RequiredBySteps = [WellKnownPipelineSteps.Deploy], DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq] }; @@ -99,6 +100,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet // Add Azure deployment information to the pipeline summary AddToPipelineSummary(ctx, provisioningContext); }, + Tags = [WellKnownDependencyTags.AzureCli], RequiredBySteps = [WellKnownPipelineSteps.Deploy], DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq] }; @@ -109,7 +111,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Name = ProvisionInfrastructureStepName, Description = "Aggregation step for all Azure infrastructure provisioning operations.", Action = _ => Task.CompletedTask, - Tags = [WellKnownPipelineTags.ProvisionInfrastructure], + Tags = [WellKnownPipelineTags.ProvisionInfrastructure, WellKnownDependencyTags.AzureCli], RequiredBySteps = [WellKnownPipelineSteps.Deploy], DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq] }; diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index afbbff7052d..dd69de976f3 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -72,7 +72,13 @@ public static IResourceBuilder AddGitHubActionsWo workflow.Annotations.Add(new PipelineScopeMapAnnotation(scopeToSteps)); // Generate the YAML model - var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow, context.RepositoryRootDirectory); + + // Apply user customization callbacks + foreach (var customization in workflow.Annotations.OfType()) + { + customization.Callback(yamlModel); + } // Serialize to YAML string var yamlContent = WorkflowYamlSerializer.Serialize(yamlModel); @@ -125,4 +131,36 @@ public static GitHubActionsJobResource AddJob( return builder.Resource.AddJob(id); } + + /// + /// Registers a callback to customize the generated model + /// before it is serialized to disk. Multiple callbacks can be registered and will be + /// invoked in registration order. + /// + /// The workflow resource builder. + /// A callback that receives the model for mutation. + /// The resource builder for chaining. + /// + /// + /// var workflow = builder.AddGitHubActionsWorkflow("deploy"); + /// workflow.ConfigureWorkflow(yaml => + /// { + /// foreach (var job in yaml.Jobs.Values) + /// { + /// job.Env ??= new(); + /// job.Env["MY_SECRET"] = "${{ secrets.MY_SECRET }}"; + /// } + /// }); + /// + /// + [AspireExportIgnore(Reason = "Pipeline generation is not yet ATS-compatible")] + public static IResourceBuilder ConfigureWorkflow( + this IResourceBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + return builder.WithAnnotation(new WorkflowCustomizationAnnotation(configure)); + } } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.cs new file mode 100644 index 00000000000..4320edcebd7 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowCustomizationAnnotation.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREPIPELINES001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines.GitHubActions.Yaml; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Annotation that stores a callback for customizing the generated +/// before it is serialized to disk. +/// +internal sealed class WorkflowCustomizationAnnotation(Action callback) : IResourceAnnotation +{ + /// + /// Gets the customization callback. + /// + public Action Callback { get; } = callback ?? throw new ArgumentNullException(nameof(callback)); +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index 42ccc69ff47..da116735b89 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 +using System.Text.Json; using Aspire.Hosting.Pipelines.GitHubActions.Yaml; namespace Aspire.Hosting.Pipelines.GitHubActions; @@ -18,11 +19,13 @@ internal static class WorkflowYamlGenerator /// /// Generates a workflow YAML model from the scheduling result. /// - public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow) + public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, string? repositoryRootDirectory = null) { ArgumentNullException.ThrowIfNull(scheduling); ArgumentNullException.ThrowIfNull(workflow); + var channel = ReadChannelFromConfig(repositoryRootDirectory); + var workflowYaml = new WorkflowYaml { Name = workflow.Name, @@ -44,41 +47,90 @@ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWo // Generate a YAML job for each workflow job foreach (var job in workflow.Jobs) { - var jobYaml = GenerateJob(job, scheduling); + var jobYaml = GenerateJob(job, scheduling, channel); workflowYaml.Jobs[job.Id] = jobYaml; } return workflowYaml; } - private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling) + private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, string? channel) { + // Collect dependency tags from all pipeline steps assigned to this job + var dependencyTags = new HashSet(StringComparer.Ordinal); + if (scheduling.StepsPerJob.TryGetValue(job.Id, out var pipelineSteps)) + { + foreach (var step in pipelineSteps) + { + foreach (var tag in step.Tags) + { + dependencyTags.Add(tag); + } + } + } + + // Every job runs `aspire do`, which implicitly requires the Aspire CLI + dependencyTags.Add(WellKnownDependencyTags.AspireCli); + var steps = new List(); - // Boilerplate: checkout + // Always: checkout steps.Add(new StepYaml { Name = "Checkout code", Uses = "actions/checkout@v4" }); - // Boilerplate: setup .NET - steps.Add(new StepYaml + // Conditional: Setup .NET (when any step needs .NET or Aspire CLI, which is .NET-based) + if (dependencyTags.Contains(WellKnownDependencyTags.DotNet) || + dependencyTags.Contains(WellKnownDependencyTags.AspireCli)) { - Name = "Setup .NET", - Uses = "actions/setup-dotnet@v4", - With = new Dictionary + steps.Add(new StepYaml { - ["dotnet-version"] = "10.0.x" - } - }); + Name = "Setup .NET", + Uses = "actions/setup-dotnet@v4", + With = new Dictionary + { + ["dotnet-version"] = "10.0.x" + } + }); + } - // Boilerplate: install Aspire CLI - steps.Add(new StepYaml + // Conditional: Setup Node.js (when any step needs Node.js) + if (dependencyTags.Contains(WellKnownDependencyTags.NodeJs)) { - Name = "Install Aspire CLI", - Run = "dotnet tool install -g aspire" - }); + steps.Add(new StepYaml + { + Name = "Setup Node.js", + Uses = "actions/setup-node@v4", + With = new Dictionary + { + ["node-version"] = "20" + } + }); + } + + // Conditional: Install Aspire CLI (when any step needs it — always true since aspire do runs) + if (dependencyTags.Contains(WellKnownDependencyTags.AspireCli)) + { + steps.Add(GenerateAspireCliInstallStep(channel)); + } + + // Conditional: Azure login (when any step needs Azure CLI) + if (dependencyTags.Contains(WellKnownDependencyTags.AzureCli)) + { + steps.Add(new StepYaml + { + Name = "Azure login", + Uses = "azure/login@v2", + With = new Dictionary + { + ["client-id"] = "${{ vars.AZURE_CLIENT_ID }}", + ["tenant-id"] = "${{ vars.AZURE_TENANT_ID }}", + ["subscription-id"] = "${{ vars.AZURE_SUBSCRIPTION_ID }}" + } + }); + } // Download state artifacts from dependency jobs var jobDeps = scheduling.JobDependencies.GetValueOrDefault(job.Id); @@ -99,9 +151,6 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul } } - // 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 — scope is auto-detected from GITHUB_JOB env var steps.Add(new StepYaml { @@ -141,4 +190,55 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul Steps = steps }; } + + private static StepYaml GenerateAspireCliInstallStep(string? channel) + { + // Determine install command based on channel + var installCommand = channel?.ToLowerInvariant() switch + { + "preview" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality preview", + "dev" or "daily" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality daily", + "staging" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality staging", + // stable or unspecified — use the default install script which gets stable + _ => "curl -sSL https://aka.ms/install-aspire.sh | bash" + }; + + return new StepYaml + { + Name = "Install Aspire CLI", + Run = installCommand + }; + } + + private static string? ReadChannelFromConfig(string? repositoryRootDirectory) + { + if (string.IsNullOrEmpty(repositoryRootDirectory)) + { + return null; + } + + var configPath = Path.Combine(repositoryRootDirectory, "aspire.config.json"); + if (!File.Exists(configPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(configPath); + using var doc = JsonDocument.Parse(stream); + + if (doc.RootElement.TryGetProperty("channel", out var channelProp) && + channelProp.ValueKind == JsonValueKind.String) + { + return channelProp.GetString(); + } + } + catch (JsonException) + { + // Malformed config — fall back to default + } + + return null; + } } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs index f8b336a0737..1eb9a8c512c 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs @@ -1,88 +1,168 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting.Pipelines.GitHubActions.Yaml; /// /// Represents a complete GitHub Actions workflow YAML document. /// -internal sealed class WorkflowYaml +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class WorkflowYaml { + /// + /// Gets the name of the workflow. + /// public required string Name { get; init; } + /// + /// Gets the trigger configuration for the workflow. + /// public WorkflowTriggers On { get; init; } = new(); + /// + /// Gets the top-level permissions for the workflow. + /// public Dictionary? Permissions { get; init; } + /// + /// Gets the jobs defined in the workflow, keyed by job ID. + /// public Dictionary Jobs { get; init; } = new(StringComparer.Ordinal); } /// /// Represents the trigger configuration for a workflow. /// -internal sealed class WorkflowTriggers +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class WorkflowTriggers { + /// + /// Gets a value indicating whether the workflow can be manually triggered. + /// public bool WorkflowDispatch { get; init; } = true; + /// + /// Gets the push trigger configuration. + /// public PushTrigger? Push { get; init; } } /// /// Represents the push trigger configuration. /// -internal sealed class PushTrigger +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PushTrigger { + /// + /// Gets the branch patterns that trigger the workflow on push. + /// public List Branches { get; init; } = []; } /// /// Represents a job in the workflow. /// -internal sealed class JobYaml +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class JobYaml { + /// + /// Gets the display name of the job. + /// public string? Name { get; init; } + /// + /// Gets the runner label for the job (e.g., "ubuntu-latest"). + /// public string RunsOn { get; init; } = "ubuntu-latest"; + /// + /// Gets the conditional expression controlling whether the job runs. + /// public string? If { get; init; } + /// + /// Gets the GitHub deployment environment name for the job. + /// public string? Environment { get; init; } + /// + /// Gets the list of job IDs that this job depends on. + /// public List? Needs { get; init; } + /// + /// Gets the permissions granted to the job. + /// public Dictionary? Permissions { get; init; } + /// + /// Gets the environment variables for the job. + /// public Dictionary? Env { get; init; } + /// + /// Gets the concurrency configuration for the job. + /// public ConcurrencyYaml? Concurrency { get; init; } + /// + /// Gets the steps in the job. + /// public List Steps { get; init; } = []; } /// /// Represents a step within a job. /// -internal sealed class StepYaml +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class StepYaml { + /// + /// Gets the display name of the step. + /// public string? Name { get; init; } + /// + /// Gets the action reference (e.g., "actions/checkout@v4"). + /// public string? Uses { get; init; } + /// + /// Gets the shell command to run. + /// public string? Run { get; init; } + /// + /// Gets the input parameters passed to the action. + /// public Dictionary? With { get; init; } + /// + /// Gets the environment variables for the step. + /// public Dictionary? Env { get; init; } + /// + /// Gets the step identifier for referencing in expressions. + /// public string? Id { get; init; } } /// /// Represents concurrency configuration for a job. /// -internal sealed class ConcurrencyYaml +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class ConcurrencyYaml { + /// + /// Gets the concurrency group name. + /// public required string Group { get; init; } + /// + /// Gets a value indicating whether to cancel in-progress runs in the same concurrency group. + /// public bool CancelInProgress { get; init; } } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs index 52a0d2f64fd..bc29d97acea 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPIPELINES001 + using System.Globalization; using System.Text; diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 1e960005d69..e143d4b9775 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -45,7 +45,7 @@ public ProjectResource(string name) : base(name) Name = $"build-{name}", Description = $"Builds the container image for the {name} project.", Action = BuildProjectImage, - Tags = [WellKnownPipelineTags.BuildCompute], + Tags = [WellKnownPipelineTags.BuildCompute, WellKnownDependencyTags.DotNet, WellKnownDependencyTags.Docker], RequiredBySteps = [WellKnownPipelineSteps.Build], DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq], Resource = this @@ -58,7 +58,7 @@ public ProjectResource(string name) : base(name) { Name = $"push-{name}", Action = ctx => PipelineStepHelpers.PushImageToRegistryAsync(this, ctx), - Tags = [WellKnownPipelineTags.PushContainerImage], + Tags = [WellKnownPipelineTags.PushContainerImage, WellKnownDependencyTags.Docker], RequiredBySteps = [WellKnownPipelineSteps.Push], Resource = this }; diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs index 54b059c81c1..2032c5d7221 100644 --- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs @@ -49,7 +49,7 @@ await containerImageBuilder.BuildImageAsync( builder.Resource, ctx.CancellationToken).ConfigureAwait(false); }, - Tags = [WellKnownPipelineTags.BuildCompute], + Tags = [WellKnownPipelineTags.BuildCompute, WellKnownDependencyTags.Docker], RequiredBySteps = [WellKnownPipelineSteps.Build], DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq], Resource = builder.Resource @@ -63,7 +63,7 @@ await containerImageBuilder.BuildImageAsync( { Name = $"push-{builder.Resource.Name}", Action = ctx => PipelineStepHelpers.PushImageToRegistryAsync(builder.Resource, ctx), - Tags = [WellKnownPipelineTags.PushContainerImage], + Tags = [WellKnownPipelineTags.PushContainerImage, WellKnownDependencyTags.Docker], RequiredBySteps = [WellKnownPipelineSteps.Push], Resource = builder.Resource }; diff --git a/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs b/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs new file mode 100644 index 00000000000..9963ae3ca12 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/WellKnownDependencyTags.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Defines well-known dependency tags that declare what tooling a pipeline step requires +/// on the CI machine. Pipeline integrations use these tags to heuristically determine +/// which setup steps (e.g., "Setup .NET", "Setup Node.js") to emit in the generated workflow. +/// +/// +/// These tags are orthogonal to , which categorize +/// what a step does. Dependency tags describe what a step needs to run. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public static class WellKnownDependencyTags +{ + /// + /// Indicates the step requires the .NET SDK to be installed on the CI machine. + /// + public const string DotNet = "requires-dotnet"; + + /// + /// Indicates the step requires Node.js to be installed on the CI machine. + /// + public const string NodeJs = "requires-nodejs"; + + /// + /// Indicates the step requires Docker to be available on the CI machine. + /// + public const string Docker = "requires-docker"; + + /// + /// Indicates the step requires the Azure CLI to be available on the CI machine. + /// + public const string AzureCli = "requires-azure-cli"; + + /// + /// Indicates the step requires the Aspire CLI to be installed on the CI machine. + /// + public const string AspireCli = "requires-aspire-cli"; +} diff --git a/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 index 8ade43ee606..ed9d3f147a2 100644 --- 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 @@ -21,7 +21,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index 171b5320dd6..b43eda74e3a 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index bf430024f26..a24e7cd0103 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -46,7 +46,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: @@ -75,7 +75,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: 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 index 923fc234a75..f3496c9a7a6 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -46,7 +46,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: dotnet tool install -g aspire + run: curl -sSL https://aka.ms/install-aspire.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index b56f675c7f3..0e022af633c 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -59,7 +59,7 @@ public void Generate_MultipleJobDeps_NeedsContainsAll() var step1 = CreateStep("build-app", job1); var step2 = CreateStep("run-tests", job2); - var step3 = CreateStep("deploy-app", job3, ["build-app", "run-tests"]); + var step3 = CreateStep("deploy-app", job3, "build-app", "run-tests"); var scheduling = SchedulingResolver.Resolve([step1, step2, step3], workflow); var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); @@ -189,35 +189,327 @@ public void SerializeRoundTrip_ProducesValidYaml() // Helpers - private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null, string[]? tags = null) { return new PipelineStep { Name = name, Action = _ => Task.CompletedTask, - ScheduledBy = scheduledBy + ScheduledBy = scheduledBy, + Tags = tags is not null ? [.. tags] : [] }; } - private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn) + // Heuristic step emission tests + + [Fact] + public void Generate_StepWithDotNetTag_EmitsSetupDotNet() { - return new PipelineStep + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.Contains(job.Steps, s => s.Name == "Setup .NET" && s.Uses == "actions/setup-dotnet@v4"); + } + + [Fact] + public void Generate_StepWithNodeJsTag_EmitsSetupNode() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-ts", tags: [WellKnownDependencyTags.NodeJs]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.Contains(job.Steps, s => s.Name == "Setup Node.js" && s.Uses == "actions/setup-node@v4"); + } + + [Fact] + public void Generate_StepWithAzureCliTag_EmitsAzureLogin() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("provision-infra", tags: [WellKnownDependencyTags.AzureCli]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.Contains(job.Steps, s => s.Name == "Azure login" && s.Uses == "azure/login@v2"); + } + + [Fact] + public void Generate_StepWithoutNodeJsTag_DoesNotEmitSetupNode() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.DoesNotContain(job.Steps, s => s.Name == "Setup Node.js"); + } + + [Fact] + public void Generate_StepWithoutAzureCliTag_DoesNotEmitAzureLogin() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app", tags: [WellKnownDependencyTags.DotNet]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.DoesNotContain(job.Steps, s => s.Name == "Azure login"); + } + + [Fact] + public void Generate_MultipleTagsOnStep_EmitsAllSetupSteps() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("deploy-all", tags: [WellKnownDependencyTags.DotNet, WellKnownDependencyTags.NodeJs, WellKnownDependencyTags.AzureCli]); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + var job = yaml.Jobs["default"]; + Assert.Contains(job.Steps, s => s.Name == "Setup .NET"); + Assert.Contains(job.Steps, s => s.Name == "Setup Node.js"); + Assert.Contains(job.Steps, s => s.Name == "Azure login"); + Assert.Contains(job.Steps, s => s.Name == "Install Aspire CLI"); + } + + [Fact] + public void Generate_TagsAcrossJobs_IndependentSetupSteps() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var buildJob = workflow.AddJob("build"); + var deployJob = workflow.AddJob("deploy"); + + var buildStep = CreateStep("build-app", buildJob, tags: [WellKnownDependencyTags.DotNet, WellKnownDependencyTags.Docker]); + var deployStep = new PipelineStep { - Name = name, + Name = "deploy-app", Action = _ => Task.CompletedTask, - DependsOnSteps = [dependsOn], - ScheduledBy = scheduledBy + DependsOnSteps = ["build-app"], + ScheduledBy = deployJob, + Tags = [WellKnownDependencyTags.AzureCli] }; + + var scheduling = SchedulingResolver.Resolve([buildStep, deployStep], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); + + // Build job: has .NET setup but NOT Azure login + var buildJobYaml = yaml.Jobs["build"]; + Assert.Contains(buildJobYaml.Steps, s => s.Name == "Setup .NET"); + Assert.DoesNotContain(buildJobYaml.Steps, s => s.Name == "Azure login"); + + // Deploy job: has Azure login but does NOT need .NET for its own steps + // (it still gets .NET because aspire do needs it) + var deployJobYaml = yaml.Jobs["deploy"]; + Assert.Contains(deployJobYaml.Steps, s => s.Name == "Azure login"); } - private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string[] dependsOn) + // Channel-aware CLI install tests + + [Fact] + public void Generate_NoConfig_UsesDefaultInstallScript() { + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, repositoryRootDirectory: null); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Equal("curl -sSL https://aka.ms/install-aspire.sh | bash", installStep.Run); + } + + [Fact] + public void Generate_PreviewChannel_UsesPreviewQuality() + { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "preview"}"""); + + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Contains("--quality preview", installStep.Run); + } + + [Fact] + public void Generate_DailyChannel_UsesDailyQuality() + { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "daily"}"""); + + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Contains("--quality daily", installStep.Run); + } + + [Fact] + public void Generate_StableChannel_UsesDefaultInstallScript() + { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "stable"}"""); + + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Equal("curl -sSL https://aka.ms/install-aspire.sh | bash", installStep.Run); + } + + // ConfigureWorkflow callback tests + + [Fact] + public void ConfigureWorkflow_CallbackModifiesYaml() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml => + { + foreach (var job in yaml.Jobs.Values) + { + job.Steps.Insert(0, new StepYaml + { + Name = "Custom step", + Run = "echo 'hello'" + }); + } + })); + + var step = CreateStep("build-app"); + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + + // Apply customization (simulating what the extension does) + foreach (var customization in workflow.Annotations.OfType()) + { + customization.Callback(yamlModel); + } + + var job = yamlModel.Jobs["default"]; + Assert.Equal("Custom step", job.Steps[0].Name); + } + + [Fact] + public void ConfigureWorkflow_MultipleCallbacks_AppliedInOrder() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml => + { + foreach (var job in yaml.Jobs.Values) + { + job.Steps.Add(new StepYaml { Name = "First callback" }); + } + })); + workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml => + { + foreach (var job in yaml.Jobs.Values) + { + job.Steps.Add(new StepYaml { Name = "Second callback" }); + } + })); + + var step = CreateStep("build-app"); + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + + foreach (var customization in workflow.Annotations.OfType()) + { + customization.Callback(yamlModel); + } + + var job = yamlModel.Jobs["default"]; + var lastTwo = job.Steps.TakeLast(2).ToArray(); + Assert.Equal("First callback", lastTwo[0].Name); + Assert.Equal("Second callback", lastTwo[1].Name); + } + + [Fact] + public void ConfigureWorkflow_CanAddEnvVarsToAllJobs() + { + var workflow = new GitHubActionsWorkflowResource("deploy"); + workflow.Annotations.Add(new WorkflowCustomizationAnnotation(yaml => + { + foreach (var job in yaml.Jobs.Values) + { + job.Steps.Add(new StepYaml + { + Name = "Secret step", + Env = new Dictionary + { + ["MY_SECRET"] = "${{ secrets.MY_SECRET }}" + }, + Run = "echo $MY_SECRET" + }); + } + })); + + var step = CreateStep("build-app"); + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); + + foreach (var customization in workflow.Annotations.OfType()) + { + customization.Callback(yamlModel); + } + + var job = yamlModel.Jobs["default"]; + var secretStep = Assert.Single(job.Steps, s => s.Name == "Secret step"); + Assert.Contains("MY_SECRET", secretStep.Env!.Keys); + } + + // Helper for creating steps with tags and dependsOn + private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy, string dependsOn, params string[] moreDependsOn) + { + var deps = new List { dependsOn }; + deps.AddRange(moreDependsOn); return new PipelineStep { Name = name, Action = _ => Task.CompletedTask, - DependsOnSteps = [.. dependsOn], + DependsOnSteps = deps, ScheduledBy = scheduledBy }; } + + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + + public TempDirectory() + { + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } } From 5d4240093903eaf9489ba3679c1f3bf90bfb5f62 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 22:02:00 +1100 Subject: [PATCH 23/39] Fix CLI install: use aspire.dev/install.sh with correct -q flags - URL: aspire.dev/install.sh (not aka.ms/install-aspire.sh) - Quality flags: -q dev (daily channel), -q staging (staging channel) - Unknown channels (e.g. 'preview') fall back to default (stable) - Remove unused PrInstallScriptUrl constant (PR install is future work) - Add staging channel test, rename preview test to unknown channel test - Update 4 snapshots with correct URL Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkflowYamlGenerator.cs | 17 +++++++---- ...BareWorkflow_SingleDefaultJob.verified.txt | 2 +- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 2 +- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 6 ++-- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 4 +-- .../WorkflowYamlGeneratorTests.cs | 28 +++++++++++++++---- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index da116735b89..b1d39c103fb 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -191,16 +191,21 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul }; } + private const string InstallScriptUrl = "https://aspire.dev/install.sh"; + private static StepYaml GenerateAspireCliInstallStep(string? channel) { - // Determine install command based on channel + // aspire.config.json channel values map to install script -q args: + // "stable" / "default" / null → no -q (default = release/stable) + // "staging" → -q staging + // "daily" → -q dev + // PR builds use a completely different script (get-aspire-cli-pr.sh) var installCommand = channel?.ToLowerInvariant() switch { - "preview" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality preview", - "dev" or "daily" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality daily", - "staging" => "curl -sSL https://aka.ms/install-aspire.sh | bash -s -- --quality staging", - // stable or unspecified — use the default install script which gets stable - _ => "curl -sSL https://aka.ms/install-aspire.sh | bash" + "daily" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev", + "staging" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q staging", + // stable, default, or unspecified — use default quality (release) + _ => $"curl -sSL {InstallScriptUrl} | bash" }; return new StepYaml 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 index ed9d3f147a2..fb9750c2ce6 100644 --- 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 @@ -21,7 +21,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index b43eda74e3a..acf008ce654 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index a24e7cd0103..350b3c81464 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -46,7 +46,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: @@ -75,7 +75,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: 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 index f3496c9a7a6..a04b394d4d8 100644 --- 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 @@ -22,7 +22,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -46,7 +46,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aka.ms/install-aspire.sh | bash + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 0e022af633c..11f9b9c6bfa 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -327,11 +327,11 @@ public void Generate_NoConfig_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Equal("curl -sSL https://aka.ms/install-aspire.sh | bash", installStep.Run); + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] - public void Generate_PreviewChannel_UsesPreviewQuality() + public void Generate_UnknownChannel_FallsBackToDefault() { using var tempDir = new TempDirectory(); File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "preview"}"""); @@ -344,7 +344,8 @@ public void Generate_PreviewChannel_UsesPreviewQuality() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains("--quality preview", installStep.Run); + // "preview" is not a recognized channel — falls through to default (stable) + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] @@ -361,7 +362,7 @@ public void Generate_DailyChannel_UsesDailyQuality() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains("--quality daily", installStep.Run); + Assert.Contains("-q dev", installStep.Run); } [Fact] @@ -378,7 +379,24 @@ public void Generate_StableChannel_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Equal("curl -sSL https://aka.ms/install-aspire.sh | bash", installStep.Run); + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + } + + [Fact] + public void Generate_StagingChannel_UsesStagingQuality() + { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "staging"}"""); + + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Contains("-q staging", installStep.Run); } // ConfigureWorkflow callback tests From b18c301bccd92c9361677b16c2494dff2de9f4c2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 29 Mar 2026 23:07:29 +1100 Subject: [PATCH 24/39] Fix aspire do step arg, add PR trigger and PR-aware CLI install - Compute terminal steps per job in SchedulingResolver and emit 'aspire do ' instead of bare 'aspire do' which requires a step argument - Add pull_request trigger to generated YAML (targeting main) - Add PullRequestTrigger to YAML model and serializer - Make CLI install step PR-aware: on pull_request events, use get-aspire-cli-pr.sh with PR number; on push/manual, use channel-based install from aspire.dev/install.sh - Update 4 snapshot tests and 4 unit tests for new behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SchedulingResolver.cs | 37 ++++++++++++++++++ .../WorkflowYamlGenerator.cs | 39 +++++++++++++++---- .../Yaml/WorkflowYaml.cs | 17 ++++++++ .../Yaml/WorkflowYamlSerializer.cs | 13 +++++++ ...BareWorkflow_SingleDefaultJob.verified.txt | 12 +++++- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 12 +++++- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 30 +++++++++++--- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 21 ++++++++-- .../WorkflowYamlGeneratorTests.cs | 15 ++++--- 9 files changed, 169 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs index 8878e6edde7..a231a425cd4 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/SchedulingResolver.cs @@ -189,6 +189,36 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub list.Add(step); } + // Compute terminal steps per job: steps that no other step in the same job depends on. + // These are the "leaf" steps whose transitive closure covers all steps in the job. + var terminalStepsPerJob = new Dictionary>(StringComparer.Ordinal); + foreach (var (jobId, jobSteps) in stepsPerJob) + { + var jobStepNames = new HashSet(jobSteps.Select(s => s.Name), StringComparer.Ordinal); + + // A step is a dependency within this job if any other step in the same job depends on it + var intraJobDependencies = new HashSet(StringComparer.Ordinal); + foreach (var step in jobSteps) + { + foreach (var dep in step.DependsOnSteps) + { + if (jobStepNames.Contains(dep)) + { + intraJobDependencies.Add(dep); + } + } + } + + // Terminal steps are those NOT depended on by any other step in the same job + var terminals = jobSteps + .Where(s => !intraJobDependencies.Contains(s.Name)) + .Select(s => s.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + terminalStepsPerJob[jobId] = terminals.Count > 0 ? terminals : [jobSteps[^1].Name]; + } + // The default job is whatever was auto-created during resolution (if any) GitHubActionsJobResource? defaultJob = null; for (var i = 0; i < workflow.Jobs.Count; i++) @@ -212,6 +242,7 @@ public static SchedulingResult Resolve(IReadOnlyList steps, GitHub kvp => kvp.Key, kvp => (IReadOnlyList)kvp.Value, StringComparer.Ordinal), + TerminalStepsPerJob = terminalStepsPerJob, DefaultJob = defaultJob }; } @@ -419,6 +450,12 @@ internal sealed class SchedulingResult /// public required Dictionary> StepsPerJob { get; init; } + /// + /// Gets the terminal step names per job. Terminal steps are those not depended on by any other + /// step in the same job — executing them via aspire do covers all steps in the job. + /// + public required Dictionary> TerminalStepsPerJob { get; init; } + /// /// Gets the default job used for unscheduled steps, or null if all steps were explicitly scheduled. /// diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index b1d39c103fb..a8ff9680cb5 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -33,6 +33,10 @@ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWo { WorkflowDispatch = true, Push = new PushTrigger + { + Branches = ["main"] + }, + PullRequest = new PullRequestTrigger { Branches = ["main"] } @@ -151,11 +155,19 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul } } - // Run aspire do — scope is auto-detected from GITHUB_JOB env var + // Run aspire do — target the terminal step(s) for this job so all assigned steps execute + var terminalSteps = scheduling.TerminalStepsPerJob.GetValueOrDefault(job.Id); + var aspireDoCommand = terminalSteps switch + { + null or { Count: 0 } => "aspire do deploy", + { Count: 1 } => $"aspire do {terminalSteps[0]}", + _ => string.Join(" && ", terminalSteps.Select(s => $"aspire do {s}")) + }; + steps.Add(new StepYaml { Name = "Run pipeline steps", - Run = "aspire do", + Run = aspireDoCommand, Env = new Dictionary { ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" @@ -192,22 +204,33 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul } private const string InstallScriptUrl = "https://aspire.dev/install.sh"; + private const string PrInstallScriptUrl = "https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh"; private static StepYaml GenerateAspireCliInstallStep(string? channel) { + // For PR builds, use the PR-specific install script that downloads artifacts from the CI run. + // For push/manual builds, use the standard install script with the appropriate quality channel. + // // aspire.config.json channel values map to install script -q args: // "stable" / "default" / null → no -q (default = release/stable) // "staging" → -q staging // "daily" → -q dev - // PR builds use a completely different script (get-aspire-cli-pr.sh) - var installCommand = channel?.ToLowerInvariant() switch + var channelInstallCommand = channel?.ToLowerInvariant() switch { - "daily" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev", - "staging" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q staging", - // stable, default, or unspecified — use default quality (release) - _ => $"curl -sSL {InstallScriptUrl} | bash" + "daily" => "curl -sSL " + InstallScriptUrl + " | bash -s -- -q dev", + "staging" => "curl -sSL " + InstallScriptUrl + " | bash -s -- -q staging", + _ => "curl -sSL " + InstallScriptUrl + " | bash" }; + // Use a conditional script: on pull_request events, install the PR build; + // otherwise install from the configured channel. + var installCommand = + "if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n" + + " curl -sSL " + PrInstallScriptUrl + " | bash -s -- ${{ github.event.pull_request.number }}\n" + + "else\n" + + " " + channelInstallCommand + "\n" + + "fi"; + return new StepYaml { Name = "Install Aspire CLI", diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs index 1eb9a8c512c..1955d71c400 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYaml.cs @@ -47,6 +47,11 @@ public sealed class WorkflowTriggers /// Gets the push trigger configuration. /// public PushTrigger? Push { get; init; } + + /// + /// Gets the pull request trigger configuration. + /// + public PullRequestTrigger? PullRequest { get; init; } } /// @@ -61,6 +66,18 @@ public sealed class PushTrigger public List Branches { get; init; } = []; } +/// +/// Represents the pull request trigger configuration. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PullRequestTrigger +{ + /// + /// Gets the branch patterns that trigger the workflow on pull request. + /// + public List Branches { get; init; } = []; +} + /// /// Represents a job in the workflow. /// diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs index bc29d97acea..1a2d248cdda 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs @@ -71,6 +71,19 @@ private static void WriteOn(StringBuilder sb, WorkflowTriggers triggers) } } } + + if (triggers.PullRequest is not null) + { + sb.AppendLine(" pull_request:"); + if (triggers.PullRequest.Branches.Count > 0) + { + sb.AppendLine(" branches:"); + foreach (var branch in triggers.PullRequest.Branches) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" - {branch}"); + } + } + } } private static void WriteJob(StringBuilder sb, string jobId, JobYaml job) 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 index fb9750c2ce6..5a06251cc9f 100644 --- 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 @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read @@ -21,11 +24,16 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do build-app - name: Upload state uses: actions/upload-artifact@v4 with: 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 index acf008ce654..5e21d88cd1e 100644 --- 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 @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read @@ -22,11 +25,16 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do build-app - name: Upload state uses: actions/upload-artifact@v4 with: 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 index 350b3c81464..cdd236b3b7e 100644 --- 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 @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read @@ -22,11 +25,16 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do build-app - name: Upload state uses: actions/upload-artifact@v4 with: @@ -46,7 +54,12 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Download state from build uses: actions/download-artifact@v4 with: @@ -55,7 +68,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do run-tests - name: Upload state uses: actions/upload-artifact@v4 with: @@ -75,7 +88,12 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Download state from build uses: actions/download-artifact@v4 with: @@ -89,7 +107,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do deploy-app - name: Upload state uses: actions/upload-artifact@v4 with: 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 index a04b394d4d8..1a9363b3a6b 100644 --- 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 @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read @@ -22,11 +25,16 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do build-app - name: Upload state uses: actions/upload-artifact@v4 with: @@ -46,7 +54,12 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} + else + curl -sSL https://aspire.dev/install.sh | bash + fi - name: Download state from build uses: actions/download-artifact@v4 with: @@ -55,7 +68,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do + run: aspire do deploy-app - name: Upload state uses: actions/upload-artifact@v4 with: diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 11f9b9c6bfa..28731309483 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -27,7 +27,7 @@ public void Generate_BareWorkflow_CreatesDefaultJobWithBoilerplate() 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 == "aspire do"); + Assert.Contains(job.Steps, s => s.Run is not null && s.Run.Contains("aspire do build-app")); } [Fact] @@ -327,7 +327,9 @@ public void Generate_NoConfig_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + // PR branch uses get-aspire-cli-pr.sh, else branch uses default install + Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] @@ -344,8 +346,9 @@ public void Generate_UnknownChannel_FallsBackToDefault() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // "preview" is not a recognized channel — falls through to default (stable) - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + // "preview" is not a recognized channel — else branch uses default (stable) install + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.DoesNotContain("-q ", installStep.Run!.Split('\n').First(l => l.Contains("aspire.dev"))); } [Fact] @@ -379,7 +382,9 @@ public void Generate_StableChannel_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + // Stable channel: else branch uses default install (no -q flag) + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.DoesNotContain("-q ", installStep.Run!.Split('\n').First(l => l.Contains("aspire.dev"))); } [Fact] From 89897513e1e51169e16ebca727106d079c855fde Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 09:47:17 +1100 Subject: [PATCH 25/39] Add synthetic job target steps and channel-based CLI install - Add ExistingSteps property to PipelineStepFactoryContext so annotation factories can see previously collected pipeline steps - Use two-pass step collection: non-pipeline-environment resources first, pipeline environments second (full visibility of all steps) - Register PipelineStepAnnotation on workflow resource that creates one synthetic no-op step per job (e.g., gha-deploy-default-stage-default-job) depending on the terminal steps for that job - YAML generator emits aspire do per job instead of chaining multiple aspire do commands with && - Remove github.event_name conditional from CLI install step; instead parse channel from aspire.config.json (pr- uses PR script, daily=-q dev, staging=-q staging, else default stable install) - Include synthetic steps in scope map for continuation mode - Move scope map creation from generator callback to annotation factory so it's available during both init and aspire do Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 80 +++++++++++++++++-- .../WorkflowYamlGenerator.cs | 75 ++++++++++------- .../DistributedApplicationPipeline.cs | 36 ++++++++- .../Pipelines/PipelineStepFactoryContext.cs | 7 ++ ...BareWorkflow_SingleDefaultJob.verified.txt | 9 +-- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 9 +-- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 27 ++----- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 18 +---- .../WorkflowYamlGeneratorTests.cs | 35 +++++--- 9 files changed, 196 insertions(+), 100 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index dd69de976f3..945c5eb3ed7 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -57,20 +57,66 @@ public static IResourceBuilder AddGitHubActionsWo }); })); + resource.Annotations.Add(new PipelineStepAnnotation(context => + { + var workflow = (GitHubActionsWorkflowResource)context.Resource; + var existingSteps = context.ExistingSteps; + + if (existingSteps.Count == 0) + { + return []; + } + + // Run the scheduler to compute job assignments and terminal steps + var scheduling = SchedulingResolver.Resolve(existingSteps.ToList(), workflow); + + // Register scope map so the executor can filter steps in continuation mode. + // This must happen here (not in the generator callback) so it's available + // during both `aspire pipeline init` and `aspire do` executions. + var scopeToSteps = new Dictionary>(StringComparer.Ordinal); + + // Create a synthetic step per job that depends on the terminal steps for that job + var syntheticSteps = new List(); + foreach (var job in workflow.Jobs) + { + var stageName = FindStageName(workflow, job); + var stepName = $"gha-{workflow.Name}-{stageName}-stage-{job.Id}-job"; + + var terminalSteps = scheduling.TerminalStepsPerJob.GetValueOrDefault(job.Id); + + var syntheticStep = new PipelineStep + { + Name = stepName, + Description = $"Scheduling target for job '{job.Id}' in workflow '{workflow.Name}'", + Action = _ => Task.CompletedTask, + DependsOnSteps = terminalSteps?.ToList() ?? [], + ScheduledBy = job + }; + + syntheticSteps.Add(syntheticStep); + + // Build scope map entry: include all real steps + the synthetic step + var jobStepNames = scheduling.StepsPerJob.TryGetValue(job.Id, out var jobSteps) + ? jobSteps.Select(s => s.Name).ToList() + : []; + jobStepNames.Add(stepName); + scopeToSteps[job.Id] = jobStepNames; + } + + workflow.Annotations.Add(new PipelineScopeMapAnnotation(scopeToSteps)); + + return syntheticSteps; + })); + resource.Annotations.Add(new PipelineWorkflowGeneratorAnnotation(async context => { var workflow = (GitHubActionsWorkflowResource)context.Environment; var logger = context.StepContext.Logger; - // Resolve scheduling (which steps run in which jobs) + // Resolve scheduling (which steps run in which jobs). + // Note: scope map is already registered by the PipelineStepAnnotation factory above. var scheduling = SchedulingResolver.Resolve(context.Steps.ToList(), workflow); - // Register scope map so the executor can filter steps in continuation mode - var scopeToSteps = scheduling.StepsPerJob.ToDictionary( - kvp => kvp.Key, - kvp => (IReadOnlyList)kvp.Value.Select(s => s.Name).ToList()); - workflow.Annotations.Add(new PipelineScopeMapAnnotation(scopeToSteps)); - // Generate the YAML model var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow, context.RepositoryRootDirectory); @@ -163,4 +209,24 @@ public static IResourceBuilder ConfigureWorkflow( return builder.WithAnnotation(new WorkflowCustomizationAnnotation(configure)); } + + /// + /// Finds the stage name that contains the specified job, or "default" if the job + /// is not part of any explicit stage. + /// + private static string FindStageName(GitHubActionsWorkflowResource workflow, GitHubActionsJobResource job) + { + foreach (var stage in workflow.Stages) + { + for (var i = 0; i < stage.Jobs.Count; i++) + { + if (stage.Jobs[i].Id == job.Id) + { + return stage.Name; + } + } + } + + return "default"; + } } diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index a8ff9680cb5..f1c9d6e8200 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -51,14 +51,14 @@ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWo // Generate a YAML job for each workflow job foreach (var job in workflow.Jobs) { - var jobYaml = GenerateJob(job, scheduling, channel); + var jobYaml = GenerateJob(job, scheduling, workflow, channel); workflowYaml.Jobs[job.Id] = jobYaml; } return workflowYaml; } - private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, string? channel) + private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, string? channel) { // Collect dependency tags from all pipeline steps assigned to this job var dependencyTags = new HashSet(StringComparer.Ordinal); @@ -155,19 +155,16 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul } } - // Run aspire do — target the terminal step(s) for this job so all assigned steps execute - var terminalSteps = scheduling.TerminalStepsPerJob.GetValueOrDefault(job.Id); - var aspireDoCommand = terminalSteps switch - { - null or { Count: 0 } => "aspire do deploy", - { Count: 1 } => $"aspire do {terminalSteps[0]}", - _ => string.Join(" && ", terminalSteps.Select(s => $"aspire do {s}")) - }; + // Run aspire do targeting the synthetic scheduling step for this job. + // The synthetic step depends on the terminal steps, so its transitive closure + // covers all steps assigned to this job. + var stageName = FindStageName(workflow, job); + var syntheticStepName = $"gha-{workflow.Name}-{stageName}-stage-{job.Id}-job"; steps.Add(new StepYaml { Name = "Run pipeline steps", - Run = aspireDoCommand, + Run = $"aspire do {syntheticStepName}", Env = new Dictionary { ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" @@ -208,28 +205,28 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul private static StepYaml GenerateAspireCliInstallStep(string? channel) { - // For PR builds, use the PR-specific install script that downloads artifacts from the CI run. - // For push/manual builds, use the standard install script with the appropriate quality channel. - // - // aspire.config.json channel values map to install script -q args: - // "stable" / "default" / null → no -q (default = release/stable) - // "staging" → -q staging - // "daily" → -q dev - var channelInstallCommand = channel?.ToLowerInvariant() switch + // Check for PR channel: "pr-" — use the PR-specific install script + if (channel is not null && + channel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) && + int.TryParse(channel.AsSpan(3), out var prNumber)) { - "daily" => "curl -sSL " + InstallScriptUrl + " | bash -s -- -q dev", - "staging" => "curl -sSL " + InstallScriptUrl + " | bash -s -- -q staging", - _ => "curl -sSL " + InstallScriptUrl + " | bash" - }; + return new StepYaml + { + Name = "Install Aspire CLI", + Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}" + }; + } - // Use a conditional script: on pull_request events, install the PR build; - // otherwise install from the configured channel. - var installCommand = - "if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n" + - " curl -sSL " + PrInstallScriptUrl + " | bash -s -- ${{ github.event.pull_request.number }}\n" + - "else\n" + - " " + channelInstallCommand + "\n" + - "fi"; + // Standard channel install via aspire.dev/install.sh with quality flag: + // "daily" → -q dev + // "staging" → -q staging + // anything else (stable/default/null) → no flag + var installCommand = channel?.ToLowerInvariant() switch + { + "daily" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev", + "staging" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q staging", + _ => $"curl -sSL {InstallScriptUrl} | bash" + }; return new StepYaml { @@ -269,4 +266,20 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) return null; } + + private static string FindStageName(GitHubActionsWorkflowResource workflow, GitHubActionsJobResource job) + { + foreach (var stage in workflow.Stages) + { + for (var i = 0; i < stage.Jobs.Count; i++) + { + if (stage.Jobs[i].Id == job.Id) + { + return stage.Name; + } + } + } + + return "default"; + } } diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index f9d92628f8c..149f936688d 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -443,7 +443,7 @@ public async Task GetEnvironmentAsync(CancellationToken ca public async Task ExecuteAsync(PipelineContext context) { - var annotationSteps = await CollectStepsFromAnnotationsAsync(context).ConfigureAwait(false); + var annotationSteps = await CollectStepsFromAnnotationsAsync(context, _steps).ConfigureAwait(false); var allSteps = _steps.Concat(annotationSteps).ToList(); // Execute configuration callbacks even if there are no steps @@ -628,10 +628,12 @@ void Visit(string stepName) return result; } - private static async Task> CollectStepsFromAnnotationsAsync(PipelineContext context) + private static async Task> CollectStepsFromAnnotationsAsync(PipelineContext context, IReadOnlyList existingSteps) { var steps = new List(); + var deferredAnnotations = new List<(IResource Resource, PipelineStepAnnotation Annotation)>(); + // First pass: collect steps from non-pipeline-environment resources foreach (var resource in context.Model.Resources) { var annotations = resource.Annotations @@ -639,10 +641,18 @@ private static async Task> CollectStepsFromAnnotationsAsync(P foreach (var annotation in annotations) { + if (resource is IPipelineEnvironment) + { + // Defer pipeline environment annotations — they need visibility of all collected steps + deferredAnnotations.Add((resource, annotation)); + continue; + } + var factoryContext = new PipelineStepFactoryContext { PipelineContext = context, - Resource = resource + Resource = resource, + ExistingSteps = existingSteps }; var annotationSteps = await annotation.CreateStepsAsync(factoryContext).ConfigureAwait(false); @@ -654,6 +664,26 @@ private static async Task> CollectStepsFromAnnotationsAsync(P } } + // Second pass: pipeline environment annotations get full visibility of all collected steps. + // This enables workflow resources to run scheduling and create synthetic steps. + foreach (var (resource, annotation) in deferredAnnotations) + { + var allStepsSoFar = existingSteps.Concat(steps).ToList(); + var factoryContext = new PipelineStepFactoryContext + { + PipelineContext = context, + Resource = resource, + ExistingSteps = allStepsSoFar + }; + + var annotationSteps = await annotation.CreateStepsAsync(factoryContext).ConfigureAwait(false); + foreach (var step in annotationSteps) + { + steps.Add(step); + step.Resource ??= resource; + } + } + return steps; } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs b/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs index d76fd535c3a..869f1a127f8 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepFactoryContext.cs @@ -22,4 +22,11 @@ public class PipelineStepFactoryContext /// Gets the resource that this factory is associated with. /// public required IResource Resource { get; init; } + + /// + /// Gets the pipeline steps that were directly added to the pipeline (not from annotations). + /// This allows annotation factories to see the existing steps and create synthetic steps + /// that depend on them. + /// + public IReadOnlyList ExistingSteps { get; init; } = []; } diff --git a/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 index 5a06251cc9f..4d7bf7708a7 100644 --- 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 @@ -24,16 +24,11 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do build-app + run: aspire do gha-deploy-default-stage-default-job - name: Upload state uses: actions/upload-artifact@v4 with: 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 index 5e21d88cd1e..2b2f5d73b31 100644 --- 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 @@ -25,16 +25,11 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do build-app + run: aspire do gha-build-windows-default-stage-build-win-job - name: Upload state uses: actions/upload-artifact@v4 with: 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 index cdd236b3b7e..dc327f289cc 100644 --- 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 @@ -25,16 +25,11 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do build-app + run: aspire do gha-ci-cd-default-stage-build-job - name: Upload state uses: actions/upload-artifact@v4 with: @@ -54,12 +49,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: @@ -68,7 +58,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do run-tests + run: aspire do gha-ci-cd-default-stage-test-job - name: Upload state uses: actions/upload-artifact@v4 with: @@ -88,12 +78,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: @@ -107,7 +92,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do deploy-app + run: aspire do gha-ci-cd-default-stage-deploy-job - name: Upload state uses: actions/upload-artifact@v4 with: 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 index 1a9363b3a6b..958b613fd83 100644 --- 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 @@ -25,16 +25,11 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do build-app + run: aspire do gha-deploy-default-stage-build-job - name: Upload state uses: actions/upload-artifact@v4 with: @@ -54,12 +49,7 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - curl -sSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- ${{ github.event.pull_request.number }} - else - curl -sSL https://aspire.dev/install.sh | bash - fi + run: curl -sSL https://aspire.dev/install.sh | bash - name: Download state from build uses: actions/download-artifact@v4 with: @@ -68,7 +58,7 @@ jobs: - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - run: aspire do deploy-app + run: aspire do gha-deploy-default-stage-deploy-job - name: Upload state uses: actions/upload-artifact@v4 with: diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 28731309483..0fe53a218e4 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -27,7 +27,7 @@ public void Generate_BareWorkflow_CreatesDefaultJobWithBoilerplate() Assert.Contains(job.Steps, s => s.Name == "Checkout code"); Assert.Contains(job.Steps, s => s.Name == "Setup .NET"); Assert.Contains(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains(job.Steps, s => s.Run is not null && s.Run.Contains("aspire do build-app")); + Assert.Contains(job.Steps, s => s.Run is not null && s.Run.Contains("aspire do gha-deploy-default-stage-default-job")); } [Fact] @@ -327,9 +327,8 @@ public void Generate_NoConfig_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // PR branch uses get-aspire-cli-pr.sh, else branch uses default install - Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); - Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + // No config → default (stable) channel install + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] @@ -346,9 +345,8 @@ public void Generate_UnknownChannel_FallsBackToDefault() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // "preview" is not a recognized channel — else branch uses default (stable) install - Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); - Assert.DoesNotContain("-q ", installStep.Run!.Split('\n').First(l => l.Contains("aspire.dev"))); + // "preview" is not a recognized channel — falls back to default (stable) install + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] @@ -382,9 +380,8 @@ public void Generate_StableChannel_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // Stable channel: else branch uses default install (no -q flag) - Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); - Assert.DoesNotContain("-q ", installStep.Run!.Split('\n').First(l => l.Contains("aspire.dev"))); + // Stable channel: default install (no -q flag) + Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); } [Fact] @@ -404,6 +401,24 @@ public void Generate_StagingChannel_UsesStagingQuality() Assert.Contains("-q staging", installStep.Run); } + [Fact] + public void Generate_PrChannel_UsesPrInstallScript() + { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "pr-15643"}"""); + + var workflow = new GitHubActionsWorkflowResource("deploy"); + var step = CreateStep("build-app"); + + var scheduling = SchedulingResolver.Resolve([step], workflow); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + + var job = yaml.Jobs["default"]; + var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); + Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); + Assert.Contains("15643", installStep.Run); + } + // ConfigureWorkflow callback tests [Fact] From 732f7c226a89905856c09d0f1f5f662e593714f7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 12:10:50 +1100 Subject: [PATCH 26/39] Filter orphaned steps from CI scheduling Steps like 'publish-manifest', 'diagnostics', and 'pipeline-init' are standalone CLI tools with no connection to the deployment DAG. Including them in the CI scheduler made them terminal steps in the job, causing the synthetic step to depend on them. This led to 'aspire do' executing publish-manifest which fails because --output-path is not provided in CI. Fix: FilterConnectedSteps() excludes steps with no DependsOnSteps, no RequiredBySteps, not depended upon by other steps, and no explicit scheduling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index 945c5eb3ed7..441d7e02121 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -67,8 +67,18 @@ public static IResourceBuilder AddGitHubActionsWo return []; } + // Filter out orphaned steps that have no connection to the deployment graph. + // Steps like "publish-manifest", "diagnostics", and "pipeline-init" are + // standalone tools for the CLI, not part of the deployment DAG. + var connectedSteps = FilterConnectedSteps(existingSteps); + + if (connectedSteps.Count == 0) + { + return []; + } + // Run the scheduler to compute job assignments and terminal steps - var scheduling = SchedulingResolver.Resolve(existingSteps.ToList(), workflow); + var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow); // Register scope map so the executor can filter steps in continuation mode. // This must happen here (not in the generator callback) so it's available @@ -114,8 +124,11 @@ public static IResourceBuilder AddGitHubActionsWo var logger = context.StepContext.Logger; // Resolve scheduling (which steps run in which jobs). + // Filter out orphaned steps (same as the annotation factory) so the YAML + // generator only includes steps that are part of the deployment graph. // Note: scope map is already registered by the PipelineStepAnnotation factory above. - var scheduling = SchedulingResolver.Resolve(context.Steps.ToList(), workflow); + var connectedSteps = FilterConnectedSteps(context.Steps); + var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow); // Generate the YAML model var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow, context.RepositoryRootDirectory); @@ -229,4 +242,45 @@ private static string FindStageName(GitHubActionsWorkflowResource workflow, GitH return "default"; } + + /// + /// Filters out orphaned steps that have no connection to the deployment graph. + /// A step is "connected" if it has dependencies, is depended upon by other steps, + /// or has explicit scheduling. Steps like "publish-manifest", "diagnostics", and + /// "pipeline-init" are standalone CLI tools and should not be scheduled into CI jobs. + /// + private static List FilterConnectedSteps(IReadOnlyList steps) + { + // Build set of step names that are depended upon + var dependedUpon = new HashSet(StringComparer.Ordinal); + foreach (var step in steps) + { + foreach (var dep in step.DependsOnSteps) + { + dependedUpon.Add(dep); + } + foreach (var req in step.RequiredBySteps) + { + dependedUpon.Add(req); + } + } + + var connected = new List(); + foreach (var step in steps) + { + // A step is connected if: + // - It depends on other steps + // - Other steps depend on it (via DependsOn or RequiredBy) + // - It has explicit scheduling (ScheduledBy is set) + if (step.DependsOnSteps.Count > 0 || + step.RequiredBySteps.Count > 0 || + dependedUpon.Contains(step.Name) || + step.ScheduledBy is not null) + { + connected.Add(step); + } + } + + return connected; + } } From fb07c4149a0d9708d01116852aa3d7c852cd7092 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 14:41:33 +1100 Subject: [PATCH 27/39] Use %HOME% env var for hive paths in NuGet.config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NuGet.config supports %HOME% environment variable expansion cross-platform. Use it for hive package source paths instead of hardcoded absolute paths so the config is portable across machines (local dev → CI runner). Also use descriptive key names (aspire-hive-pr-NNNNN) instead of the path as the packageSources key, eliminating the key mismatch bug where packageSources and packageSourceMapping keys could diverge on different machines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Packaging/NuGetConfigMerger.cs | 26 ++++++++++++++++--- src/Aspire.Cli/Packaging/PackageMapping.cs | 8 +++++- src/Aspire.Cli/Packaging/PackagingService.cs | 10 ++++--- .../Packaging/TemporaryNuGetConfig.cs | 21 ++++++++++++--- ...s_ProducesExpectedXml.pr-1234.verified.xml | 7 +++-- ...e_ProducesExpectedXml.pr-1234.verified.xml | 7 +++-- ...d_ProducesExpectedXml.pr-1234.verified.xml | 7 +++-- ...d_ProducesExpectedXml.pr-1234.verified.xml | 7 +++-- ...g_ProducesExpectedXml.pr-1234.verified.xml | 6 ++--- 9 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs index 618a0972cf8..0b54f24ae02 100644 --- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs +++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs @@ -210,6 +210,16 @@ private static void AddMissingPackageSources(NuGetConfigContext context) var existingKeys = new HashSet(context.ExistingAdds .Select(e => (string?)e.Attribute("key") ?? string.Empty), StringComparer.OrdinalIgnoreCase); + // Build a lookup from source URL to friendly key name from the mappings + var sourceToFriendlyKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var mapping in context.Mappings) + { + if (mapping.Key is not null && !sourceToFriendlyKey.ContainsKey(mapping.Source)) + { + sourceToFriendlyKey[mapping.Source] = mapping.Key; + } + } + var missingSources = context.RequiredSources .Where(s => !existingValues.Contains(s) && !existingKeys.Contains(s)) .ToArray(); @@ -217,11 +227,15 @@ private static void AddMissingPackageSources(NuGetConfigContext context) // Add missing sources foreach (var source in missingSources) { - // Use the source URL as both key and value for consistency with our temporary config + // Use the friendly key from PackageMapping if available, otherwise fall back to source URL + var keyName = sourceToFriendlyKey.TryGetValue(source, out var friendlyKey) ? friendlyKey : source; var add = new XElement("add"); - add.SetAttributeValue("key", source); + add.SetAttributeValue("key", keyName); add.SetAttributeValue("value", source); context.PackageSources.Add(add); + + // Update the urlToExistingKey map so downstream methods can find the key + context.UrlToExistingKey[source] = keyName; } } @@ -657,9 +671,15 @@ private static bool IsSourceSafeToRemove(string sourceKey, string? sourceValue) return false; } + // Check if the key follows the aspire-hive-* naming convention + if (sourceKey.StartsWith("aspire-hive-", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + var urlToCheck = sourceValue ?? sourceKey; - // Check if this is an Aspire PR hive + // Check if this is an Aspire PR hive (absolute path or %HOME%-relative path) if (!string.IsNullOrEmpty(urlToCheck) && urlToCheck.Contains(".aspire") && urlToCheck.Contains("hives")) { return true; diff --git a/src/Aspire.Cli/Packaging/PackageMapping.cs b/src/Aspire.Cli/Packaging/PackageMapping.cs index 4c49a912d12..fcf070052f9 100644 --- a/src/Aspire.Cli/Packaging/PackageMapping.cs +++ b/src/Aspire.Cli/Packaging/PackageMapping.cs @@ -3,9 +3,15 @@ namespace Aspire.Cli.Packaging; -internal class PackageMapping(string PackageFilter, string source) +internal class PackageMapping(string PackageFilter, string source, string? key = null) { public const string AllPackages = "*"; public string PackageFilter { get; } = PackageFilter; public string Source { get; } = source; + + /// + /// Optional friendly key name for this source in NuGet.config (e.g., "aspire-hive-pr-15643"). + /// When set, used as the <add key="..." /> attribute instead of the source URL/path. + /// + public string? Key { get; } = key; } \ No newline at end of file diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index ddb0b6dae7a..5cb0407b840 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -41,12 +41,14 @@ public Task> GetChannelsAsync(CancellationToken canc var prHives = executionContext.HivesDirectory.GetDirectories(); foreach (var prHive in prHives) { - // The packages subdirectory contains the actual .nupkg files - // Use forward slashes for cross-platform NuGet config compatibility - var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/'); + // Use %HOME% environment variable instead of the absolute path so that the + // NuGet.config is portable across machines (e.g., local dev → CI runner). + // NuGet expands %HOME% at runtime on all platforms. + var packagesPath = $"%HOME%/.aspire/hives/{prHive.Name}/packages"; + var sourceKey = $"aspire-hive-{prHive.Name}"; var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Prerelease, new[] { - new PackageMapping("Aspire*", packagesPath), + new PackageMapping("Aspire*", packagesPath, key: sourceKey), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") }, nuGetPackageCache); diff --git a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs index 9ac09517be1..8caa49aa2cd 100644 --- a/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs +++ b/src/Aspire.Cli/Packaging/TemporaryNuGetConfig.cs @@ -38,10 +38,23 @@ public static async Task GenerateAsync(PackageMapping[] mappings, string targetP private static async Task GenerateNuGetConfigAsync(PackageMapping[] mappings, FileInfo configFile) { - var distinctSources = mappings - .Select(m => m.Source) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select((source, index) => new { Source = source, Key = source }) + // Build distinct sources with their preferred key names. + // If any mapping for a source specifies a Key, use that; otherwise fall back to the source URL. + var sourceKeyLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var m in mappings) + { + if (!sourceKeyLookup.ContainsKey(m.Source)) + { + sourceKeyLookup[m.Source] = m.Key ?? m.Source; + } + else if (m.Key is not null) + { + sourceKeyLookup[m.Source] = m.Key; + } + } + + var distinctSources = sourceKeyLookup + .Select(kvp => new { Source = kvp.Key, Key = kvp.Value }) .ToArray(); await using var fileStream = configFile.Create(); diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml index 13162c35237..04afc3bc74f 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + @@ -13,9 +13,8 @@ - + - diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml index d73c31e907d..9efc878b35f 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml @@ -1,16 +1,15 @@ - + - + - + - \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml index e978e326b2e..69961fcf30e 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml @@ -1,18 +1,17 @@ - + - + - + - diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml index 3695a23bf34..7a9ba2a1c33 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml @@ -1,17 +1,16 @@ - + - + - + - diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml index 8afa43a77c9..2c712572df3 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + - + From dd755cecd2a02944a07c3d74c777924b7b8580d5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 15:00:36 +1100 Subject: [PATCH 28/39] Add GH_TOKEN env var to PR CLI install step When the channel is a PR channel (pr-), the generated GitHub Actions workflow step for installing the Aspire CLI now includes GH_TOKEN: ${{ github.token }} in its env block. This is required for the get-aspire-cli-pr.sh script which uses the GitHub CLI (gh) to download the PR build artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkflowYamlGenerator.cs | 6 +++++- .../WorkflowYamlGeneratorTests.cs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index f1c9d6e8200..2491c2f3eb2 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -213,7 +213,11 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) return new StepYaml { Name = "Install Aspire CLI", - Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}" + Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}", + Env = new Dictionary + { + ["GH_TOKEN"] = "${{ github.token }}" + } }; } diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 0fe53a218e4..77754192b0e 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -417,6 +417,8 @@ public void Generate_PrChannel_UsesPrInstallScript() var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); Assert.Contains("15643", installStep.Run); + Assert.NotNull(installStep.Env); + Assert.Equal("${{ github.token }}", installStep.Env["GH_TOKEN"]); } // ConfigureWorkflow callback tests From 1d385e929f3f7357cbf5970d6533f215af66ffb2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 15:54:19 +1100 Subject: [PATCH 29/39] Add GITHUB_PATH step to Aspire CLI install for cross-step visibility Append `echo "\$HOME/.aspire/bin" >> \$GITHUB_PATH` after the curl install command in all channels. This ensures the aspire CLI is on PATH for all subsequent workflow steps in GitHub Actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkflowYamlGenerator.cs | 6 ++++-- ...tTests.BareWorkflow_SingleDefaultJob.verified.txt | 4 +++- ...napshotTests.CustomRunsOn_WindowsJob.verified.txt | 4 +++- ...hotTests.ThreeJobDiamond_FanOutAndIn.verified.txt | 12 +++++++++--- ...tTests.TwoJobPipeline_BuildAndDeploy.verified.txt | 8 ++++++-- .../WorkflowYamlGeneratorTests.cs | 10 +++++++--- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index 2491c2f3eb2..b168381eb6d 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -205,6 +205,8 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul private static StepYaml GenerateAspireCliInstallStep(string? channel) { + const string addToPath = """echo "$HOME/.aspire/bin" >> $GITHUB_PATH"""; + // Check for PR channel: "pr-" — use the PR-specific install script if (channel is not null && channel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) && @@ -213,7 +215,7 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) return new StepYaml { Name = "Install Aspire CLI", - Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}", + Run = $"curl -sSL {PrInstallScriptUrl} | bash -s -- {prNumber}\n{addToPath}", Env = new Dictionary { ["GH_TOKEN"] = "${{ github.token }}" @@ -235,7 +237,7 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) return new StepYaml { Name = "Install Aspire CLI", - Run = installCommand + Run = $"{installCommand}\n{addToPath}" }; } 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 index 4d7bf7708a7..07d9ca0c6fc 100644 --- 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 @@ -24,7 +24,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index 2b2f5d73b31..eb757134160 100644 --- 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 @@ -25,7 +25,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 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 index dc327f289cc..7728cbe3dec 100644 --- 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 @@ -25,7 +25,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -49,7 +51,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 with: @@ -78,7 +82,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 with: 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 index 958b613fd83..7696b8cd1cb 100644 --- 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 @@ -25,7 +25,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -49,7 +51,9 @@ jobs: with: dotnet-version: 10.0.x - name: Install Aspire CLI - run: curl -sSL https://aspire.dev/install.sh | bash + run: | + curl -sSL https://aspire.dev/install.sh | bash + echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 with: diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 77754192b0e..90ed7ca4126 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -328,7 +328,8 @@ public void Generate_NoConfig_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); // No config → default (stable) channel install - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); } [Fact] @@ -346,7 +347,8 @@ public void Generate_UnknownChannel_FallsBackToDefault() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); // "preview" is not a recognized channel — falls back to default (stable) install - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); } [Fact] @@ -381,7 +383,8 @@ public void Generate_StableChannel_UsesDefaultInstallScript() var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); // Stable channel: default install (no -q flag) - Assert.Equal("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); + Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); } [Fact] @@ -417,6 +420,7 @@ public void Generate_PrChannel_UsesPrInstallScript() var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); Assert.Contains("15643", installStep.Run); + Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); Assert.NotNull(installStep.Env); Assert.Equal("${{ github.token }}", installStep.Env["GH_TOKEN"]); } From cec96eea2365b5a7ed2ea1a8512f043cfbeebc2d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 16:36:35 +1100 Subject: [PATCH 30/39] Detect PR build from assembly version instead of aspire.config.json Replace the config-file-based channel detection with assembly version introspection. The AssemblyInformationalVersionAttribute contains "-pr.{number}" for PR builds (set by ci.yml VersionSuffix), so the pipeline generator is now self-aware about its build origin. - PR build (version contains -pr.NNNNN): emits PR install script + GH_TOKEN - Prerelease build (any - suffix): emits install with -q dev quality - Stable build (no prerelease): emits default install Removes dependency on aspire.config.json for CLI install decisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 2 +- .../WorkflowYamlGenerator.cs | 95 ++++++++------- ...BareWorkflow_SingleDefaultJob.verified.txt | 2 +- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 2 +- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 6 +- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 4 +- .../WorkflowYamlGeneratorTests.cs | 114 +++--------------- 7 files changed, 82 insertions(+), 143 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index 441d7e02121..aaf93fd58d9 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -131,7 +131,7 @@ public static IResourceBuilder AddGitHubActionsWo var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow); // Generate the YAML model - var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow, context.RepositoryRootDirectory); + var yamlModel = WorkflowYamlGenerator.Generate(scheduling, workflow); // Apply user customization callbacks foreach (var customization in workflow.Annotations.OfType()) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index b168381eb6d..9e68afc805f 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -3,7 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 -using System.Text.Json; +using System.Reflection; using Aspire.Hosting.Pipelines.GitHubActions.Yaml; namespace Aspire.Hosting.Pipelines.GitHubActions; @@ -19,12 +19,12 @@ internal static class WorkflowYamlGenerator /// /// Generates a workflow YAML model from the scheduling result. /// - public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, string? repositoryRootDirectory = null) + public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWorkflowResource workflow) { ArgumentNullException.ThrowIfNull(scheduling); ArgumentNullException.ThrowIfNull(workflow); - var channel = ReadChannelFromConfig(repositoryRootDirectory); + var buildChannel = DetectBuildChannel(); var workflowYaml = new WorkflowYaml { @@ -51,14 +51,14 @@ public static WorkflowYaml Generate(SchedulingResult scheduling, GitHubActionsWo // Generate a YAML job for each workflow job foreach (var job in workflow.Jobs) { - var jobYaml = GenerateJob(job, scheduling, workflow, channel); + var jobYaml = GenerateJob(job, scheduling, workflow, buildChannel); workflowYaml.Jobs[job.Id] = jobYaml; } return workflowYaml; } - private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, string? channel) + private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResult scheduling, GitHubActionsWorkflowResource workflow, BuildChannel buildChannel) { // Collect dependency tags from all pipeline steps assigned to this job var dependencyTags = new HashSet(StringComparer.Ordinal); @@ -117,7 +117,7 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul // Conditional: Install Aspire CLI (when any step needs it — always true since aspire do runs) if (dependencyTags.Contains(WellKnownDependencyTags.AspireCli)) { - steps.Add(GenerateAspireCliInstallStep(channel)); + steps.Add(GenerateAspireCliInstallStep(buildChannel)); } // Conditional: Azure login (when any step needs Azure CLI) @@ -203,14 +203,12 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul private const string InstallScriptUrl = "https://aspire.dev/install.sh"; private const string PrInstallScriptUrl = "https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh"; - private static StepYaml GenerateAspireCliInstallStep(string? channel) + private static StepYaml GenerateAspireCliInstallStep(BuildChannel buildChannel) { const string addToPath = """echo "$HOME/.aspire/bin" >> $GITHUB_PATH"""; - // Check for PR channel: "pr-" — use the PR-specific install script - if (channel is not null && - channel.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) && - int.TryParse(channel.AsSpan(3), out var prNumber)) + // PR build: use the PR-specific install script with GH_TOKEN for artifact download + if (buildChannel.PrNumber is { } prNumber) { return new StepYaml { @@ -223,16 +221,12 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) }; } - // Standard channel install via aspire.dev/install.sh with quality flag: - // "daily" → -q dev - // "staging" → -q staging - // anything else (stable/default/null) → no flag - var installCommand = channel?.ToLowerInvariant() switch - { - "daily" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev", - "staging" => $"curl -sSL {InstallScriptUrl} | bash -s -- -q staging", - _ => $"curl -sSL {InstallScriptUrl} | bash" - }; + // Non-PR build: use aspire.dev/install.sh with quality flag based on prerelease status + // prerelease (e.g. preview/dev build) → -q dev + // stable (no prerelease suffix) → no flag + var installCommand = buildChannel.IsPrerelease + ? $"curl -sSL {InstallScriptUrl} | bash -s -- -q dev" + : $"curl -sSL {InstallScriptUrl} | bash"; return new StepYaml { @@ -241,38 +235,57 @@ private static StepYaml GenerateAspireCliInstallStep(string? channel) }; } - private static string? ReadChannelFromConfig(string? repositoryRootDirectory) + /// + /// Detects the build channel by inspecting the assembly's informational version. + /// PR builds contain -pr.{number} in the version suffix (e.g. 13.3.0-pr.15643.g8a1b2c3d). + /// + internal static BuildChannel DetectBuildChannel() { - if (string.IsNullOrEmpty(repositoryRootDirectory)) - { - return null; - } + var version = typeof(WorkflowYamlGenerator).Assembly + .GetCustomAttribute()?.InformationalVersion; + + return ParseBuildChannel(version); + } - var configPath = Path.Combine(repositoryRootDirectory, "aspire.config.json"); - if (!File.Exists(configPath)) + /// + /// Parses a build channel from a version string. + /// + internal static BuildChannel ParseBuildChannel(string? version) + { + if (string.IsNullOrEmpty(version)) { - return null; + return new BuildChannel(PrNumber: null, IsPrerelease: false); } - try - { - using var stream = File.OpenRead(configPath); - using var doc = JsonDocument.Parse(stream); + // Strip the +commit suffix (e.g. "+8a1b2c3d...") + var plusIdx = version.IndexOf('+'); + var versionCore = plusIdx >= 0 ? version[..plusIdx] : version; - if (doc.RootElement.TryGetProperty("channel", out var channelProp) && - channelProp.ValueKind == JsonValueKind.String) + // Check for PR pattern: "-pr.{digits}" in the version string + const string prMarker = "-pr."; + var prIdx = versionCore.IndexOf(prMarker, StringComparison.OrdinalIgnoreCase); + if (prIdx >= 0) + { + var afterMarker = versionCore.AsSpan(prIdx + prMarker.Length); + // Take digits until next '.' or end of string + var dotIdx = afterMarker.IndexOf('.'); + var numberSpan = dotIdx >= 0 ? afterMarker[..dotIdx] : afterMarker; + if (int.TryParse(numberSpan, out var prNumber)) { - return channelProp.GetString(); + return new BuildChannel(PrNumber: prNumber, IsPrerelease: true); } } - catch (JsonException) - { - // Malformed config — fall back to default - } - return null; + // Any prerelease suffix (contains '-') means it's a dev/preview build + var isPrerelease = versionCore.Contains('-'); + return new BuildChannel(PrNumber: null, IsPrerelease: isPrerelease); } + /// + /// Represents the detected build channel from assembly version metadata. + /// + internal readonly record struct BuildChannel(int? PrNumber, bool IsPrerelease); + private static string FindStageName(GitHubActionsWorkflowResource workflow, GitHubActionsJobResource job) { foreach (var stage in workflow.Stages) 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 index 07d9ca0c6fc..e4c45b261e3 100644 --- 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 @@ -25,7 +25,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: 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 index eb757134160..81a3a4e0ce2 100644 --- 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 @@ -26,7 +26,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: 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 index 7728cbe3dec..88bfbded700 100644 --- 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 @@ -26,7 +26,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: @@ -52,7 +52,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 @@ -83,7 +83,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 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 index 7696b8cd1cb..70fdfa1517a 100644 --- 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 @@ -26,7 +26,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Run pipeline steps env: @@ -52,7 +52,7 @@ jobs: dotnet-version: 10.0.x - name: Install Aspire CLI run: | - curl -sSL https://aspire.dev/install.sh | bash + curl -sSL https://aspire.dev/install.sh | bash -s -- -q dev echo "$HOME/.aspire/bin" >> $GITHUB_PATH - name: Download state from build uses: actions/download-artifact@v4 diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs index 90ed7ca4126..7c83c7a1af5 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/WorkflowYamlGeneratorTests.cs @@ -314,117 +314,43 @@ public void Generate_TagsAcrossJobs_IndependentSetupSteps() Assert.Contains(deployJobYaml.Steps, s => s.Name == "Azure login"); } - // Channel-aware CLI install tests - - [Fact] - public void Generate_NoConfig_UsesDefaultInstallScript() - { - var workflow = new GitHubActionsWorkflowResource("deploy"); - var step = CreateStep("build-app"); - - var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, repositoryRootDirectory: null); - - var job = yaml.Jobs["default"]; - var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // No config → default (stable) channel install - Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); - Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); - } - - [Fact] - public void Generate_UnknownChannel_FallsBackToDefault() + // Build channel detection tests (version-based) + + [Theory] + [InlineData(null, null, false)] + [InlineData("", null, false)] + [InlineData("13.3.0", null, false)] + [InlineData("13.3.0+abc123", null, false)] + [InlineData("13.3.0-preview.1.25608.1", null, true)] + [InlineData("13.3.0-preview.1.25608.1+abc123", null, true)] + [InlineData("13.3.0-dev.25180.1", null, true)] + [InlineData("13.3.0-pr.15643.g8a1b2c3d", 15643, true)] + [InlineData("13.3.0-pr.15643.g8a1b2c3d+abc123def456", 15643, true)] + [InlineData("13.3.0-pr.999", 999, true)] + public void ParseBuildChannel_DetectsCorrectly(string? version, int? expectedPr, bool expectedPrerelease) { - using var tempDir = new TempDirectory(); - File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "preview"}"""); - - var workflow = new GitHubActionsWorkflowResource("deploy"); - var step = CreateStep("build-app"); - - var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + var channel = WorkflowYamlGenerator.ParseBuildChannel(version); - var job = yaml.Jobs["default"]; - var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // "preview" is not a recognized channel — falls back to default (stable) install - Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); - Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); + Assert.Equal(expectedPr, channel.PrNumber); + Assert.Equal(expectedPrerelease, channel.IsPrerelease); } [Fact] - public void Generate_DailyChannel_UsesDailyQuality() + public void GenerateInstallStep_StableBuild_UsesDefaultInstallScript() { - using var tempDir = new TempDirectory(); - File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "daily"}"""); - var workflow = new GitHubActionsWorkflowResource("deploy"); var step = CreateStep("build-app"); var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); - - var job = yaml.Jobs["default"]; - var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains("-q dev", installStep.Run); - } - - [Fact] - public void Generate_StableChannel_UsesDefaultInstallScript() - { - using var tempDir = new TempDirectory(); - File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "stable"}"""); - - var workflow = new GitHubActionsWorkflowResource("deploy"); - var step = CreateStep("build-app"); - - var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); + var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow); var job = yaml.Jobs["default"]; var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - // Stable channel: default install (no -q flag) + // Running in tests → local dev build → stable install (no -q flag) Assert.Contains("curl -sSL https://aspire.dev/install.sh | bash", installStep.Run); Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); } - [Fact] - public void Generate_StagingChannel_UsesStagingQuality() - { - using var tempDir = new TempDirectory(); - File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "staging"}"""); - - var workflow = new GitHubActionsWorkflowResource("deploy"); - var step = CreateStep("build-app"); - - var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); - - var job = yaml.Jobs["default"]; - var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains("-q staging", installStep.Run); - } - - [Fact] - public void Generate_PrChannel_UsesPrInstallScript() - { - using var tempDir = new TempDirectory(); - File.WriteAllText(Path.Combine(tempDir.Path, "aspire.config.json"), """{"channel": "pr-15643"}"""); - - var workflow = new GitHubActionsWorkflowResource("deploy"); - var step = CreateStep("build-app"); - - var scheduling = SchedulingResolver.Resolve([step], workflow); - var yaml = WorkflowYamlGenerator.Generate(scheduling, workflow, tempDir.Path); - - var job = yaml.Jobs["default"]; - var installStep = Assert.Single(job.Steps, s => s.Name == "Install Aspire CLI"); - Assert.Contains("get-aspire-cli-pr.sh", installStep.Run); - Assert.Contains("15643", installStep.Run); - Assert.Contains("""echo "$HOME/.aspire/bin" >> $GITHUB_PATH""", installStep.Run); - Assert.NotNull(installStep.Env); - Assert.Equal("${{ github.token }}", installStep.Env["GH_TOKEN"]); - } - // ConfigureWorkflow callback tests [Fact] From e3ce0826187ecb024c3000d3f2b4990f6eef0d57 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 17:37:16 +1100 Subject: [PATCH 31/39] Add GitHub repository bootstrapper for aspire pipeline init - GitHelper: wraps git commands (init, remote, commit, push) - GitHubApiClient: uses gh CLI auth + REST API for repo creation - GitHubRepositoryBootstrapper: orchestrates repo detection, init, GitHub remote creation, and post-generation commit/push - GitIgnoreTemplate: baked-in .NET/Aspire .gitignore - Wire bootstrapper into GHA extension callback (runs before YAML gen) - Make RepositoryRootDirectory optional (extensions can override) - Central repo detection becomes best-effort default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHelper.cs | 129 +++++++ .../GitHubActionsWorkflowExtensions.cs | 20 ++ .../GitHubApiClient.cs | 179 +++++++++ .../GitHubRepositoryBootstrapper.cs | 340 ++++++++++++++++++ .../GitIgnoreTemplate.cs | 42 +++ .../DistributedApplicationPipeline.cs | 9 +- .../PipelineWorkflowGeneratorAnnotation.cs | 15 +- 7 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs create mode 100644 src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs new file mode 100644 index 00000000000..0abc3a3c2cf --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHelper.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Helper for executing Git commands from the hosting extension. +/// +internal static class GitHelper +{ + /// + /// Checks whether the given directory is inside a Git work tree. + /// + public static async Task IsGitRepoAsync(string directory, ILogger logger, CancellationToken ct = default) + { + var (exitCode, _) = await RunGitAsync(directory, "rev-parse --is-inside-work-tree", logger, ct).ConfigureAwait(false); + return exitCode == 0; + } + + /// + /// Gets the root directory of the Git repository containing . + /// + public static async Task GetRepoRootAsync(string directory, ILogger logger, CancellationToken ct = default) + { + var (exitCode, output) = await RunGitAsync(directory, "rev-parse --show-toplevel", logger, ct).ConfigureAwait(false); + return exitCode == 0 ? output.Trim() : null; + } + + /// + /// Gets the URL of the named Git remote, or null if none exists. + /// + public static async Task GetRemoteUrlAsync(string directory, ILogger logger, string remote = "origin", CancellationToken ct = default) + { + var (exitCode, output) = await RunGitAsync(directory, $"remote get-url {remote}", logger, ct).ConfigureAwait(false); + return exitCode == 0 ? output.Trim() : null; + } + + /// + /// Initializes a new Git repository in the given directory. + /// + public static async Task InitAsync(string directory, ILogger logger, CancellationToken ct = default) + { + var (exitCode, _) = await RunGitAsync(directory, "init", logger, ct).ConfigureAwait(false); + return exitCode == 0; + } + + /// + /// Adds a remote to the Git repository. + /// + public static async Task AddRemoteAsync(string directory, string url, ILogger logger, string remote = "origin", CancellationToken ct = default) + { + var (exitCode, _) = await RunGitAsync(directory, $"remote add {remote} {url}", logger, ct).ConfigureAwait(false); + return exitCode == 0; + } + + /// + /// Stages all files and creates a commit. + /// + public static async Task AddAllAndCommitAsync(string directory, string message, ILogger logger, CancellationToken ct = default) + { + var (addExit, _) = await RunGitAsync(directory, "add .", logger, ct).ConfigureAwait(false); + if (addExit != 0) + { + return false; + } + + var (commitExit, _) = await RunGitAsync(directory, $"commit -m \"{message}\"", logger, ct).ConfigureAwait(false); + return commitExit == 0; + } + + /// + /// Pushes the current branch to the remote. + /// + public static async Task PushAsync(string directory, ILogger logger, string remote = "origin", string branch = "main", CancellationToken ct = default) + { + var (exitCode, _) = await RunGitAsync(directory, $"push -u {remote} {branch}", logger, ct).ConfigureAwait(false); + return exitCode == 0; + } + + /// + /// Gets the current branch name. + /// + public static async Task GetCurrentBranchAsync(string directory, ILogger logger, CancellationToken ct = default) + { + var (exitCode, output) = await RunGitAsync(directory, "branch --show-current", logger, ct).ConfigureAwait(false); + return exitCode == 0 ? output.Trim() : null; + } + + private static async Task<(int ExitCode, string Output)> RunGitAsync(string workingDirectory, string arguments, ILogger logger, CancellationToken ct) + { + try + { + var startInfo = new ProcessStartInfo("git", arguments) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(ct); + var errorTask = process.StandardError.ReadToEndAsync(ct); + + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + var output = await outputTask.ConfigureAwait(false); + var error = await errorTask.ConfigureAwait(false); + + if (process.ExitCode != 0) + { + logger.LogDebug("git {Arguments} exited with code {ExitCode}: {Error}", arguments, process.ExitCode, error.Trim()); + } + + return (process.ExitCode, output); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Git is not installed or not found in PATH"); + return (-1, string.Empty); + } + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index aaf93fd58d9..8e5cfaf140f 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -123,6 +123,22 @@ public static IResourceBuilder AddGitHubActionsWo var workflow = (GitHubActionsWorkflowResource)context.Environment; var logger = context.StepContext.Logger; + // Bootstrap Git repo and GitHub remote if needed. + // This may initialize a Git repo, create .gitignore, create a GitHub repo, + // and set the RepositoryRootDirectory on the context. + var repoRoot = await GitHubRepositoryBootstrapper.BootstrapAsync(context).ConfigureAwait(false); + + if (repoRoot is not null) + { + context.RepositoryRootDirectory = repoRoot; + } + + if (context.RepositoryRootDirectory is null) + { + logger.LogError("Could not determine the repository root directory. Workflow generation cannot continue."); + return; + } + // Resolve scheduling (which steps run in which jobs). // Filter out orphaned steps (same as the annotation factory) so the YAML // generator only includes steps that are part of the deployment graph. @@ -151,6 +167,10 @@ public static IResourceBuilder AddGitHubActionsWo logger.LogInformation("Generated GitHub Actions workflow: {Path}", outputPath); context.StepContext.Summary.Add("📄 Workflow", outputPath); + + // Offer to commit and push the generated files + await GitHubRepositoryBootstrapper.OfferCommitAndPushAsync( + context.RepositoryRootDirectory, context.StepContext).ConfigureAwait(false); })); return builder.AddResource(resource) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs new file mode 100644 index 00000000000..fe8e8c79963 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubApiClient.cs @@ -0,0 +1,179 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Lightweight GitHub API client that uses the gh CLI for authentication +/// and for REST API calls. +/// +internal sealed class GitHubApiClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public GitHubApiClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.github.com"), + DefaultRequestHeaders = + { + { "Accept", "application/vnd.github+json" }, + { "User-Agent", "aspire-pipeline-init" }, + { "X-GitHub-Api-Version", "2022-11-28" } + } + }; + } + + /// + /// Checks whether the gh CLI is installed and available on PATH. + /// + public static async Task IsGhInstalledAsync(CancellationToken ct = default) + { + try + { + var (exitCode, _) = await RunGhAsync("--version", ct).ConfigureAwait(false); + return exitCode == 0; + } + catch + { + return false; + } + } + + /// + /// Gets an authentication token from the gh CLI. + /// Returns null if the user is not authenticated. + /// + public async Task GetAuthTokenAsync(CancellationToken ct = default) + { + var (exitCode, output) = await RunGhAsync("auth token", ct).ConfigureAwait(false); + if (exitCode != 0) + { + _logger.LogDebug("gh auth token returned exit code {ExitCode}. User may not be authenticated.", exitCode); + return null; + } + + var token = output.Trim(); + return string.IsNullOrEmpty(token) ? null : token; + } + + /// + /// Gets the authenticated user's login name. + /// + public async Task GetAuthenticatedUserAsync(string token, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/user"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("GET /user returned {StatusCode}", response.StatusCode); + return null; + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false); + return doc.RootElement.TryGetProperty("login", out var login) ? login.GetString() : null; + } + + /// + /// Gets the list of organizations the authenticated user is a member of. + /// Returns org login names. + /// + public async Task> GetUserOrgsAsync(string token, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/user/orgs?per_page=100"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("GET /user/orgs returned {StatusCode}", response.StatusCode); + return []; + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false); + + var orgs = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (element.TryGetProperty("login", out var login) && login.GetString() is { } orgLogin) + { + orgs.Add(orgLogin); + } + } + + return orgs; + } + + /// + /// Creates a new GitHub repository. + /// + /// GitHub auth token. + /// Repository name. + /// Organization login, or null for a personal repo. + /// Whether the repo should be private. + /// Cancellation token. + /// The full clone URL (https), or null on failure. + public async Task CreateRepoAsync(string token, string name, string? org, bool isPrivate, CancellationToken ct = default) + { + var endpoint = org is not null ? $"/orgs/{org}/repos" : "/user/repos"; + + var body = JsonSerializer.Serialize(new + { + name, + @private = isPrivate, + auto_init = false + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + _logger.LogWarning("Failed to create repository. Status: {StatusCode}, Body: {Body}", response.StatusCode, errorBody); + return null; + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), cancellationToken: ct).ConfigureAwait(false); + return doc.RootElement.TryGetProperty("clone_url", out var cloneUrl) ? cloneUrl.GetString() : null; + } + + private static async Task<(int ExitCode, string Output)> RunGhAsync(string arguments, CancellationToken ct) + { + var startInfo = new ProcessStartInfo("gh", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); + await process.WaitForExitAsync(ct).ConfigureAwait(false); + + return (process.ExitCode, output); + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs new file mode 100644 index 00000000000..af4284ee829 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs @@ -0,0 +1,340 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREINTERACTION001 +#pragma warning disable ASPIREPIPELINES001 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Orchestrates Git repository and GitHub remote bootstrapping during aspire pipeline init. +/// +internal static class GitHubRepositoryBootstrapper +{ + /// + /// Bootstraps the Git repo and GitHub remote if needed, returning the repository root directory. + /// + public static async Task BootstrapAsync(PipelineWorkflowGenerationContext context) + { + var logger = context.StepContext.Logger; + var ct = context.CancellationToken; + var interactionService = context.StepContext.Services.GetService(); + + // Determine working directory from the execution context + var cwd = Directory.GetCurrentDirectory(); + + // Step 1: Check if we're already in a Git repo + var isGitRepo = await GitHelper.IsGitRepoAsync(cwd, logger, ct).ConfigureAwait(false); + + string repoRoot; + + if (isGitRepo) + { + repoRoot = await GitHelper.GetRepoRootAsync(cwd, logger, ct).ConfigureAwait(false) ?? cwd; + logger.LogInformation("Git repository detected at: {RepoRoot}", repoRoot); + } + else + { + // Offer to initialize a Git repo + if (interactionService is not null) + { + var initResult = await interactionService.PromptConfirmationAsync( + "Initialize Git Repository", + "No Git repository found in the current directory. Would you like to initialize one?", + cancellationToken: ct).ConfigureAwait(false); + + if (initResult.Canceled || !initResult.Data) + { + logger.LogInformation("Skipping Git initialization. Using current directory as root."); + return cwd; + } + } + else + { + logger.LogWarning("No Git repository found and no interaction service available. Using current directory."); + return cwd; + } + + // Initialize Git repo + logger.LogInformation("Initializing Git repository in {Directory}...", cwd); + if (!await GitHelper.InitAsync(cwd, logger, ct).ConfigureAwait(false)) + { + logger.LogError("Failed to initialize Git repository."); + return cwd; + } + + repoRoot = cwd; + logger.LogInformation("Git repository initialized."); + + // Offer to create .gitignore + var gitignorePath = Path.Combine(repoRoot, ".gitignore"); + if (!File.Exists(gitignorePath)) + { + var createGitignore = await interactionService.PromptConfirmationAsync( + "Create .gitignore", + "Would you like to create a .gitignore file with sensible defaults for .NET/Aspire projects?", + cancellationToken: ct).ConfigureAwait(false); + + if (!createGitignore.Canceled && createGitignore.Data) + { + await File.WriteAllTextAsync(gitignorePath, GitIgnoreTemplate.Content, ct).ConfigureAwait(false); + logger.LogInformation("Created .gitignore"); + context.StepContext.Summary.Add("📄 .gitignore", gitignorePath); + } + } + } + + // Step 2: Check for existing GitHub remote + var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false); + + if (IsGitHubUrl(remoteUrl)) + { + logger.LogInformation("GitHub remote already configured: {Url}", remoteUrl); + return repoRoot; + } + + // Step 3: Offer to create a GitHub repository + if (interactionService is null) + { + logger.LogInformation("No interaction service available. Skipping GitHub repository setup."); + return repoRoot; + } + + var pushToGitHub = await interactionService.PromptConfirmationAsync( + "Push to GitHub", + remoteUrl is null + ? "No Git remote configured. Would you like to create a GitHub repository and push?" + : "The current remote is not a GitHub URL. Would you like to create a GitHub repository?", + cancellationToken: ct).ConfigureAwait(false); + + if (pushToGitHub.Canceled || !pushToGitHub.Data) + { + return repoRoot; + } + + // Step 4: Set up GitHub repo + var cloneUrl = await SetupGitHubRepoAsync(repoRoot, interactionService, logger, ct).ConfigureAwait(false); + if (cloneUrl is not null) + { + context.StepContext.Summary.Add("🔗 GitHub", cloneUrl); + } + + return repoRoot; + } + + /// + /// After YAML generation, optionally commits and pushes all changes. + /// + public static async Task OfferCommitAndPushAsync(string repoRoot, PipelineStepContext stepContext) + { + var logger = stepContext.Logger; + var ct = stepContext.CancellationToken; + var interactionService = stepContext.Services.GetService(); + + if (interactionService is null) + { + return; + } + + // Check if there's a remote to push to + var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false); + if (remoteUrl is null) + { + return; + } + + var commitResult = await interactionService.PromptConfirmationAsync( + "Commit & Push", + "Would you like to commit the generated workflow files and push to GitHub?", + cancellationToken: ct).ConfigureAwait(false); + + if (commitResult.Canceled || !commitResult.Data) + { + return; + } + + logger.LogInformation("Committing and pushing..."); + + if (!await GitHelper.AddAllAndCommitAsync(repoRoot, "Add Aspire CI/CD pipeline workflow", logger, ct).ConfigureAwait(false)) + { + logger.LogWarning("Failed to commit changes. You may need to commit manually."); + return; + } + + var branch = await GitHelper.GetCurrentBranchAsync(repoRoot, logger, ct).ConfigureAwait(false) ?? "main"; + + if (!await GitHelper.PushAsync(repoRoot, logger, branch: branch, ct: ct).ConfigureAwait(false)) + { + logger.LogWarning("Failed to push. You may need to push manually with: git push -u origin {Branch}", branch); + return; + } + + logger.LogInformation("Pushed to {Remote} on branch {Branch}", remoteUrl, branch); + stepContext.Summary.Add("🚀 Pushed", $"{branch} → {remoteUrl}"); + } + + private static async Task SetupGitHubRepoAsync( + string repoRoot, + IInteractionService interactionService, + ILogger logger, + CancellationToken ct) + { + using var github = new GitHubApiClient(logger); + + // Check if gh CLI is installed + if (!await GitHubApiClient.IsGhInstalledAsync(ct).ConfigureAwait(false)) + { + logger.LogWarning("The GitHub CLI (gh) is not installed. Install it from https://cli.github.com/ to create repositories."); + return null; + } + + // Get auth token + var token = await github.GetAuthTokenAsync(ct).ConfigureAwait(false); + if (token is null) + { + logger.LogWarning("Not authenticated with GitHub CLI. Run 'gh auth login' first."); + return null; + } + + // Fetch user and orgs in parallel + var userTask = github.GetAuthenticatedUserAsync(token, ct); + var orgsTask = github.GetUserOrgsAsync(token, ct); + + await Task.WhenAll(userTask, orgsTask).ConfigureAwait(false); + + var username = await userTask.ConfigureAwait(false); + var orgs = await orgsTask.ConfigureAwait(false); + + if (username is null) + { + logger.LogWarning("Could not determine authenticated GitHub user."); + return null; + } + + // Build owner choices: personal account + orgs + var ownerChoices = new List> + { + new(username, $"{username} (personal account)") + }; + + foreach (var org in orgs) + { + ownerChoices.Add(new(org, org)); + } + + // Prompt for owner + var ownerResult = await interactionService.PromptInputAsync( + "GitHub Repository Owner", + "Select the owner for the new repository.", + new InteractionInput + { + Name = "owner", + Label = "Owner", + InputType = InputType.Choice, + Options = ownerChoices, + Value = username, + Required = true + }, + cancellationToken: ct).ConfigureAwait(false); + + if (ownerResult.Canceled || ownerResult.Data?.Value is null) + { + return null; + } + + var selectedOwner = ownerResult.Data.Value; + var isOrg = selectedOwner != username; + + // Prompt for repo name (default: directory name) + var defaultRepoName = new DirectoryInfo(repoRoot).Name; + + var nameResult = await interactionService.PromptInputAsync( + "Repository Name", + null, + new InteractionInput + { + Name = "repoName", + Label = "Repository name", + InputType = InputType.Text, + Value = defaultRepoName, + Required = true + }, + cancellationToken: ct).ConfigureAwait(false); + + if (nameResult.Canceled || string.IsNullOrWhiteSpace(nameResult.Data?.Value)) + { + return null; + } + + var repoName = nameResult.Data.Value; + + // Prompt for visibility + var visibilityResult = await interactionService.PromptInputAsync( + "Repository Visibility", + null, + new InteractionInput + { + Name = "visibility", + Label = "Visibility", + InputType = InputType.Choice, + Options = + [ + new("private", "Private"), + new("public", "Public") + ], + Value = "private", + Required = true + }, + cancellationToken: ct).ConfigureAwait(false); + + if (visibilityResult.Canceled) + { + return null; + } + + var isPrivate = visibilityResult.Data?.Value != "public"; + + // Create the repo + logger.LogInformation("Creating GitHub repository {Owner}/{Repo}...", selectedOwner, repoName); + + var cloneUrl = await github.CreateRepoAsync( + token, + repoName, + isOrg ? selectedOwner : null, + isPrivate, + ct).ConfigureAwait(false); + + if (cloneUrl is null) + { + logger.LogError("Failed to create GitHub repository."); + return null; + } + + logger.LogInformation("Created repository: {Url}", cloneUrl); + + // Add remote + if (!await GitHelper.AddRemoteAsync(repoRoot, cloneUrl, logger, ct: ct).ConfigureAwait(false)) + { + logger.LogWarning("Failed to add remote. You can add it manually: git remote add origin {Url}", cloneUrl); + } + else + { + logger.LogInformation("Added remote 'origin' → {Url}", cloneUrl); + } + + return cloneUrl; + } + + private static bool IsGitHubUrl(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + return url.Contains("github.com", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs new file mode 100644 index 00000000000..56efd56ed19 --- /dev/null +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitIgnoreTemplate.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines.GitHubActions; + +/// +/// Provides a sensible .gitignore template for Aspire projects. +/// +internal static class GitIgnoreTemplate +{ + public const string Content = """ + ## .NET + bin/ + obj/ + artifacts/ + TestResults/ + *.user + *.suo + *.userosscache + *.sln.docstates + + ## NuGet + *.nupkg + **/[Pp]ackages/* + !**/[Pp]ackages/build/ + + ## IDE + .vs/ + .vscode/ + .idea/ + *.swp + *~ + + ## Aspire + .aspire/state/ + .aspire/secrets/ + + ## OS + .DS_Store + Thumbs.db + """; +} diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 149f936688d..4745982a29d 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -1432,17 +1432,14 @@ private async Task ExecutePipelineInitAsync(PipelineStepContext context) return; } - // Detect the repository root directory + // Detect the repository root directory (best-effort default — extensions may override) var repoRoot = await DetectRepositoryRootAsync(context).ConfigureAwait(false); - if (repoRoot is null) + if (repoRoot is not null) { - context.Logger.LogError("Could not determine the repository root directory. Pipeline init cannot continue."); - return; + context.Logger.LogInformation("Using repository root: {RepoRoot}", repoRoot); } - context.Logger.LogInformation("Using repository root: {RepoRoot}", repoRoot); - foreach (var env in environments) { var resource = (IResource)env; diff --git a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs index 6ff814ea42c..8c0eccaea05 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineWorkflowGeneratorAnnotation.cs @@ -52,15 +52,20 @@ public sealed class PipelineWorkflowGenerationContext public required IReadOnlyList Steps { get; init; } /// - /// Gets the root directory of the repository. This is used as the base path for + /// Gets or sets the root directory of the repository. This is used as the base path for /// writing generated workflow files (e.g., .github/workflows/). /// /// - /// The directory is resolved during aspire pipeline init by detecting the git - /// repository root, falling back to the location of aspire.config.json, and - /// confirmed by the user via the interaction service. + /// + /// When set by the pipeline infrastructure, this is resolved by detecting the git + /// repository root or the location of aspire.config.json. + /// + /// + /// Pipeline environment extensions (e.g., GitHub Actions) may override this value + /// during bootstrapping — for example, after initializing a new Git repository. + /// /// - public required string RepositoryRootDirectory { get; init; } + public string? RepositoryRootDirectory { get; set; } /// /// Gets the cancellation token. From a52073faad4439ae95f7a8aed99d6ac0fb8b631a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 17:39:27 +1100 Subject: [PATCH 32/39] Add tests for GitHelper and GitIgnoreTemplate - GitHelperTests: integration tests using real git in temp directories (init, repo detection, remote management, commit) - GitIgnoreTemplateTests: verifies content contains expected patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHelperTests.cs | 170 ++++++++++++++++++ .../GitIgnoreTemplateTests.cs | 24 +++ 2 files changed, 194 insertions(+) create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs create mode 100644 tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs new file mode 100644 index 00000000000..36a9a4bb6e0 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitHelperTests.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +public class GitHelperTests : IDisposable +{ + private readonly string _tempDir; + private readonly NullLogger _logger = NullLogger.Instance; + + public GitHelperTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-git-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + [Fact] + public async Task IsGitRepoAsync_ReturnsFalse_ForNonGitDirectory() + { + var result = await GitHelper.IsGitRepoAsync(_tempDir, _logger); + + Assert.False(result); + } + + [Fact] + public async Task InitAsync_CreatesGitRepo() + { + var result = await GitHelper.InitAsync(_tempDir, _logger); + + Assert.True(result); + Assert.True(Directory.Exists(Path.Combine(_tempDir, ".git"))); + } + + [Fact] + public async Task IsGitRepoAsync_ReturnsTrue_AfterInit() + { + await GitHelper.InitAsync(_tempDir, _logger); + + var result = await GitHelper.IsGitRepoAsync(_tempDir, _logger); + + Assert.True(result); + } + + [Fact] + public async Task GetRepoRootAsync_ReturnsRoot_AfterInit() + { + await GitHelper.InitAsync(_tempDir, _logger); + + var root = await GitHelper.GetRepoRootAsync(_tempDir, _logger); + + Assert.NotNull(root); + // Resolve symlinks for macOS /tmp → /private/tmp + Assert.Equal( + Path.GetFullPath(_tempDir), + Path.GetFullPath(root)); + } + + [Fact] + public async Task GetRepoRootAsync_ReturnsNull_ForNonGitDirectory() + { + var root = await GitHelper.GetRepoRootAsync(_tempDir, _logger); + + Assert.Null(root); + } + + [Fact] + public async Task GetRemoteUrlAsync_ReturnsNull_WhenNoRemote() + { + await GitHelper.InitAsync(_tempDir, _logger); + + var url = await GitHelper.GetRemoteUrlAsync(_tempDir, _logger); + + Assert.Null(url); + } + + [Fact] + public async Task AddRemoteAsync_AddsRemote() + { + await GitHelper.InitAsync(_tempDir, _logger); + + var result = await GitHelper.AddRemoteAsync(_tempDir, "https://github.com/test/repo.git", _logger); + + Assert.True(result); + + var url = await GitHelper.GetRemoteUrlAsync(_tempDir, _logger); + Assert.Equal("https://github.com/test/repo.git", url); + } + + [Fact] + public async Task GetCurrentBranchAsync_ReturnsBranchName_AfterCommit() + { + await GitHelper.InitAsync(_tempDir, _logger); + + // Configure git user for commit + await RunGitAsync(_tempDir, "config user.email test@test.com"); + await RunGitAsync(_tempDir, "config user.name Test"); + + // Create a file and commit to establish a branch + await File.WriteAllTextAsync(Path.Combine(_tempDir, "README.md"), "test"); + await GitHelper.AddAllAndCommitAsync(_tempDir, "Initial commit", _logger); + + var branch = await GitHelper.GetCurrentBranchAsync(_tempDir, _logger); + + Assert.NotNull(branch); + Assert.NotEmpty(branch); + } + + [Fact] + public async Task AddAllAndCommitAsync_CommitsFiles() + { + await GitHelper.InitAsync(_tempDir, _logger); + await RunGitAsync(_tempDir, "config user.email test@test.com"); + await RunGitAsync(_tempDir, "config user.name Test"); + + await File.WriteAllTextAsync(Path.Combine(_tempDir, "file.txt"), "hello"); + + var result = await GitHelper.AddAllAndCommitAsync(_tempDir, "Test commit", _logger); + + Assert.True(result); + + // Verify commit exists via log + var (exitCode, output) = await RunGitWithOutputAsync(_tempDir, "log --oneline"); + Assert.Equal(0, exitCode); + Assert.Contains("Test commit", output); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch + { + // Best effort cleanup + } + } + + private static async Task RunGitAsync(string workingDir, string args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git", args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi)!; + await p.WaitForExitAsync(); + } + + private static async Task<(int ExitCode, string Output)> RunGitWithOutputAsync(string workingDir, string args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git", args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi)!; + var output = await p.StandardOutput.ReadToEndAsync(); + await p.WaitForExitAsync(); + return (p.ExitCode, output); + } +} diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs new file mode 100644 index 00000000000..539691e9056 --- /dev/null +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/GitIgnoreTemplateTests.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines.GitHubActions.Tests; + +public class GitIgnoreTemplateTests +{ + [Fact] + public void Content_IsNotEmpty() + { + Assert.False(string.IsNullOrWhiteSpace(GitIgnoreTemplate.Content)); + } + + [Theory] + [InlineData("bin/")] + [InlineData("obj/")] + [InlineData(".vs/")] + [InlineData("*.user")] + [InlineData("artifacts/")] + public void Content_ContainsExpectedPatterns(string pattern) + { + Assert.Contains(pattern, GitIgnoreTemplate.Content); + } +} From 45eb3a88685bc05a6c777645b61d02adc4d54e46 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 18:16:47 +1100 Subject: [PATCH 33/39] Fix pipeline init hang: use PromptInputAsync instead of PromptConfirmationAsync The CLI-to-AppHost backchannel only proxies InputsInteractionInfo (from PromptInputAsync) and NotificationInteractionInfo. It does NOT handle MessageBoxInteractionInfo (from PromptConfirmationAsync), causing the AppHost to hang forever waiting for a response that never arrives. - Replace all PromptConfirmationAsync calls with PromptInputAsync using InputType.Boolean, which IS supported by the backchannel - Extract PromptBooleanAsync helper for consistent yes/no prompting - Skip redundant git detection when RepositoryRootDirectory is already set by the central pipeline - Extract InitGitRepoAsync to reduce duplication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubRepositoryBootstrapper.cs | 189 +++++++++++------- 1 file changed, 116 insertions(+), 73 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs index af4284ee829..ccfeeeb2a8e 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubRepositoryBootstrapper.cs @@ -17,77 +17,66 @@ internal static class GitHubRepositoryBootstrapper /// /// Bootstraps the Git repo and GitHub remote if needed, returning the repository root directory. /// + /// + /// All user prompts use + /// rather than PromptConfirmationAsync because the CLI-to-AppHost backchannel only + /// proxies InputsInteractionInfo prompts (not MessageBoxInteractionInfo). + /// public static async Task BootstrapAsync(PipelineWorkflowGenerationContext context) { var logger = context.StepContext.Logger; var ct = context.CancellationToken; var interactionService = context.StepContext.Services.GetService(); - // Determine working directory from the execution context - var cwd = Directory.GetCurrentDirectory(); + // Use the repo root already detected by the central pipeline if available. + var repoRoot = context.RepositoryRootDirectory; - // Step 1: Check if we're already in a Git repo - var isGitRepo = await GitHelper.IsGitRepoAsync(cwd, logger, ct).ConfigureAwait(false); + if (repoRoot is not null) + { + var isGitRepo = await GitHelper.IsGitRepoAsync(repoRoot, logger, ct).ConfigureAwait(false); - string repoRoot; + if (!isGitRepo) + { + // Repo root was detected (e.g. via aspire.config.json) but it's not a git repo yet. + if (!await PromptBooleanAsync(interactionService, + "Initialize Git Repository", + "No Git repository found. Would you like to initialize one?", + ct).ConfigureAwait(false)) + { + return repoRoot; + } - if (isGitRepo) - { - repoRoot = await GitHelper.GetRepoRootAsync(cwd, logger, ct).ConfigureAwait(false) ?? cwd; - logger.LogInformation("Git repository detected at: {RepoRoot}", repoRoot); + await InitGitRepoAsync(repoRoot, interactionService, context, logger, ct).ConfigureAwait(false); + } } else { - // Offer to initialize a Git repo - if (interactionService is not null) + // No repo root from central detection — try to detect ourselves. + var cwd = Directory.GetCurrentDirectory(); + var isGitRepo = await GitHelper.IsGitRepoAsync(cwd, logger, ct).ConfigureAwait(false); + + if (isGitRepo) { - var initResult = await interactionService.PromptConfirmationAsync( + repoRoot = await GitHelper.GetRepoRootAsync(cwd, logger, ct).ConfigureAwait(false) ?? cwd; + logger.LogInformation("Git repository detected at: {RepoRoot}", repoRoot); + } + else + { + if (!await PromptBooleanAsync(interactionService, "Initialize Git Repository", "No Git repository found in the current directory. Would you like to initialize one?", - cancellationToken: ct).ConfigureAwait(false); - - if (initResult.Canceled || !initResult.Data) + ct).ConfigureAwait(false)) { logger.LogInformation("Skipping Git initialization. Using current directory as root."); return cwd; } - } - else - { - logger.LogWarning("No Git repository found and no interaction service available. Using current directory."); - return cwd; - } - // Initialize Git repo - logger.LogInformation("Initializing Git repository in {Directory}...", cwd); - if (!await GitHelper.InitAsync(cwd, logger, ct).ConfigureAwait(false)) - { - logger.LogError("Failed to initialize Git repository."); - return cwd; - } - - repoRoot = cwd; - logger.LogInformation("Git repository initialized."); - - // Offer to create .gitignore - var gitignorePath = Path.Combine(repoRoot, ".gitignore"); - if (!File.Exists(gitignorePath)) - { - var createGitignore = await interactionService.PromptConfirmationAsync( - "Create .gitignore", - "Would you like to create a .gitignore file with sensible defaults for .NET/Aspire projects?", - cancellationToken: ct).ConfigureAwait(false); - - if (!createGitignore.Canceled && createGitignore.Data) - { - await File.WriteAllTextAsync(gitignorePath, GitIgnoreTemplate.Content, ct).ConfigureAwait(false); - logger.LogInformation("Created .gitignore"); - context.StepContext.Summary.Add("📄 .gitignore", gitignorePath); - } + repoRoot = cwd; + await InitGitRepoAsync(repoRoot, interactionService, context, logger, ct).ConfigureAwait(false); } } - // Step 2: Check for existing GitHub remote + // Check for existing GitHub remote var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false); if (IsGitHubUrl(remoteUrl)) @@ -96,27 +85,18 @@ internal static class GitHubRepositoryBootstrapper return repoRoot; } - // Step 3: Offer to create a GitHub repository - if (interactionService is null) - { - logger.LogInformation("No interaction service available. Skipping GitHub repository setup."); - return repoRoot; - } - - var pushToGitHub = await interactionService.PromptConfirmationAsync( - "Push to GitHub", - remoteUrl is null - ? "No Git remote configured. Would you like to create a GitHub repository and push?" - : "The current remote is not a GitHub URL. Would you like to create a GitHub repository?", - cancellationToken: ct).ConfigureAwait(false); + // Offer to create a GitHub repository + var pushMessage = remoteUrl is null + ? "No Git remote configured. Would you like to create a GitHub repository and push?" + : "The current remote is not a GitHub URL. Would you like to create a GitHub repository?"; - if (pushToGitHub.Canceled || !pushToGitHub.Data) + if (!await PromptBooleanAsync(interactionService, "Push to GitHub", pushMessage, ct).ConfigureAwait(false)) { return repoRoot; } - // Step 4: Set up GitHub repo - var cloneUrl = await SetupGitHubRepoAsync(repoRoot, interactionService, logger, ct).ConfigureAwait(false); + // Set up GitHub repo + var cloneUrl = await SetupGitHubRepoAsync(repoRoot, interactionService!, logger, ct).ConfigureAwait(false); if (cloneUrl is not null) { context.StepContext.Summary.Add("🔗 GitHub", cloneUrl); @@ -125,6 +105,39 @@ remoteUrl is null return repoRoot; } + private static async Task InitGitRepoAsync( + string directory, + IInteractionService? interactionService, + PipelineWorkflowGenerationContext context, + ILogger logger, + CancellationToken ct) + { + logger.LogInformation("Initializing Git repository in {Directory}...", directory); + + if (!await GitHelper.InitAsync(directory, logger, ct).ConfigureAwait(false)) + { + logger.LogError("Failed to initialize Git repository."); + return; + } + + logger.LogInformation("Git repository initialized."); + + // Offer to create .gitignore + var gitignorePath = Path.Combine(directory, ".gitignore"); + if (!File.Exists(gitignorePath)) + { + if (await PromptBooleanAsync(interactionService, + "Create .gitignore", + "Would you like to create a .gitignore file with sensible defaults for .NET/Aspire projects?", + ct).ConfigureAwait(false)) + { + await File.WriteAllTextAsync(gitignorePath, GitIgnoreTemplate.Content, ct).ConfigureAwait(false); + logger.LogInformation("Created .gitignore"); + context.StepContext.Summary.Add("📄 .gitignore", gitignorePath); + } + } + } + /// /// After YAML generation, optionally commits and pushes all changes. /// @@ -134,11 +147,6 @@ public static async Task OfferCommitAndPushAsync(string repoRoot, PipelineStepCo var ct = stepContext.CancellationToken; var interactionService = stepContext.Services.GetService(); - if (interactionService is null) - { - return; - } - // Check if there's a remote to push to var remoteUrl = await GitHelper.GetRemoteUrlAsync(repoRoot, logger, ct: ct).ConfigureAwait(false); if (remoteUrl is null) @@ -146,12 +154,10 @@ public static async Task OfferCommitAndPushAsync(string repoRoot, PipelineStepCo return; } - var commitResult = await interactionService.PromptConfirmationAsync( + if (!await PromptBooleanAsync(interactionService, "Commit & Push", "Would you like to commit the generated workflow files and push to GitHub?", - cancellationToken: ct).ConfigureAwait(false); - - if (commitResult.Canceled || !commitResult.Data) + ct).ConfigureAwait(false)) { return; } @@ -176,6 +182,43 @@ public static async Task OfferCommitAndPushAsync(string repoRoot, PipelineStepCo stepContext.Summary.Add("🚀 Pushed", $"{branch} → {remoteUrl}"); } + /// + /// Prompts a yes/no question via + /// using , which is supported by the CLI backchannel. + /// Returns false if the interaction service is unavailable or the user declines/cancels. + /// + private static async Task PromptBooleanAsync( + IInteractionService? interactionService, + string title, + string message, + CancellationToken ct) + { + if (interactionService is null) + { + return false; + } + + var result = await interactionService.PromptInputAsync( + title, + message, + new InteractionInput + { + Name = "confirm", + Label = title, + InputType = InputType.Boolean, + Value = "true", + Required = true + }, + cancellationToken: ct).ConfigureAwait(false); + + if (result.Canceled || result.Data?.Value is null) + { + return false; + } + + return string.Equals(result.Data.Value, "true", StringComparison.OrdinalIgnoreCase); + } + private static async Task SetupGitHubRepoAsync( string repoRoot, IInteractionService interactionService, From 386345cf2f00904657b815a9f400a869c8fe4377 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 20:19:34 +1100 Subject: [PATCH 34/39] Fix circular dependency when running aspire pipeline init twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On second run, synthetic scheduling-target steps (gha-*-job) from the first run were included in existingSteps, passed through the filter (they have ScheduledBy set), and got fed into SchedulingResolver alongside real steps — corrupting the job dependency graph. Fix: FilterConnectedSteps now excludes synthetic steps matching the gha-{workflowName}-*-job pattern before scheduling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index 8e5cfaf140f..c77e1b23f7c 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -70,7 +70,9 @@ public static IResourceBuilder AddGitHubActionsWo // Filter out orphaned steps that have no connection to the deployment graph. // Steps like "publish-manifest", "diagnostics", and "pipeline-init" are // standalone tools for the CLI, not part of the deployment DAG. - var connectedSteps = FilterConnectedSteps(existingSteps); + // Also exclude synthetic scheduling-target steps from prior runs to avoid + // circular dependencies when `aspire pipeline init` is run more than once. + var connectedSteps = FilterConnectedSteps(existingSteps, workflow.Name); if (connectedSteps.Count == 0) { @@ -143,7 +145,7 @@ public static IResourceBuilder AddGitHubActionsWo // Filter out orphaned steps (same as the annotation factory) so the YAML // generator only includes steps that are part of the deployment graph. // Note: scope map is already registered by the PipelineStepAnnotation factory above. - var connectedSteps = FilterConnectedSteps(context.Steps); + var connectedSteps = FilterConnectedSteps(context.Steps, workflow.Name); var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow); // Generate the YAML model @@ -268,9 +270,14 @@ private static string FindStageName(GitHubActionsWorkflowResource workflow, GitH /// A step is "connected" if it has dependencies, is depended upon by other steps, /// or has explicit scheduling. Steps like "publish-manifest", "diagnostics", and /// "pipeline-init" are standalone CLI tools and should not be scheduled into CI jobs. + /// Synthetic scheduling-target steps from prior runs (matching gha-{workflowName}-*-job) + /// are also excluded to prevent circular dependencies when running pipeline init again. /// - private static List FilterConnectedSteps(IReadOnlyList steps) + private static List FilterConnectedSteps(IReadOnlyList steps, string workflowName) { + var syntheticPrefix = $"gha-{workflowName}-"; + const string syntheticSuffix = "-job"; + // Build set of step names that are depended upon var dependedUpon = new HashSet(StringComparer.Ordinal); foreach (var step in steps) @@ -288,6 +295,13 @@ private static List FilterConnectedSteps(IReadOnlyList(); foreach (var step in steps) { + // Exclude synthetic scheduling-target steps from prior runs + if (step.Name.StartsWith(syntheticPrefix, StringComparison.Ordinal) && + step.Name.EndsWith(syntheticSuffix, StringComparison.Ordinal)) + { + continue; + } + // A step is connected if: // - It depends on other steps // - Other steps depend on it (via DependsOn or RequiredBy) From 53ab5fc3ef6cec86076c570ba8d83401d15f996f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 20:44:06 +1100 Subject: [PATCH 35/39] Fix artifact upload path: use global deployments dir instead of workspace-relative The YAML generator hardcoded '.aspire/state/...' (workspace-relative) for artifact upload/download, but FileDeploymentStateManager writes state to $HOME/.aspire/deployments/{sha}/{env}.json (global path). The upload step found nothing because the runtime never writes to the workspace path. Changed StatePathExpression to '${{ env.HOME }}/.aspire/deployments/' which resolves to the actual runtime state directory in GitHub Actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkflowYamlGenerator.cs | 4 +++- ...tTests.BareWorkflow_SingleDefaultJob.verified.txt | 2 +- ...napshotTests.CustomRunsOn_WindowsJob.verified.txt | 2 +- ...hotTests.ThreeJobDiamond_FanOutAndIn.verified.txt | 12 ++++++------ ...tTests.TwoJobPipeline_BuildAndDeploy.verified.txt | 6 +++--- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index 9e68afc805f..bb4594a7094 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -14,7 +14,9 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; internal static class WorkflowYamlGenerator { private const string StateArtifactPrefix = "aspire-do-state-"; - private const string StatePathExpression = ".aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/"; + // Must match the path used by FileDeploymentStateManager at runtime: + // $HOME/.aspire/deployments/{appHostPathSha256}/{environment}.json + private const string StatePathExpression = "${{ env.HOME }}/.aspire/deployments/"; /// /// Generates a workflow YAML model from the scheduling result. 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 index e4c45b261e3..3862c3cef94 100644 --- 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 @@ -35,5 +35,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-default - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' 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 index 81a3a4e0ce2..49edd06322a 100644 --- 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 @@ -36,5 +36,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build-win - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' 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 index 88bfbded700..e5d92c9c466 100644 --- 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 @@ -36,7 +36,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' if-no-files-found: ignore test: @@ -58,7 +58,7 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -67,7 +67,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-test - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' if-no-files-found: ignore deploy: @@ -89,12 +89,12 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' - name: Download state from test uses: actions/download-artifact@v4 with: name: aspire-do-state-test - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -103,5 +103,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' 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 index 70fdfa1517a..f5490103e60 100644 --- 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 @@ -36,7 +36,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' if-no-files-found: ignore deploy: @@ -58,7 +58,7 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -67,5 +67,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: '.aspire/state/${{ github.run_id }}-${{ github.run_attempt }}/' + path: '${{ env.HOME }}/.aspire/deployments/' if-no-files-found: ignore From 88a819a1608180cd8eb8714080303f509e08b7b4 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 21:16:03 +1100 Subject: [PATCH 36/39] Fix YAML quoting of GitHub Actions expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YamlQuote() was wrapping values containing ${{ }} in single quotes, which causes GitHub Actions to treat them as literal strings instead of evaluating the expression. This broke artifact upload/download paths like ${{ env.HOME }}/.aspire/deployments/ — the path was interpreted literally, causing uploads from the workspace root. Now values containing ${{ are returned unquoted so the runner evaluates the expression before passing it to the action. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Yaml/WorkflowYamlSerializer.cs | 7 +++++++ ...tTests.BareWorkflow_SingleDefaultJob.verified.txt | 2 +- ...napshotTests.CustomRunsOn_WindowsJob.verified.txt | 2 +- ...hotTests.ThreeJobDiamond_FanOutAndIn.verified.txt | 12 ++++++------ ...tTests.TwoJobPipeline_BuildAndDeploy.verified.txt | 6 +++--- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs index 1a2d248cdda..b855be5cecf 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/Yaml/WorkflowYamlSerializer.cs @@ -237,6 +237,13 @@ private static void WriteRunStep(StringBuilder sb, StepYaml step, bool leadWithD private static string YamlQuote(string value) { + // GitHub Actions expressions (${{ ... }}) must NOT be single-quoted or they + // are treated as literal strings instead of being evaluated. + if (value.Contains("${{")) + { + return value; + } + if (value.Contains('\'') || value.Contains('"') || value.Contains(':') || value.Contains('#') || value.Contains('{') || value.Contains('}') || value.Contains('[') || value.Contains(']') || value.Contains('&') || 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 index 3862c3cef94..58119bed928 100644 --- 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 @@ -35,5 +35,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-default - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ 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 index 49edd06322a..5ee7b2c5b57 100644 --- 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 @@ -36,5 +36,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build-win - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ 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 index e5d92c9c466..91ba99ad68f 100644 --- 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 @@ -36,7 +36,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ if-no-files-found: ignore test: @@ -58,7 +58,7 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -67,7 +67,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-test - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ if-no-files-found: ignore deploy: @@ -89,12 +89,12 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ - name: Download state from test uses: actions/download-artifact@v4 with: name: aspire-do-state-test - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -103,5 +103,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ 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 index f5490103e60..f1324633808 100644 --- 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 @@ -36,7 +36,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ if-no-files-found: ignore deploy: @@ -58,7 +58,7 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -67,5 +67,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: '${{ env.HOME }}/.aspire/deployments/' + path: ${{ env.HOME }}/.aspire/deployments/ if-no-files-found: ignore From 2309a1c5bfd23a2016d0416d26c0f490055330e5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 30 Mar 2026 22:04:42 +1100 Subject: [PATCH 37/39] =?UTF-8?q?Normalize=20RequiredBy=E2=86=92DependsOn?= =?UTF-8?q?=20before=20scheduling=20to=20prevent=20cycles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SchedulingResolver only inspects DependsOnSteps when building its reverse dependency map. Steps using RequiredBy (like docker-compose publish/deploy steps) had invisible edges, causing incorrect job assignments. When combined with explicit stage scheduling, this led to circular job dependencies (deploy-default → build-default → deploy-default). The fix normalizes RequiredBy relationships into DependsOn before calling the scheduler, matching what the pipeline does later in ExecuteAsync. This gives the scheduler full visibility of the DAG. Added test covering the docker-compose two-stage scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GitHubActionsWorkflowExtensions.cs | 26 +++++++ .../SchedulingResolverTests.cs | 72 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs index c77e1b23f7c..4a737af5cc9 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/GitHubActionsWorkflowExtensions.cs @@ -79,6 +79,11 @@ public static IResourceBuilder AddGitHubActionsWo return []; } + // Normalize RequiredBy→DependsOn before scheduling. The pipeline does this + // later in ExecuteAsync, but the scheduler needs the full dependency graph + // (including RequiredBy-derived edges) to compute correct job assignments. + NormalizeRequiredByToDependsOn(connectedSteps); + // Run the scheduler to compute job assignments and terminal steps var scheduling = SchedulingResolver.Resolve(connectedSteps, workflow); @@ -317,4 +322,25 @@ private static List FilterConnectedSteps(IReadOnlyList + /// Converts RequiredBy relationships to their equivalent DependsOn relationships. + /// If step A is required by step B, this adds step A as a dependency of step B. + /// This must be done before scheduling so the resolver sees the full dependency graph. + /// + private static void NormalizeRequiredByToDependsOn(List steps) + { + var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal); + foreach (var step in steps) + { + foreach (var requiredByStepName in step.RequiredBySteps) + { + if (stepsByName.TryGetValue(requiredByStepName, out var requiredByStep) && + !requiredByStep.DependsOnSteps.Contains(step.Name)) + { + requiredByStep.DependsOnSteps.Add(step.Name); + } + } + } + } } diff --git a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs index 41ac7e40eb7..b55525d71b6 100644 --- a/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs +++ b/tests/Aspire.Hosting.Pipelines.GitHubActions.Tests/SchedulingResolverTests.cs @@ -423,6 +423,66 @@ public void Resolve_FanOut_UnscheduledPulledToFirstConsumer() Assert.Same(job1, result.StepToJob["A"]); } + [Fact] + public void Resolve_RequiredByWithTwoStages_NoCycleAfterNormalization() + { + // Simulates the docker-compose scenario: + // publish-env RequiredBy "publish" (scheduled to stage1) + // prepare-env DependsOn "publish", DependsOn "build" + // docker-compose-up-env DependsOn "prepare-env", RequiredBy "deploy" (scheduled to stage2) + // + // Before normalization, the RequiredBy edges are invisible to the scheduler, + // causing incorrect job assignments. After normalization, the scheduler should + // correctly see that "publish" depends on "publish-env" and "deploy" depends + // on "docker-compose-up-env". + var workflow = new GitHubActionsWorkflowResource("ci"); + var stage1 = workflow.AddStage("build"); + var stage2 = workflow.AddStage("deploy"); + + var publishEnv = CreateStepWithRequiredBy("publish-env", "publish"); + var publish = CreateStep("publish", stage1); + var build = CreateStep("build", stage1); + var prepareEnv = CreateStep("prepare-env", scheduledBy: null, dependsOn: ["publish", "build"]); + var dockerComposeUp = CreateStepWithRequiredBy("docker-compose-up-env", "deploy"); + dockerComposeUp.DependsOnSteps.Add("prepare-env"); + var deploy = CreateStep("deploy", stage2); + + var steps = new List { publishEnv, publish, build, prepareEnv, dockerComposeUp, deploy }; + + // Normalize RequiredBy→DependsOn (same as GitHubActionsWorkflowExtensions does) + var stepsByName = steps.ToDictionary(s => s.Name, StringComparer.Ordinal); + foreach (var step in steps) + { + foreach (var requiredByStepName in step.RequiredBySteps) + { + if (stepsByName.TryGetValue(requiredByStepName, out var requiredByStep) && + !requiredByStep.DependsOnSteps.Contains(step.Name)) + { + requiredByStep.DependsOnSteps.Add(step.Name); + } + } + } + + // After normalization: publish depends on publish-env, deploy depends on docker-compose-up-env + Assert.Contains("publish-env", publish.DependsOnSteps); + Assert.Contains("docker-compose-up-env", deploy.DependsOnSteps); + + // Should not throw SchedulingValidationException (no cycle) + var result = SchedulingResolver.Resolve(steps, workflow); + + // publish and build are in stage1 (build-default job) + Assert.Equal("build-default", result.StepToJob["publish"].Id); + Assert.Equal("build-default", result.StepToJob["build"].Id); + Assert.Equal("build-default", result.StepToJob["publish-env"].Id); + + // deploy is in stage2 (deploy-default job) + Assert.Equal("deploy-default", result.StepToJob["deploy"].Id); + + // deploy-default should depend on build-default (not the other way around) + Assert.True(result.JobDependencies.TryGetValue("deploy-default", out var deployDeps)); + Assert.Contains("build-default", deployDeps); + } + // Helper methods private static PipelineStep CreateStep(string name, IPipelineStepTarget? scheduledBy = null) @@ -456,4 +516,16 @@ private static PipelineStep CreateStep(string name, IPipelineStepTarget? schedul ScheduledBy = scheduledBy }; } + + private static PipelineStep CreateStepWithRequiredBy(string name, string requiredBy, IPipelineStepTarget? scheduledBy = null) + { + var step = new PipelineStep + { + Name = name, + Action = _ => Task.CompletedTask, + ScheduledBy = scheduledBy + }; + step.RequiredBy(requiredBy); + return step; + } } From b1f84f2afb55768d9249bc686af1803e65981584 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 31 Mar 2026 10:46:50 +1100 Subject: [PATCH 38/39] Run config callbacks before pipeline environment scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split CollectStepsFromAnnotationsAsync into two phases: 1. CollectResourceStepsAsync — collects steps from non-environment resources 2. ExecuteConfigurationCallbacksAsync — wires up cross-step deps (push→build) 3. CollectEnvironmentStepsAsync — runs scheduling with full dependency graph Previously, the GHA scheduling resolver ran during step collection (phase 1 + 2 combined), before configuration callbacks had a chance to wire up dependencies like push-X.DependsOn(build-X). This caused push steps to be orphaned and assigned to the wrong job, leading to Docker tag failures in CI (image not yet built). Now the scheduler sees the complete dependency graph including edges from configuration callbacks, producing correct job assignments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationPipeline.cs | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 4745982a29d..a24e6846121 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -443,12 +443,18 @@ public async Task GetEnvironmentAsync(CancellationToken ca public async Task ExecuteAsync(PipelineContext context) { - var annotationSteps = await CollectStepsFromAnnotationsAsync(context, _steps).ConfigureAwait(false); - var allSteps = _steps.Concat(annotationSteps).ToList(); + var (resourceSteps, deferredAnnotations) = await CollectResourceStepsAsync(context, _steps).ConfigureAwait(false); + var allStepsSoFar = _steps.Concat(resourceSteps).ToList(); - // Execute configuration callbacks even if there are no steps - // This allows callbacks to run validation or other logic - await ExecuteConfigurationCallbacksAsync(context, allSteps).ConfigureAwait(false); + // Execute configuration callbacks before the deferred (pipeline environment) pass. + // Config callbacks wire up cross-step dependencies (e.g., push-X.DependsOn(build-X)) + // that the scheduling resolver needs to see for correct job assignments. + await ExecuteConfigurationCallbacksAsync(context, allStepsSoFar).ConfigureAwait(false); + + // Deferred pass: pipeline environment annotations (e.g., GHA workflow) run + // scheduling over the fully-wired step graph, including config callback edges. + var environmentSteps = await CollectEnvironmentStepsAsync(context, deferredAnnotations, allStepsSoFar).ConfigureAwait(false); + var allSteps = allStepsSoFar.Concat(environmentSteps).ToList(); if (allSteps.Count == 0) { @@ -628,12 +634,15 @@ void Visit(string stepName) return result; } - private static async Task> CollectStepsFromAnnotationsAsync(PipelineContext context, IReadOnlyList existingSteps) + /// + /// First pass: collects steps from non-pipeline-environment resource annotations. + /// Returns the collected steps and any deferred (pipeline environment) annotations for the second pass. + /// + private static async Task<(List Steps, List<(IResource Resource, PipelineStepAnnotation Annotation)> DeferredAnnotations)> CollectResourceStepsAsync(PipelineContext context, IReadOnlyList existingSteps) { var steps = new List(); var deferredAnnotations = new List<(IResource Resource, PipelineStepAnnotation Annotation)>(); - // First pass: collect steps from non-pipeline-environment resources foreach (var resource in context.Model.Resources) { var annotations = resource.Annotations @@ -664,11 +673,23 @@ private static async Task> CollectStepsFromAnnotationsAsync(P } } - // Second pass: pipeline environment annotations get full visibility of all collected steps. - // This enables workflow resources to run scheduling and create synthetic steps. + return (steps, deferredAnnotations); + } + + /// + /// Second pass: collects steps from deferred pipeline environment annotations. + /// These annotations run scheduling over the fully-wired step graph (including + /// configuration callback edges) so that job assignments are correct. + /// + private static async Task> CollectEnvironmentStepsAsync( + PipelineContext context, + List<(IResource Resource, PipelineStepAnnotation Annotation)> deferredAnnotations, + List allStepsSoFar) + { + var steps = new List(); + foreach (var (resource, annotation) in deferredAnnotations) { - var allStepsSoFar = existingSteps.Concat(steps).ToList(); var factoryContext = new PipelineStepFactoryContext { PipelineContext = context, From 34c2f98a134cf9d76371f790f6b211f88f5b507b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 31 Mar 2026 11:35:30 +1100 Subject: [PATCH 39/39] Fix artifact upload/download: use workspace-relative staging path upload-artifact@v4 does NOT evaluate ${{ env.HOME }} or other env var expressions in the `with.path` parameter. Values are treated as literal strings, so the artifacts were being uploaded from/to the wrong location. Fix: use a workspace-relative staging directory (.aspire-state-staging) for artifact upload/download, with run steps that copy state to/from the real $HOME/.aspire/deployments/ path using shell expansion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WorkflowYamlGenerator.cs | 29 +++++++++++++++---- ...BareWorkflow_SingleDefaultJob.verified.txt | 4 ++- ...Tests.CustomRunsOn_WindowsJob.verified.txt | 4 ++- ...s.ThreeJobDiamond_FanOutAndIn.verified.txt | 22 ++++++++++---- ...TwoJobPipeline_BuildAndDeploy.verified.txt | 12 ++++++-- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs index bb4594a7094..5385d3e9fec 100644 --- a/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs +++ b/src/Aspire.Hosting.Pipelines.GitHubActions/WorkflowYamlGenerator.cs @@ -14,9 +14,12 @@ namespace Aspire.Hosting.Pipelines.GitHubActions; internal static class WorkflowYamlGenerator { private const string StateArtifactPrefix = "aspire-do-state-"; - // Must match the path used by FileDeploymentStateManager at runtime: - // $HOME/.aspire/deployments/{appHostPathSha256}/{environment}.json - private const string StatePathExpression = "${{ env.HOME }}/.aspire/deployments/"; + // Workspace-relative staging path for artifact upload/download. + // upload-artifact@v4 does NOT evaluate ${{ env.HOME }} or other env vars in + // the `path` parameter, so we must use a workspace-relative path and copy + // to/from the real state directory ($HOME/.aspire/deployments/) via run steps. + private const string StateStagingPath = ".aspire-state-staging"; + private const string StateRealPath = "$HOME/.aspire/deployments"; /// /// Generates a workflow YAML model from the scheduling result. @@ -138,7 +141,7 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul }); } - // Download state artifacts from dependency jobs + // Download state artifacts from dependency jobs and restore to real path var jobDeps = scheduling.JobDependencies.GetValueOrDefault(job.Id); if (jobDeps is { Count: > 0 }) { @@ -151,10 +154,17 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul With = new Dictionary { ["name"] = $"{StateArtifactPrefix}{depJobId}", - ["path"] = StatePathExpression + ["path"] = StateStagingPath } }); } + + // Restore downloaded state from workspace-relative staging to real path + steps.Add(new StepYaml + { + Name = "Restore deployment state", + Run = $"mkdir -p {StateRealPath} && cp -r {StateStagingPath}/. {StateRealPath}/" + }); } // Run aspire do targeting the synthetic scheduling step for this job. @@ -173,6 +183,13 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul } }); + // Stage deployment state from real path to workspace-relative dir for upload + steps.Add(new StepYaml + { + Name = "Stage deployment state", + Run = $"mkdir -p {StateStagingPath} && cp -r {StateRealPath}/. {StateStagingPath}/ 2>/dev/null || true" + }); + // Upload state artifacts for downstream jobs steps.Add(new StepYaml { @@ -181,7 +198,7 @@ private static JobYaml GenerateJob(GitHubActionsJobResource job, SchedulingResul With = new Dictionary { ["name"] = $"{StateArtifactPrefix}{job.Id}", - ["path"] = StatePathExpression, + ["path"] = StateStagingPath, ["if-no-files-found"] = "ignore" } }); 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 index 58119bed928..733c718b935 100644 --- 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 @@ -31,9 +31,11 @@ jobs: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-deploy-default-stage-default-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-default - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging 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 index 5ee7b2c5b57..e8f71fa01a3 100644 --- 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 @@ -32,9 +32,11 @@ jobs: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-build-windows-default-stage-build-win-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-build-win - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging 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 index 91ba99ad68f..d4a86853e8c 100644 --- 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 @@ -32,11 +32,13 @@ jobs: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-ci-cd-default-stage-build-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging if-no-files-found: ignore test: @@ -58,16 +60,20 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging + - name: Restore deployment state + run: mkdir -p $HOME/.aspire/deployments && cp -r .aspire-state-staging/. $HOME/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-ci-cd-default-stage-test-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-test - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging if-no-files-found: ignore deploy: @@ -89,19 +95,23 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging - name: Download state from test uses: actions/download-artifact@v4 with: name: aspire-do-state-test - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging + - name: Restore deployment state + run: mkdir -p $HOME/.aspire/deployments && cp -r .aspire-state-staging/. $HOME/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-ci-cd-default-stage-deploy-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging 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 index f1324633808..4297acf57f6 100644 --- 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 @@ -32,11 +32,13 @@ jobs: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-deploy-default-stage-build-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-build - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging if-no-files-found: ignore deploy: @@ -58,14 +60,18 @@ jobs: uses: actions/download-artifact@v4 with: name: aspire-do-state-build - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging + - name: Restore deployment state + run: mkdir -p $HOME/.aspire/deployments && cp -r .aspire-state-staging/. $HOME/.aspire/deployments/ - name: Run pipeline steps env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 run: aspire do gha-deploy-default-stage-deploy-job + - name: Stage deployment state + run: mkdir -p .aspire-state-staging && cp -r $HOME/.aspire/deployments/. .aspire-state-staging/ 2>/dev/null || true - name: Upload state uses: actions/upload-artifact@v4 with: name: aspire-do-state-deploy - path: ${{ env.HOME }}/.aspire/deployments/ + path: .aspire-state-staging if-no-files-found: ignore