Files
Stefan Broenner 4e670445f7 Fix generated skill version validation (#803)
* Fix generated skill version metadata

Require explicit package versions and stamp generated skill outputs across plugin, ZIP, and VS Code distributions.

Tests: Skill version integration tests; plugin packaging tests; Release solution build; VS Code package build.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Restrict VS Code skill copy target

Remove the caller-controlled deletion path so the packaging script only replaces its fixed generated extension directory.

Tests: CopyVscodeSkills_CleansOutputAndStampsExtensionVersion.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-20 14:10:35 +02:00

382 lines
14 KiB
PowerShell

<#
.SYNOPSIS
Builds Agent Plugins from canonical source-owned templates and updates versions.
.DESCRIPTION
1. Copy canonical plugin templates from .github/plugins/
2. Strip any runtime payloads from plugin bin/ roots
3. Update runtime-bootstrap metadata in plugin.json and version.txt
4. Synchronize complete Agent Skill directories from source
5. Validate Agent Plugins 1.0 and Agent Skills layout requirements
RUNTIME BOOTSTRAP MODEL:
- Published plugins ship wrapper/download logic and metadata only
- Self-contained Windows runtimes are downloaded from the latest GitHub release on first use
- No committed .exe/.dll runtime payloads should survive into the published plugin repo
OUTPUT:
plugins/
excel-mcp/ → MCP plugin (wrapper/bootstrap assets + updated version + fresh skills)
excel-cli/ → CLI plugin (wrapper/bootstrap assets + updated version + fresh skills)
.PARAMETER Version
Plugin version. Required for distributable builds.
.PARAMETER OutputDir
Output directory. Default: plugins/
.EXAMPLE
./Build-Plugins.ps1 -Version 1.2.3
#>
param(
[string]$Version = $null,
[string]$OutputDir = "plugins"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$SkillsDir = Join-Path $RepoRoot "skills"
$PluginSourceDir = Join-Path $RepoRoot ".github\plugins"
$AgentPluginSchema = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
$AgentPluginMcpSchema = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Version is required. Pass -Version <version>."
}
$Version = $Version.Trim()
$BootstrapScriptPath = Join-Path $RepoRoot "scripts\Build-BootstrapScripts.ps1"
if (-not (Test-Path $BootstrapScriptPath)) {
throw "Bootstrap generator script not found: $BootstrapScriptPath"
}
Write-Host "Rendering canonical plugin bootstrap scripts from the shared template..." -ForegroundColor Cyan
& $BootstrapScriptPath -OutputRoot $PluginSourceDir
if ($LASTEXITCODE -ne 0) {
throw "Bootstrap script generation failed."
}
function Remove-PackagedRuntimePayload {
param(
[string]$PluginName,
[string]$PluginDir
)
$pluginBinDir = Join-Path $PluginDir "bin"
if (-not (Test-Path $pluginBinDir)) {
return
}
$runtimePayload = Get-ChildItem -Path $pluginBinDir -Recurse -Force -File | Where-Object {
$_.Extension -in @(".exe", ".dll", ".pdb") -or
$_.Name.EndsWith(".deps.json", [System.StringComparison]::OrdinalIgnoreCase) -or
$_.Name.EndsWith(".runtimeconfig.json", [System.StringComparison]::OrdinalIgnoreCase)
}
foreach ($file in $runtimePayload) {
Write-Host " Removing committed runtime payload from ${PluginName}: $($file.FullName)" -ForegroundColor DarkYellow
Remove-Item -Path $file.FullName -Force
}
}
function Update-PluginManifest {
param(
[string]$PluginJsonPath,
[string]$Version
)
$pluginJson = Get-Content $PluginJsonPath -Raw | ConvertFrom-Json
$pluginJson.version = $Version
$pluginJson | ConvertTo-Json -Depth 10 | Set-Content $PluginJsonPath -Encoding UTF8
}
function Copy-AgentSkill {
param(
[string]$SourceDir,
[string]$DestinationDir,
[string]$Version = $null
)
if (-not (Test-Path $SourceDir -PathType Container)) {
throw "Canonical Agent Skill directory not found: $SourceDir"
}
if (Test-Path $DestinationDir) {
Remove-Item -Path $DestinationDir -Recurse -Force
}
New-Item -ItemType Directory -Path (Split-Path -Parent $DestinationDir) -Force | Out-Null
Copy-Item -Path $SourceDir -Destination $DestinationDir -Recurse -Force
# Write the VERSION file unconditionally. Guarding on Test-Path only *updated* a VERSION that
# the canonical skill already carried, so excel-cli - whose source skill has none - shipped
# without one while excel-mcp shipped with one.
$skillVersionPath = Join-Path $DestinationDir "VERSION"
if ($Version) {
Set-Content -Path $skillVersionPath -Value $Version -Encoding UTF8 -NoNewline
}
}
function Assert-AgentSkill {
param(
[string]$SkillDir,
[string]$ExpectedVersion
)
$skillPath = Join-Path $SkillDir "SKILL.md"
if (-not (Test-Path $skillPath -PathType Leaf)) {
throw "Agent Skill is missing SKILL.md: $SkillDir"
}
# Every packaged skill must carry a VERSION stamped with the version the plugin was built at.
# excel-cli previously shipped with no VERSION at all, and nothing failed the build.
if ($ExpectedVersion) {
$versionPath = Join-Path $SkillDir "VERSION"
if (-not (Test-Path $versionPath -PathType Leaf)) {
throw "Agent Skill is missing VERSION: $SkillDir"
}
$skillVersion = (Get-Content $versionPath -Raw).Trim()
if ($skillVersion -ne $ExpectedVersion) {
throw "$versionPath has version '$skillVersion' but expected '$ExpectedVersion'."
}
}
$lines = @(Get-Content $skillPath)
if ($lines.Count -lt 3 -or $lines[0].Trim() -ne "---") {
throw "$skillPath must begin with YAML frontmatter."
}
$closingDelimiter = -1
for ($index = 1; $index -lt $lines.Count; $index++) {
if ($lines[$index].Trim() -eq "---") {
$closingDelimiter = $index
break
}
}
if ($closingDelimiter -lt 2) {
throw "$skillPath is missing the closing YAML frontmatter delimiter."
}
$frontmatter = $lines[1..($closingDelimiter - 1)]
$allowedFields = @("name", "description", "license", "compatibility", "metadata", "allowed-tools")
$fields = @{}
foreach ($line in $frontmatter) {
if ($line -match "^([a-z][a-z-]*):(?:\s*(.*))?$") {
$fieldName = $Matches[1]
if ($fieldName -notin $allowedFields) {
throw "$skillPath contains unsupported Agent Skills frontmatter field '$fieldName'."
}
$fields[$fieldName] = $Matches[2]
}
}
$expectedName = Split-Path -Leaf $SkillDir
$skillName = $fields["name"]
if ($skillName -ne $expectedName -or $skillName -notmatch "^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" -or $skillName.Length -gt 64) {
throw "$skillPath has invalid Agent Skill name '$skillName'; it must match directory '$expectedName'."
}
if (-not $fields.ContainsKey("description")) {
throw "$skillPath must declare an Agent Skill description."
}
$descriptionStart = [Array]::IndexOf($frontmatter, ($frontmatter | Where-Object { $_ -match "^description:" } | Select-Object -First 1))
$description = $fields["description"]
if ($description -in @(">", "|")) {
$descriptionLines = @()
for ($index = $descriptionStart + 1; $index -lt $frontmatter.Count; $index++) {
if ($frontmatter[$index] -notmatch "^\s+") {
break
}
$descriptionLines += $frontmatter[$index].Trim()
}
$description = $descriptionLines -join " "
}
if ([string]::IsNullOrWhiteSpace($description) -or $description.Length -gt 1024) {
throw "$skillPath has an invalid Agent Skill description length."
}
}
function Assert-AgentPluginPackage {
param(
[string]$PluginName,
[string]$PluginDir,
[string]$ExpectedVersion
)
$pluginJsonPath = Join-Path $PluginDir "plugin.json"
if (-not (Test-Path $pluginJsonPath -PathType Leaf)) {
throw "Agent Plugin manifest not found: $pluginJsonPath"
}
$pluginJson = Get-Content $pluginJsonPath -Raw | ConvertFrom-Json
$allowedFields = @('$schema', "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions")
foreach ($property in $pluginJson.PSObject.Properties) {
if ($property.Name -notin $allowedFields) {
throw "$pluginJsonPath contains unsupported Agent Plugins 1.0 field '$($property.Name)'."
}
}
if ($pluginJson.'$schema' -ne $AgentPluginSchema) {
throw "$pluginJsonPath must target $AgentPluginSchema."
}
if ($pluginJson.name -ne $PluginName) {
throw "$pluginJsonPath has name '$($pluginJson.name)' but expected '$PluginName'."
}
if ($pluginJson.version -ne $ExpectedVersion) {
throw "$pluginJsonPath has version '$($pluginJson.version)' but expected '$ExpectedVersion'."
}
if ($pluginJson.repository -isnot [string]) {
throw "$pluginJsonPath repository must be a string."
}
$legacyCopilotHelper = Join-Path $PluginDir "bin\install-global.ps1"
if (Test-Path $legacyCopilotHelper) {
throw "Copilot-only files must be placed under com.github.copilot/: $legacyCopilotHelper"
}
$legacyMcpPath = Join-Path $PluginDir ".mcp.json"
if (Test-Path $legacyMcpPath) {
throw "Legacy MCP configuration is not permitted in Agent Plugins 1.0 packages: $legacyMcpPath"
}
$skillsRoot = Join-Path $PluginDir "skills"
if (Test-Path $skillsRoot) {
Get-ChildItem -Path $skillsRoot -Directory | ForEach-Object {
Assert-AgentSkill -SkillDir $_.FullName -ExpectedVersion $ExpectedVersion
}
}
$mcpPath = Join-Path $PluginDir "mcp.json"
if (-not (Test-Path $mcpPath)) {
return
}
$mcp = Get-Content $mcpPath -Raw | ConvertFrom-Json
$mcpProperties = @($mcp.PSObject.Properties.Name)
if ($mcpProperties.Count -ne 2 -or '$schema' -notin $mcpProperties -or "mcpServers" -notin $mcpProperties) {
throw "$mcpPath must contain only '`$schema' and 'mcpServers'."
}
if ($mcp.'$schema' -ne $AgentPluginMcpSchema) {
throw "$mcpPath must target $AgentPluginMcpSchema."
}
foreach ($serverProperty in $mcp.mcpServers.PSObject.Properties) {
$server = $serverProperty.Value
$serverFields = @($server.PSObject.Properties.Name)
$allowedServerFields = @("type", "command", "args", "env", "cwd")
if ($serverFields | Where-Object { $_ -notin $allowedServerFields }) {
throw "$mcpPath server '$($serverProperty.Name)' contains unsupported fields."
}
if ($server.type -ne "stdio" -or $server.command -isnot [string] -or $server.command -match "\s") {
throw "$mcpPath server '$($serverProperty.Name)' must use type 'stdio' and a single executable command token."
}
if (@($server.args) | Where-Object { $_ -match "\{pluginDir\}" }) {
throw "$mcpPath server '$($serverProperty.Name)' still uses the legacy '{pluginDir}' placeholder."
}
}
}
Write-Host "`n=== Building Agent Plugins v$Version ===" -ForegroundColor Green
Write-Host "Source: $RepoRoot"
Write-Host "Templates: $PluginSourceDir"
Write-Host "Output: $OutputDir`n"
# Clean output
if (Test-Path $OutputDir) {
Write-Host "Cleaning output: $OutputDir" -ForegroundColor Yellow
Remove-Item -Path $OutputDir -Recurse -Force
}
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
# =============================================================================
# Build: excel-mcp Plugin
# =============================================================================
Write-Host "`n[1/2] Building excel-mcp plugin..." -ForegroundColor Yellow
$TemplateMcp = Join-Path $PluginSourceDir "excel-mcp"
$OutputMcp = Join-Path $OutputDir "excel-mcp"
if (-not (Test-Path $TemplateMcp)) {
Write-Error "Template not found: $TemplateMcp"
exit 1
}
Write-Host " Copying canonical plugin template..." -ForegroundColor Cyan
Copy-Item -Path $TemplateMcp -Destination $OutputMcp -Recurse -Force
Remove-PackagedRuntimePayload -PluginName "excel-mcp" -PluginDir $OutputMcp
Write-Host " Updating plugin.json version to $Version..." -ForegroundColor Cyan
$PluginJsonPath = Join-Path $OutputMcp "plugin.json"
Update-PluginManifest `
-PluginJsonPath $PluginJsonPath `
-Version $Version
Write-Host " Updating version.txt to $Version..." -ForegroundColor Cyan
Set-Content -Path (Join-Path $OutputMcp "version.txt") -Value $Version -Encoding UTF8 -NoNewline
Write-Host " Synchronizing complete excel-mcp skill directory..." -ForegroundColor Cyan
$SourceSkillMcp = Join-Path $SkillsDir "excel-mcp"
$DestSkillMcp = Join-Path $OutputMcp "skills\excel-mcp"
Copy-AgentSkill -SourceDir $SourceSkillMcp -DestinationDir $DestSkillMcp -Version $Version
Assert-AgentPluginPackage -PluginName "excel-mcp" -PluginDir $OutputMcp -ExpectedVersion $Version
Write-Host "✅ excel-mcp plugin built" -ForegroundColor Green
# =============================================================================
# Build: excel-cli Plugin
# =============================================================================
Write-Host "`n[2/2] Building excel-cli plugin..." -ForegroundColor Yellow
$TemplateCli = Join-Path $PluginSourceDir "excel-cli"
$OutputCli = Join-Path $OutputDir "excel-cli"
if (-not (Test-Path $TemplateCli)) {
Write-Error "Template not found: $TemplateCli"
exit 1
}
Write-Host " Copying canonical plugin template..." -ForegroundColor Cyan
Copy-Item -Path $TemplateCli -Destination $OutputCli -Recurse -Force
Remove-PackagedRuntimePayload -PluginName "excel-cli" -PluginDir $OutputCli
Write-Host " Updating plugin.json version to $Version..." -ForegroundColor Cyan
$PluginJsonPath = Join-Path $OutputCli "plugin.json"
Update-PluginManifest `
-PluginJsonPath $PluginJsonPath `
-Version $Version
Write-Host " Updating version.txt to $Version..." -ForegroundColor Cyan
Set-Content -Path (Join-Path $OutputCli "version.txt") -Value $Version -Encoding UTF8 -NoNewline
Write-Host " Synchronizing complete excel-cli skill directory..." -ForegroundColor Cyan
$SourceSkillCli = Join-Path $SkillsDir "excel-cli"
$DestSkillCli = Join-Path $OutputCli "skills\excel-cli"
Copy-AgentSkill -SourceDir $SourceSkillCli -DestinationDir $DestSkillCli -Version $Version
Assert-AgentPluginPackage -PluginName "excel-cli" -PluginDir $OutputCli -ExpectedVersion $Version
Write-Host "✅ excel-cli plugin built" -ForegroundColor Green
# =============================================================================
# Summary
# =============================================================================
Write-Host "`n=== Build Complete ===" -ForegroundColor Green
Write-Host "Version: $Version"
Write-Host "Output: $OutputDir"
Write-Host ""
Write-Host "Plugins:" -ForegroundColor Cyan
Write-Host ' [ok] excel-mcp - bootstrap assets and skill' -ForegroundColor Green
Write-Host ' [ok] excel-cli - bootstrap assets and skill' -ForegroundColor Green
Write-Host ""
Write-Host "Test locally:" -ForegroundColor Yellow
Write-Host " copilot plugin install $OutputDir\excel-mcp"
Write-Host " copilot plugin install $OutputDir\excel-cli"