Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/verify-telemetry-hook-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.'
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ internal sealed class AspireSkillsInstaller(
AspireCliTelemetry telemetry,
ILogger<AspireSkillsInstaller> 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";
Expand Down
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public async Task<TelemetryHookConfigurationResult> 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<AgentClientKind>();
if (detectedClients.Contains(AgentClientKind.CopilotApp))
{
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Agents/Hooks/track-telemetry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
<EmbeddedResource Include="$(SharedDir)Mcp\Resources\aspire-32.png" Link="Mcp\Resources\aspire-32.png" />
<EmbeddedResource Include="$(SharedDir)Mcp\Resources\aspire-48.png" Link="Mcp\Resources\aspire-48.png" />
<EmbeddedResource Include="$(SharedDir)Mcp\Resources\aspire-64.png" Link="Mcp\Resources\aspire-64.png" />
<EmbeddedResource Include="Agents\AspireSkills\Embedded\aspire-skills-v0.0.2.tgz" LogicalName="aspire-skills.bundle.tgz">
<EmbeddedResource Include="Agents\AspireSkills\Embedded\aspire-skills-v0.0.3.tgz" LogicalName="aspire-skills.bundle.tgz">
<WithCulture>false</WithCulture>
</EmbeddedResource>
<EmbeddedResource Include="Agents\AspireSkills\Embedded\aspire-skills.metadata.json" LogicalName="aspire-skills.metadata.json">
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Hosting.Dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
32 changes: 30 additions & 2 deletions tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmbeddedAspireSkillsBundleProvider>.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."));
Expand All @@ -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
{
Expand Down
55 changes: 35 additions & 20 deletions tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand All @@ -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");
}
Expand Down Expand Up @@ -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/<skill>/ is forwarded — never the absolute path.
AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md");
AssertArg(args, "--file-reference", referencePath);
}

[Theory]
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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/<skill>/ is forwarded — never the absolute path.
AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md");
AssertArg(args, "--file-reference", referencePath);
}

[Theory]
Expand Down
Loading
Loading