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
11 changes: 11 additions & 0 deletions .github/actions/create-pull-request/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ inputs:
description: 'Set to true if the branch is already pushed remotely (skips commit/push)'
required: false
default: 'false'
draft:
description: 'Create the pull request as a draft'
required: false
default: 'false'
outputs:
pull-request-number:
description: 'The pull request number'
Expand Down Expand Up @@ -91,6 +95,7 @@ runs:
PR_TITLE: ${{ inputs.title }}
PR_BODY: ${{ inputs.body }}
LABELS: ${{ inputs.labels }}
DRAFT: ${{ inputs.draft }}
run: |
# Check if a PR already exists for this branch
EXISTING_PR=$(gh pr list --head "$BRANCH" --base "$BASE" --json number,url --jq '.[0] // empty')
Expand Down Expand Up @@ -133,12 +138,18 @@ runs:
trap 'rm -f "$BODY_FILE"' EXIT
printf '%s\n' "$PR_BODY" > "$BODY_FILE"

DRAFT_ARGS=()
if [ "$DRAFT" = "true" ]; then
DRAFT_ARGS+=(--draft)
fi

# Create the pull request without eval — all args are properly quoted
PR_URL=$(gh pr create \
--title "$PR_TITLE" \
--body-file "$BODY_FILE" \
--base "$BASE" \
--head "$BRANCH" \
"${DRAFT_ARGS[@]}" \
"${LABEL_ARGS[@]}")

rm -f "$BODY_FILE"
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/update-aspire-skills-bundle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Update Aspire Skills Bundle

on:
workflow_dispatch:
inputs:
version:
description: "Aspire skills release version to embed. Defaults to the currently pinned version."
required: false
type: string
schedule:
- cron: '0 17 * * *' # 9am PT / 17:00 UTC

permissions:
contents: write
pull-requests: write

jobs:
update-and-pr:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'microsoft' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Generate GitHub App Token for bundle update
id: update-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ASPIRE_BOT_APP_ID }}
private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }}

- name: Update embedded Aspire skills bundle
shell: pwsh
env:
GH_TOKEN: ${{ steps.update-token.outputs.token }}
VERSION: ${{ inputs.version }}
run: |
if ([string]::IsNullOrWhiteSpace($env:VERSION)) {
./eng/scripts/update-aspire-skills-bundle.ps1
}
else {
./eng/scripts/update-aspire-skills-bundle.ps1 -Version $env:VERSION
}

- name: Restore solution
run: ./restore.sh

- name: Test Aspire skills installer
run: >
dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj
--no-launch-profile
--
--filter-class "*.AspireSkillsInstallerTests"
--filter-class "*.AspireSkillsBundleTests"
--filter-class "*.AgentInitCommandTests"
--filter-not-trait "quarantined=true"
--filter-not-trait "outerloop=true"

- name: Generate GitHub App Token for pull request
id: pr-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ASPIRE_BOT_APP_ID }}
private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }}

- name: Create or update pull request
uses: ./.github/actions/create-pull-request
with:
token: ${{ steps.pr-token.outputs.token }}
branch: update-aspire-skills-bundle
base: main
draft: true
commit-message: "[Automated] Update Aspire skills bundle"
labels: |
area-cli
area-engineering-systems
title: "[Automated] Update Aspire skills bundle"
body: "Auto-generated update to refresh the embedded Aspire skills bundle fallback used by the Aspire CLI."
26 changes: 26 additions & 0 deletions .github/workflows/verify-aspire-skills-bundle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Verify Aspire Skills Bundle

on:
workflow_dispatch:
pull_request:
paths:
- 'src/Aspire.Cli/Agents/AspireSkills/Embedded/**'
- 'eng/scripts/verify-aspire-skills-bundle.ps1'
- '.github/workflows/verify-aspire-skills-bundle.yml'

permissions:
contents: read
attestations: read

jobs:
verify:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'microsoft' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Verify embedded Aspire skills bundle attestation
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./eng/scripts/verify-aspire-skills-bundle.ps1
164 changes: 164 additions & 0 deletions eng/scripts/update-aspire-skills-bundle.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env pwsh

[CmdletBinding()]
param(
[string]$Version,
[string]$Repository = 'microsoft/aspire-skills'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

$scriptDir = $PSScriptRoot
$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded'
$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json'
$installerPath = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\AspireSkillsInstaller.cs'
$cliProjectPath = Join-Path $repoRoot 'src\Aspire.Cli\Aspire.Cli.csproj'

function Invoke-GitHubCli {
param(
[Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
[string[]]$Arguments
)

& gh @Arguments
if ($LASTEXITCODE -ne 0) {
throw "gh $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
}
}

function Get-UnprefixedVersion([string]$Value) {
if ([string]::IsNullOrWhiteSpace($Value)) {
throw 'A version is required.'
}

if ($Value.StartsWith('v', [System.StringComparison]::OrdinalIgnoreCase)) {
return $Value.Substring(1)
}

return $Value
}

function Get-CurrentEmbeddedVersion {
if (-not (Test-Path $metadataPath)) {
throw "Embedded Aspire skills metadata was not found at '$metadataPath'. Pass -Version to choose the initial version."
}

$metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json
return Get-UnprefixedVersion $metadata.version
}

function Set-TextFile {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Content
)

$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content.TrimEnd("`r", "`n") + [System.Environment]::NewLine, $utf8NoBom)
}

function Get-GitHubRelease([string]$NormalizedVersion) {
$tagCandidates = @("v$NormalizedVersion", $NormalizedVersion) | Select-Object -Unique

foreach ($tag in $tagCandidates) {
try {
$json = Invoke-GitHubCli release view $tag --repo $Repository --json 'tagName,assets'
return $json | ConvertFrom-Json
}
catch {
Write-Host "Release '$tag' was not found in '$Repository'."
}
}

throw "Could not find an Aspire skills release for version '$NormalizedVersion' in '$Repository'."
}

function Get-ReleaseAsset($Release, [string]$NormalizedVersion) {
$assetNameCandidates = foreach ($archiveExtension in @('.zip', '.tar.gz', '.tgz')) {
"aspire-skills-v$NormalizedVersion$archiveExtension"
"aspire-skills-$NormalizedVersion$archiveExtension"
}

foreach ($assetName in $assetNameCandidates) {
$asset = $Release.assets | Where-Object { $_.name -ieq $assetName } | Select-Object -First 1
if ($null -ne $asset) {
return $asset
}
}

throw "Release '$($Release.tagName)' does not contain a supported Aspire skills archive asset for version '$NormalizedVersion'."
}

if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "The GitHub CLI ('gh') is required to update the embedded Aspire skills bundle."
}

$normalizedVersion = if ([string]::IsNullOrWhiteSpace($Version)) {
Get-CurrentEmbeddedVersion
}
else {
Get-UnprefixedVersion $Version
}

New-Item -ItemType Directory -Force -Path $embeddedDir | Out-Null

Write-Host "Resolving Aspire skills release '$normalizedVersion' from '$Repository'..."
$release = Get-GitHubRelease $normalizedVersion
$asset = Get-ReleaseAsset $release $normalizedVersion

$tempDir = [System.IO.Directory]::CreateTempSubdirectory('aspire-skills-update-').FullName
try {
Write-Host "Downloading '$($asset.name)' from '$Repository' release '$($release.tagName)'..."
Invoke-GitHubCli release download $release.tagName --repo $Repository --pattern $asset.name --dir $tempDir --clobber

$archivePath = Join-Path $tempDir $asset.name
if (-not (Test-Path $archivePath)) {
throw "Expected downloaded asset '$archivePath' was not found."
}

$certIdentity = "https://github.com/$Repository/.github/workflows/publish.yml@refs/tags/$($release.tagName)"
Write-Host "Verifying GitHub artifact attestation for '$($asset.name)'..."
Invoke-GitHubCli attestation verify $archivePath --repo $Repository --cert-identity $certIdentity --cert-oidc-issuer 'https://token.actions.githubusercontent.com'

$hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
$targetArchivePath = Join-Path $embeddedDir $asset.name

Get-ChildItem -Path $embeddedDir -File -Force |
Where-Object { $_.Name -match '^aspire-skills-.*\.(zip|tar\.gz|tgz)$' -and $_.Name -ne $asset.name } |
Remove-Item -Force

Copy-Item -Path $archivePath -Destination $targetArchivePath -Force

$metadata = [ordered]@{
version = $normalizedVersion
repository = $Repository
tag = $release.tagName
assetName = $asset.name
sha256 = $hash
}
Set-TextFile -Path $metadataPath -Content ($metadata | ConvertTo-Json)

$installerContent = Get-Content -Raw -Path $installerPath
$installerContent = [regex]::Replace(
$installerContent,
'internal const string Version = "[^"]+";',
"internal const string Version = ""$normalizedVersion"";")
Set-TextFile -Path $installerPath -Content $installerContent

$cliProjectContent = Get-Content -Raw -Path $cliProjectPath
$cliProjectContent = [regex]::Replace(
$cliProjectContent,
'Agents\\AspireSkills\\Embedded\\aspire-skills-[^"]+\.(zip|tar\.gz|tgz)',
"Agents\AspireSkills\Embedded\$($asset.name)")
Set-TextFile -Path $cliProjectPath -Content $cliProjectContent

Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-256 '$hash'."
}
finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force
}
}
63 changes: 63 additions & 0 deletions eng/scripts/verify-aspire-skills-bundle.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env pwsh

[CmdletBinding()]
param(
[string]$Repository = 'microsoft/aspire-skills'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

$scriptDir = $PSScriptRoot
$repoRoot = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
$embeddedDir = Join-Path $repoRoot 'src\Aspire.Cli\Agents\AspireSkills\Embedded'
$metadataPath = Join-Path $embeddedDir 'aspire-skills.metadata.json'

if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw "The GitHub CLI ('gh') is required to verify the embedded Aspire skills bundle."
}

if (-not (Test-Path $metadataPath)) {
throw "Embedded Aspire skills metadata was not found at '$metadataPath'."
}

$metadata = Get-Content -Raw -Path $metadataPath | ConvertFrom-Json

if ($metadata.repository -ne $Repository) {
throw "Unexpected embedded bundle repository '$($metadata.repository)'. Expected '$Repository'."
}

if ([string]::IsNullOrWhiteSpace($metadata.tag)) {
throw "Embedded Aspire skills metadata must specify a GitHub release tag."
}

if ([string]::IsNullOrWhiteSpace($metadata.assetName)) {
throw "Embedded Aspire skills metadata must specify a release asset name."
}

if ($metadata.assetName -ne [System.IO.Path]::GetFileName($metadata.assetName)) {
throw "Embedded Aspire skills asset name '$($metadata.assetName)' must not contain path separators."
}

if ([string]::IsNullOrWhiteSpace($metadata.sha256)) {
throw "Embedded Aspire skills metadata must specify the release asset SHA-256 hash."
}

$archivePath = Join-Path $embeddedDir $metadata.assetName
if (-not (Test-Path $archivePath)) {
throw "Embedded Aspire skills archive was not found at '$archivePath'."
}

$actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
if ($actualHash -ne $metadata.sha256) {
throw "Embedded bundle SHA-256 mismatch. Expected '$($metadata.sha256)', got '$actualHash'."
}

$certIdentity = "https://github.com/$($metadata.repository)/.github/workflows/publish.yml@refs/tags/$($metadata.tag)"
gh attestation verify $archivePath `
--repo $metadata.repository `
--cert-identity $certIdentity `
--cert-oidc-issuer 'https://token.actions.githubusercontent.com'

Write-Host "Embedded Aspire skills bundle '$($metadata.assetName)' verified against GitHub artifact attestation."
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ internal static string NormalizeRelativePath(string? relativePath)
return Path.Combine(segments);
}

private static string NormalizeSha256(string sha256)
internal static string NormalizeSha256(string sha256)
{
const string prefix = "sha256-";
return sha256.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
Expand Down
Loading
Loading