diff --git a/.github/workflows/verify-telemetry-hook-changes.yml b/.github/workflows/verify-telemetry-hook-changes.yml index 6bf34fe1c67..975b7471bc6 100644 --- a/.github/workflows/verify-telemetry-hook-changes.yml +++ b/.github/workflows/verify-telemetry-hook-changes.yml @@ -31,6 +31,12 @@ jobs: 'src/Aspire.Cli/Agents/Hooks/track-telemetry.sh', 'src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1' ) + $bundleDirectory = 'src/Aspire.Cli/Agents/AspireSkills/Embedded' + $requiredBundlePaths = @( + 'src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs', + 'src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json', + 'src/Aspire.Cli/Aspire.Cli.csproj' + ) # Disable rename detection so both the removed and added paths are checked. $changedPaths = @(& git --no-pager diff --no-ext-diff --no-textconv --no-renames --name-only 'HEAD^1' HEAD -- @protectedPaths) @@ -39,7 +45,24 @@ jobs: } if ($changedPaths.Count -gt 0 -and $env:PR_HEAD_REF -cne $allowedHeadRef) { - throw "Telemetry hook scripts can only change in PRs whose head branch is '$allowedHeadRef'. Make changes in microsoft/aspire-skills and use the synchronization workflow instead of editing these copies manually." + # A feature PR may consume a newly published bundle directly, but only when it carries + # every generated companion change. The separate bundle-verification workflow checks + # the archive attestation and the hook bytes against the pinned aspire-skills commit. + $bundleChangedPaths = @(& git --no-pager diff --no-ext-diff --no-textconv --no-renames --name-only 'HEAD^1' HEAD -- @requiredBundlePaths $bundleDirectory) + if ($LASTEXITCODE -ne 0) { + throw 'Could not inspect the generated Aspire skills bundle changes.' + } + + $missingBundlePaths = @($requiredBundlePaths | Where-Object { $_ -notin $bundleChangedPaths }) + $archiveChanged = $bundleChangedPaths | Where-Object { + $_ -match '^src/Aspire\.Cli/Agents/AspireSkills/Embedded/aspire-skills-.*\.(zip|tar\.gz|tgz)$' + } + + if ($missingBundlePaths.Count -gt 0 -or -not $archiveChanged) { + throw "Telemetry hook scripts can only change on '$allowedHeadRef' or as part of a complete generated bundle update. Make changes in microsoft/aspire-skills and run the synchronization workflow instead of editing these copies manually." + } + + Write-Host "Telemetry hook changes are part of a complete generated bundle update; canonical content is checked separately." } Write-Host 'Telemetry hook branch policy satisfied. Canonical content is checked separately.' diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index 81695234f80..a93ba4105e0 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -30,7 +30,7 @@ internal sealed class AspireSkillsInstaller( AspireCliTelemetry telemetry, ILogger logger) : IAspireSkillsInstaller { - internal const string Version = "0.0.2"; + internal const string Version = "0.0.3"; internal const string GitHubRepository = "microsoft/aspire-skills"; internal const string ExpectedSourceRepository = $"https://github.com/{GitHubRepository}"; internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz deleted file mode 100644 index 0336dd345db..00000000000 Binary files a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz and /dev/null differ diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz new file mode 100644 index 00000000000..90c023efc1d Binary files /dev/null and b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz differ diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json index 0e4ae0c4d17..f9eeb0afcac 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -1,14 +1,14 @@ { - "version": "0.0.2", + "version": "0.0.3", "repository": "microsoft/aspire-skills", - "tag": "v0.0.2", - "assetName": "aspire-skills-v0.0.2.tgz", - "sha512": "8caa7a0639477a1d14da01483ff45d963ea157c6b2a6560df26d65eac991cb469e01cbdef1e3847d153573e61a4fffa15dd51c853a43afe4f1f1bd9fe0265102", + "tag": "v0.0.3", + "assetName": "aspire-skills-v0.0.3.tgz", + "sha512": "a5f8bd971b6aa0556994a6f6003d5a763056f6c64d1d9e6ae7a883ef9408c3d8a7689371c0499d980d4f82a9cd55e37cebd1e182e3cb71fc028380cec0ececad", "hooks": { - "commitSha": "842c4fb8d9ff5f89bf3e5f6f16739a728b5ba77e", + "commitSha": "a83ca78b9d35b8238f9c4fdba72cb779defe2a90", "files": { - "track-telemetry.sh": "73d44c910ac39c196b3cd4e0ff3993bbf05125a75a351e068e8d7a32d4c520f6ec8560397ccd722c21d2710949baeef40fcb25dcebed7314a7b03fd65af78e44", - "track-telemetry.ps1": "5311aba3e0087132d40c460276bd8cf889fc3a7c4dd9e110eea305ed95ef24dd5c837bcff276eb801c44270bba2ebe42e53b99244ec52ffac91f1ecb6b2a6618" + "track-telemetry.sh": "6fa64f350e2709a7abb52842232cc8d584e24d104b17a200a93ca4010d36a838dd1f6f73b83e92cd2ad0d8ad4abf9753323f840dc28625ccaf2f082097667181", + "track-telemetry.ps1": "303dc8d0eba181154222dacf6151d6bbe24614dd0e3144704fab1cb83c81f149e95cac47e87488357f0c04d232629a2f91f5551fe33e5c6c91f25a8f97aa0af3" } } } diff --git a/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs index 26dd4a6a5ff..e6fa8020682 100644 --- a/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs +++ b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs @@ -66,7 +66,7 @@ public async Task ConfigureAsync( // configured here even though they are detected/marked. The Copilot App and CLI share the // same ~/.copilot hook location, so prefer the App display name when both are detected. // This identifies the configuration target, not the hook event's client-name: the canonical - // v0.0.2 scripts report App events as copilot-cli. See https://github.com/microsoft/aspire-skills/issues/71. + // scripts currently report App events as copilot-cli. See https://github.com/microsoft/aspire-skills/issues/71. var supported = new List(); if (detectedClients.Contains(AgentClientKind.CopilotApp)) { diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 index 05b1a4ee67c..ff6c8b3f9a2 100644 --- a/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 +++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 @@ -26,7 +26,7 @@ trap { # Allowlist of Aspire-owned skill names (keep in sync with github.com/microsoft/aspire-skills). # A shared .agents/skills directory can also contain third-party skills, so a path/name is only # treated as Aspire when its skill segment is one of these. -$AspireSkills = @('aspire', 'aspire-init', 'aspireify', 'aspire-orchestration', 'aspire-deployment', 'aspire-monitoring') +$AspireSkills = @('aspire', 'aspire-init', 'aspireify', 'aspire-project-v2-migration', 'aspire-orchestration', 'aspire-deployment', 'aspire-monitoring') $AspireMcpTools = @( 'doctor', @@ -65,6 +65,8 @@ $AspireReferenceFiles = @( 'aspire-orchestration/references/detection.md', 'aspire-orchestration/references/resource-management.md', 'aspire-orchestration/references/safety-guardrails.md', + 'aspire-project-v2-migration/references/compatibility-and-validation.md', + 'aspire-project-v2-migration/references/migration-patterns.md', 'aspire/references/aspire-13-3-breaking-changes.md', 'aspire/references/aspire-13-5-breaking-changes.md', 'aspireify/references/apphost-wiring.md', diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh index 9ccd9755c49..a4f26ccb192 100644 --- a/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh +++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh @@ -66,7 +66,7 @@ trap emit_continue EXIT # github.com/microsoft/aspire-skills). A shared .agents/skills directory can also contain # third-party skills (dotnet-inspect, playwright, ...), so a path/name is only treated as # Aspire when its skill segment is one of these. -ASPIRE_SKILLS="aspire aspire-init aspireify aspire-orchestration aspire-deployment aspire-monitoring" +ASPIRE_SKILLS="aspire aspire-init aspireify aspire-project-v2-migration aspire-orchestration aspire-deployment aspire-monitoring" ASPIRE_MCP_TOOLS="doctor execute_resource_command get_doc list_apphosts list_console_logs list_docs list_integrations list_resources list_structured_logs list_trace_structured_logs list_traces refresh_tools search_docs select_apphost" @@ -93,6 +93,8 @@ aspire-orchestration/references/app-commands.md aspire-orchestration/references/detection.md aspire-orchestration/references/resource-management.md aspire-orchestration/references/safety-guardrails.md +aspire-project-v2-migration/references/compatibility-and-validation.md +aspire-project-v2-migration/references/migration-patterns.md aspire/references/aspire-13-3-breaking-changes.md aspire/references/aspire-13-5-breaking-changes.md aspireify/references/apphost-wiring.md diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 47b1fa4919f..05d86d6fc3e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -374,7 +374,7 @@ - + false diff --git a/src/Aspire.Hosting.Dotnet/README.md b/src/Aspire.Hosting.Dotnet/README.md index 9d5dcf8c07a..9c7b46b8646 100644 --- a/src/Aspire.Hosting.Dotnet/README.md +++ b/src/Aspire.Hosting.Dotnet/README.md @@ -229,6 +229,14 @@ Call `PublishAsDockerFile(...)` or `publishAsDockerFile(...)` to use an explicit container publishing. Call `ExcludeFromManifest()` or `excludeFromManifest()` when the resource is intentionally available only during local orchestration. +## Migrating from legacy project resources + +For C# and TypeScript AppHosts targeting Aspire 13.6 or newer, the +[`aspire-project-v2-migration` agent skill](https://github.com/microsoft/aspire-skills/tree/main/skills/aspire-project-v2-migration) +assesses existing project resources and proposes a migration for approval before editing. +It preserves supported resource configuration and identifies unsupported patterns, including +Azure Functions and custom integrations tied to `ProjectResource`. It does not upgrade the AppHost's Aspire version. + ## Additional documentation - https://aspire.dev/integrations/gallery/ diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 5b997d03124..8b99e2d3da0 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -1384,8 +1384,11 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs try { + const string migrationSkillName = "aspire-project-v2-migration"; var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var embeddedBundleProvider = new EmbeddedAspireSkillsBundleProvider( + new AspireSkillsBundleProvider(executionContext), + NullLogger.Instance); var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); // Throw on any HTTP call so we can prove the GitHub path was never invoked. var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); @@ -1401,8 +1404,33 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.True(embeddedBundleProvider.CreateBundleCalled); + Assert.Equal(AspireSkillsInstaller.Version, result.Bundle.Version); Assert.False(attestationVerifier.VerifyCalled); + var migrationSkill = Assert.Single( + result.Bundle.GetSkillDefinitions(), + skill => skill.HasName(migrationSkillName)); + Assert.True(migrationSkill.IsDefault); + Assert.Empty(migrationSkill.ApplicableLanguages); + Assert.Equal(["evals"], migrationSkill.InstallExcludedRelativePaths); + + var migrationFiles = await result.Bundle.GetSkillFilesAsync(migrationSkill, CancellationToken.None); + Assert.Collection( + migrationFiles.OrderBy(file => file.RelativePath, StringComparer.Ordinal), + skillFile => + { + Assert.Equal("SKILL.md", skillFile.RelativePath); + Assert.Contains("# Aspire Project v2 migration", skillFile.Content, StringComparison.Ordinal); + }, + compatibilityReference => + { + Assert.Equal(Path.Combine("references", "compatibility-and-validation.md"), compatibilityReference.RelativePath); + Assert.Contains("# Project v2 compatibility and validation", compatibilityReference.Content, StringComparison.Ordinal); + }, + migrationPatternsReference => + { + Assert.Equal(Path.Combine("references", "migration-patterns.md"), migrationPatternsReference.RelativePath); + Assert.Contains("# Project v2 migration patterns", migrationPatternsReference.Content, StringComparison.Ordinal); + }); } finally { diff --git a/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs index fdb04caab2e..d740d3d4efe 100644 --- a/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs @@ -24,20 +24,24 @@ public class TelemetryHookScriptTests(ITestOutputHelper outputHelper) private const string CaptureFileEnvName = "ASPIRE_HOOK_TEST_CAPTURE_FILE"; private const string ContinueResponse = """{"continue":true}"""; - [Fact] + [Theory] + [InlineData("aspire")] + [InlineData("aspire-project-v2-migration")] [RequiresTools(["bash"])] [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")] - public async Task Bash_SkillInvocation_Copilot_ForwardsSkillName() + public async Task Bash_SkillInvocation_Copilot_ForwardsSkillName(string skillName) { + var payload = """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"__SKILL_NAME__"}}""" + .Replace("__SKILL_NAME__", skillName, StringComparison.Ordinal); var run = await RunBashHookAsync( - """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"aspire"}}""", + payload, new() { ["COPILOT_CLI"] = "1" }); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); AssertArg(args, "--client-name", "copilot-cli"); - AssertArg(args, "--skill-name", "aspire"); + AssertArg(args, "--skill-name", skillName); AssertArg(args, "--session-id", "11111111-2222-3333-4444-555555555555"); } @@ -57,8 +61,7 @@ public async Task Bash_SkillInvocation_CopilotAppMarker_DoesNotProvideDistinctAt AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); - // v0.0.2 ignores AI_AGENT; App attribution must be added upstream, not in these copied scripts. - // https://github.com/microsoft/aspire-skills/issues/71 + // Keep this limitation explicit until https://github.com/microsoft/aspire-skills/issues/71 ships. AssertArg(args, "--client-name", "copilot-cli"); AssertArg(args, "--skill-name", "aspire"); } @@ -95,19 +98,23 @@ public async Task Bash_McpTool_Claude_ForwardsToolName() AssertArg(args, "--tool-name", "mcp__aspire__list_resources"); } - [Fact] + [Theory] + [InlineData("aspire-deployment/references/azure.md")] + [InlineData("aspire-project-v2-migration/references/compatibility-and-validation.md")] + [InlineData("aspire-project-v2-migration/references/migration-patterns.md")] [RequiresTools(["bash"])] [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")] - public async Task Bash_ReferenceFileRead_ForwardsRelativePath() + public async Task Bash_ReferenceFileRead_ForwardsRelativePath(string referencePath) { - var run = await RunBashHookAsync( - """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire-deployment/references/azure.md"}}"""); + var payload = """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/__REFERENCE_PATH__"}}""" + .Replace("__REFERENCE_PATH__", referencePath, StringComparison.Ordinal); + var run = await RunBashHookAsync(payload); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "reference_file_read"); // Only the repo-relative path after skills// is forwarded — never the absolute path. - AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); + AssertArg(args, "--file-reference", referencePath); } [Theory] @@ -272,19 +279,23 @@ public async Task Bash_ReferenceFileRead_CopilotStringArgs_ForwardsRelativePath( AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); } - [Fact] + [Theory] + [InlineData("aspire")] + [InlineData("aspire-project-v2-migration")] [RequiresTools(["pwsh"])] - public async Task Pwsh_SkillInvocation_Copilot_ForwardsSkillName() + public async Task Pwsh_SkillInvocation_Copilot_ForwardsSkillName(string skillName) { + var payload = """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"__SKILL_NAME__"}}""" + .Replace("__SKILL_NAME__", skillName, StringComparison.Ordinal); var run = await RunPwshHookAsync( - """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"aspire"}}""", + payload, new() { ["COPILOT_CLI"] = "1" }); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); AssertArg(args, "--client-name", "copilot-cli"); - AssertArg(args, "--skill-name", "aspire"); + AssertArg(args, "--skill-name", skillName); AssertArg(args, "--session-id", "11111111-2222-3333-4444-555555555555"); } @@ -371,18 +382,22 @@ public async Task Pwsh_MalformedJson_DoesNotInvokeCli() AssertNotInvoked(run); } - [Fact] + [Theory] + [InlineData("aspire-deployment/references/azure.md")] + [InlineData("aspire-project-v2-migration/references/compatibility-and-validation.md")] + [InlineData("aspire-project-v2-migration/references/migration-patterns.md")] [RequiresTools(["pwsh"])] - public async Task Pwsh_ReferenceFileRead_ForwardsRelativePath() + public async Task Pwsh_ReferenceFileRead_ForwardsRelativePath(string referencePath) { - var run = await RunPwshHookAsync( - """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire-deployment/references/azure.md"}}"""); + var payload = """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/__REFERENCE_PATH__"}}""" + .Replace("__REFERENCE_PATH__", referencePath, StringComparison.Ordinal); + var run = await RunPwshHookAsync(payload); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "reference_file_read"); // Only the repo-relative path after skills// is forwarded — never the absolute path. - AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); + AssertArg(args, "--file-reference", referencePath); } [Theory] diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 54cce1f0c97..ebaff125132 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -86,7 +86,7 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() $" {string.Format( CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, - $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}, {CommonAgentApplicators.AspireifySkillName}")}", + $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}, {FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName}, {CommonAgentApplicators.AspireifySkillName}")}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); Assert.Equal(expectedSummary, message.Message); @@ -155,7 +155,8 @@ public async Task AgentInitCommand_NonInteractive_WithAllLocationsAndSkills_Inst CommonAgentApplicators.AspireDeploymentSkillName, FakeAspireSkillsInstaller.AspireInitSkillName, FakeAspireSkillsInstaller.AspireMonitoringSkillName, - FakeAspireSkillsInstaller.AspireOrchestrationSkillName + FakeAspireSkillsInstaller.AspireOrchestrationSkillName, + FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName }; var expectedSkillDirectories = new[] { @@ -211,6 +212,7 @@ public async Task AgentInitCommand_InteractiveSkillPrompt_IncludesAllBundleSkill Assert.Contains(FakeAspireSkillsInstaller.AspireInitSkillName, promptedSkillNames); Assert.Contains(FakeAspireSkillsInstaller.AspireMonitoringSkillName, promptedSkillNames); Assert.Contains(FakeAspireSkillsInstaller.AspireOrchestrationSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName, promptedSkillNames); } [Fact] @@ -387,6 +389,62 @@ public async Task AgentInitCommand_NonInteractive_WithSpecificBundleSkill_Instal AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".opencode", "skill"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AgentInitCommand_NonInteractive_InstallsProjectV2MigrationSkillWithReferences(bool selectByName) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var homeDirectory = workspace.CreateDirectory("fake-home"); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory); + }); + using var provider = services.BuildServiceProvider(); + + var installer = provider.GetRequiredService(); + var installation = await installer.InstallAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(installation.Bundle); + var skill = Assert.Single( + installation.Bundle.GetSkillDefinitions(), + skill => skill.HasName(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName)); + Assert.True(skill.IsDefault); + Assert.Empty(skill.ApplicableLanguages); + var expectedFiles = await installation.Bundle.GetSkillFilesAsync(skill, TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.Equal( + [ + "SKILL.md", + Path.Combine("references", "compatibility-and-validation.md"), + Path.Combine("references", "migration-patterns.md") + ], + expectedFiles.Select(file => file.RelativePath)); + + var command = provider.GetRequiredService(); + var selection = selectByName ? $" --skills {skill.Name}" : string.Empty; + var result = command.Parse($"agent init --workspace-root \"{workspace.WorkspaceRoot.FullName}\" --skill-locations all{selection}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var skillDirectories = SkillLocation.All + .Select(location => Path.Combine(workspace.WorkspaceRoot.FullName, location.RelativeSkillDirectory, skill.Name)) + .Append(Path.Combine(homeDirectory.FullName, SkillLocation.Standard.RelativeSkillDirectory, skill.Name)); + foreach (var skillDirectory in skillDirectories) + { + Assert.Equal( + expectedFiles.Select(file => file.RelativePath), + Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(skillDirectory, path)) + .Order(StringComparer.Ordinal)); + foreach (var file in expectedFiles) + { + Assert.Equal( + file.Content, + await File.ReadAllTextAsync(Path.Combine(skillDirectory, file.RelativePath), TestContext.Current.CancellationToken).DefaultTimeout()); + } + } + } + [Fact] public async Task AgentInitCommand_NonInteractive_WithCliDefinedSkillDifferentCasing_DoesNotResolveBundle() { @@ -514,6 +572,7 @@ public async Task AgentInitCommand_NonInteractive_WithoutSkills_UsesDefaultSkill AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); } @@ -616,6 +675,7 @@ public async Task AgentInitCommand_NonInteractive_WithoutWorkspaceRoot_UsesWorki AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); } @@ -710,7 +770,9 @@ public async Task PromptAndChainAsync_PreSelectsBundleDefaultsIncludingAspireify Assert.Equal(CliExitCodes.Success, result.ExitCode); Assert.Contains(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + Assert.Contains(result.SelectedSkills, static skill => skill.HasName(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName)); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index 72807372e5a..130b999cab4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -51,6 +51,7 @@ internal sealed class FakeAspireSkillsInstaller : IAspireSkillsInstaller internal const string AspireInitSkillName = "aspire-init"; internal const string AspireMonitoringSkillName = "aspire-monitoring"; internal const string AspireOrchestrationSkillName = "aspire-orchestration"; + internal const string AspireProjectV2MigrationSkillName = "aspire-project-v2-migration"; private readonly DirectoryInfo _bundleDirectory; private readonly AspireSkillsInstallResult? _result; @@ -143,7 +144,19 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) --- # Aspire Orchestration + """, + [(AspireProjectV2MigrationSkillName, "SKILL.md")] = """ + --- + name: aspire-project-v2-migration + description: "Migrate approved project resources in Aspire 13.6 or newer AppHosts" + --- + + # Project v2 migration + """, + [(AspireProjectV2MigrationSkillName, Path.Combine("references", "migration-patterns.md"))] = "# Migration patterns", + [(AspireProjectV2MigrationSkillName, Path.Combine("references", "compatibility-and-validation.md"))] = "# Compatibility and validation", + [(AspireProjectV2MigrationSkillName, Path.Combine("evals", "eval.yaml"))] = "stimuli: []" }; foreach (var ((skillName, relativePath), content) in files) @@ -168,7 +181,8 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, ["evals"], files), CreateSkill(AspireInitSkillName, ["evals"], files), CreateSkill(AspireMonitoringSkillName, ["evals"], files), - CreateSkill(AspireOrchestrationSkillName, ["evals"], files) + CreateSkill(AspireOrchestrationSkillName, ["evals"], files), + CreateSkill(AspireProjectV2MigrationSkillName, ["evals"], files) ] }; diff --git a/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs new file mode 100644 index 00000000000..7d68363abaa --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests; + +public sealed class VerifyTelemetryHookChangesWorkflowTests(ITestOutputHelper output) +{ + private const string WorkflowRelativePath = ".github/workflows/verify-telemetry-hook-changes.yml"; + private const string ProtectedHookPath = "src/Aspire.Cli/Agents/Hooks/track-telemetry.sh"; + private const string AspireSkillsInstallerPath = "src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs"; + private const string AspireSkillsMetadataPath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json"; + private const string AspireCliProjectPath = "src/Aspire.Cli/Aspire.Cli.csproj"; + private const string OldArchivePath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz"; + private const string NewArchivePath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz"; + private const string ArchiveChange = "archive"; + private const string SynchronizationBranch = "update-aspire-skills-bundle"; + + private static readonly string[] s_requiredBundlePaths = + [ + AspireSkillsInstallerPath, + AspireSkillsMetadataPath, + AspireCliProjectPath, + ]; + + [Fact] + public void WorkflowProvidesMergeResultHistoryAndPullRequestHead() + { + var root = LoadWorkflow(); + var job = Mapping(Mapping(root, "jobs"), "verify"); + var steps = Sequence(job, "steps").Children.Cast().ToList(); + + var checkout = Assert.Single(steps, step => ScalarOrNull(step, "uses")?.StartsWith("actions/checkout@", StringComparison.Ordinal) == true); + var checkoutOptions = Mapping(checkout, "with"); + Assert.Equal("${{ github.sha }}", Scalar(checkoutOptions, "ref")); + Assert.Equal("2", Scalar(checkoutOptions, "fetch-depth")); + Assert.Equal("false", Scalar(checkoutOptions, "persist-credentials")); + + var verify = GetVerifyStep(steps); + Assert.Equal("pwsh", Scalar(verify, "shell")); + Assert.Equal("${{ github.head_ref }}", Scalar(Mapping(verify, "env"), "PR_HEAD_REF")); + Assert.False(string.IsNullOrWhiteSpace(Scalar(verify, "run"))); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchRejectsHookOnlyChange() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + CommitAll(workspace, "Change telemetry hook only"); + + var result = await RunPolicyAsync(workspace, "feature/hook-only"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Telemetry hook scripts can only change", result.Output, StringComparison.Ordinal); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchAcceptsCompleteBundleRefresh() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + foreach (var path in s_requiredBundlePaths) + { + ChangeFile(workspace, path); + } + ReplaceArchive(workspace); + CommitAll(workspace, "Refresh complete Aspire skills bundle"); + + var result = await RunPolicyAsync(workspace, "feature/bundle-refresh"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Telemetry hook changes are part of a complete generated bundle update", result.Output, StringComparison.Ordinal); + Assert.Contains("Telemetry hook branch policy satisfied", result.Output, StringComparison.Ordinal); + } + + [Theory] + [InlineData(AspireSkillsInstallerPath)] + [InlineData(AspireSkillsMetadataPath)] + [InlineData(AspireCliProjectPath)] + [InlineData(ArchiveChange)] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchRejectsIncompleteBundleRefresh(string omittedChange) + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + foreach (var path in s_requiredBundlePaths) + { + if (!string.Equals(path, omittedChange, StringComparison.Ordinal)) + { + ChangeFile(workspace, path); + } + } + if (!string.Equals(omittedChange, ArchiveChange, StringComparison.Ordinal)) + { + ReplaceArchive(workspace); + } + CommitAll(workspace, $"Refresh Aspire skills bundle without {omittedChange}"); + + var result = await RunPolicyAsync(workspace, "feature/incomplete-bundle-refresh"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("as part of a complete generated bundle update", result.Output, StringComparison.Ordinal); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task SynchronizationBranchAcceptsHookChange() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + CommitAll(workspace, "Synchronize telemetry hook"); + + var result = await RunPolicyAsync(workspace, SynchronizationBranch); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Telemetry hook branch policy satisfied", result.Output, StringComparison.Ordinal); + Assert.DoesNotContain("complete generated bundle update", result.Output, StringComparison.Ordinal); + } + + private TemporaryWorkspace CreateRepository() + { + var workspace = TemporaryWorkspace.Create(output); + GitCli.Run(workspace.Path, "init", "-q", "-b", "main"); + GitCli.Run(workspace.Path, "config", "user.email", "test@example.com"); + GitCli.Run(workspace.Path, "config", "user.name", "Test"); + GitCli.Run(workspace.Path, "config", "commit.gpgsign", "false"); + + WriteFile(workspace, ProtectedHookPath, "#!/usr/bin/env bash\necho baseline\n"); + WriteFile(workspace, "src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1", "Write-Output baseline\n"); + foreach (var path in s_requiredBundlePaths) + { + WriteFile(workspace, path, $"baseline {path}\n"); + } + WriteFile(workspace, OldArchivePath, "baseline archive\n"); + CommitAll(workspace, "Create baseline"); + + return workspace; + } + + private async Task RunPolicyAsync(TemporaryWorkspace workspace, string headRef) + { + var scriptPath = Path.Combine(workspace.Path, "verify-telemetry-hook-changes.ps1"); + await File.WriteAllTextAsync(scriptPath, GetPolicyScript()); + + using var command = new PowerShellCommand(scriptPath, output) + .WithWorkingDirectory(workspace.Path) + .WithEnvironmentVariable("PR_HEAD_REF", headRef) + .WithTimeout(TimeSpan.FromMinutes(1)); + + return await command.ExecuteAsync(); + } + + private static void ReplaceArchive(TemporaryWorkspace workspace) + { + File.Delete(GetFullPath(workspace, OldArchivePath)); + WriteFile(workspace, NewArchivePath, "refreshed archive\n"); + } + + private static void ChangeFile(TemporaryWorkspace workspace, string relativePath) + { + File.AppendAllText(GetFullPath(workspace, relativePath), "changed\n"); + } + + private static void WriteFile(TemporaryWorkspace workspace, string relativePath, string contents) + { + var path = GetFullPath(workspace, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + private static string GetFullPath(TemporaryWorkspace workspace, string relativePath) + => Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + private static void CommitAll(TemporaryWorkspace workspace, string message) + { + GitCli.Run(workspace.Path, "add", "-A"); + GitCli.Run(workspace.Path, "commit", "-q", "-m", message); + } + + private static string GetPolicyScript() + { + var steps = Sequence(Mapping(Mapping(LoadWorkflow(), "jobs"), "verify"), "steps") + .Children + .Cast() + .ToList(); + return Scalar(GetVerifyStep(steps), "run"); + } + + private static YamlMappingNode GetVerifyStep(IReadOnlyList steps) + => Assert.Single( + steps, + step => string.Equals( + ScalarOrNull(step, "name"), + "Verify telemetry scripts use the synchronization branch", + StringComparison.Ordinal)); + + private static YamlMappingNode LoadWorkflow() + { + using var reader = File.OpenText(Path.Combine(RepoRoot.Path, WorkflowRelativePath)); + var yaml = new YamlStream(); + yaml.Load(reader); + return Assert.IsType(Assert.Single(yaml.Documents).RootNode); + } + + private static YamlMappingNode Mapping(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]); + + private static YamlSequenceNode Sequence(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]); + + private static string Scalar(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]).Value!; + + private static string? ScalarOrNull(YamlMappingNode node, string key) + => node.Children.TryGetValue(new YamlScalarNode(key), out var value) + ? Assert.IsType(value).Value + : null; +}