Context
github/awesome-copilot#1570 adopts apm pack for its marketplace generation (73 plugins from one apm.yml). Code review on that PR (review #4202980531) surfaced two independent shortcomings in APM's marketplace builder that together prevent the PR from being a clean drop-in replacement for the bespoke generator:
- Local-source
pluginRoot double-prefix -- breaks runtime resolution.
- Remote-source pass-through metadata dropped -- forces a bridge script to retain
plugins/external.json.
Filing both as one umbrella because they share the theme "make apm pack's marketplace.json output spec-equivalent to what hand-rolled generators produce today" and both are small, additive builder changes.
Finding 1 -- Local sources double-prefix metadata.pluginRoot
Symptom
awesome-copilot's existing baseline (staged branch) emits:
{ "name": "ai-team-orchestration", "source": "ai-team-orchestration", ... }
with metadata.pluginRoot: "./plugins". A spec-compliant consumer joins the two as ./plugins/ai-team-orchestration.
apm pack today emits:
{ "name": "ai-team-orchestration", "source": "./plugins/ai-team-orchestration", ... }
against the same pluginRoot. The consumer joins to ./plugins/./plugins/ai-team-orchestration -- a path that does not exist. This is a behavior break, not a cosmetic delta.
Root cause
LOCAL_SOURCE_RE = re.compile(r"^\./") (yml_schema.py:72) requires the leading ./ for an apm.yml source value to be classified as local -- so authors must write source: ./plugins/foo in apm.yml.
The builder then emits that source verbatim (builder.py:730):
if is_local:
plugin["source"] = entry.source # never subtracts metadata.pluginRoot
MarketplaceConfig.plugin_root is parsed and re-emitted into metadata.pluginRoot (models.py:85,199), but never consulted on the emit path. The result is structurally inconsistent output: APM declares pluginRoot but doesn't honor it in its own sources.
Proposed fix
In the local-source emit branch of builder.py, when metadata.pluginRoot is set:
if is_local:
source_value = entry.source
if config.plugin_root:
source_value = _strip_plugin_root(source_value, config.plugin_root)
plugin["source"] = source_value
Where _strip_plugin_root("./plugins/foo", "./plugins") returns "foo". Idempotent (no leading ./ -> no strip), pure path arithmetic, no I/O.
Why now
Workaround in awesome-copilot is a 5-line post-process in eng/merge-external-plugins.mjs. Acceptable as a stopgap but it couples a downstream repo's bridge to APM's emit format. Native fix removes that coupling permanently.
Acceptance
Finding 2 -- Remote PackageEntry drops author/keywords/license/repository
Symptom
awesome-copilot's plugins/external.json entries publish four fields that vanish through apm pack:
{
"name": "azure",
"author": { "name": "Microsoft", "url": "https://www.microsoft.com" },
"keywords": ["azure", "cloud", ...],
"license": "MIT",
"repository": "https://github.com/microsoft/github-copilot-for-azure",
"source": { ... }
}
apm pack today emits only description/version/tags as pass-through; the four fields above are silently dropped.
Root cause
_PACKAGE_ENTRY_KEYS in yml_schema.py:88-99 does not list them. Even if a maintainer writes them in apm.yml, the parser rejects unknown keys.
A second smaller issue stacked on the same surface: for remote entries, description/version come from the remote repo's own apm.yml only. Third-party plugin repos (microsoft/Dataverse-skills, dotnet/skills, figma/mcp-server-guide) don't ship one, so those fields render empty even when the maintainer supplied values in awesome-copilot's apm.yml.
Proposed fix
Schema (additive): extend _PACKAGE_ENTRY_KEYS to permit author, keywords, license, repository. Validate types (object / array / string / string).
Builder: for remote entries:
- emit the four new fields verbatim when present;
- when
entry.description / entry.version are set in apm.yml, prefer them over the remote-fetched values (today the remote fetch wins).
Out of scope here
The source-object key delta ({type, repository, ref, commit} vs legacy {source, repo, path}) is a downstream consumer concern. APM should keep emitting its own canonical shape; consumers adapt at publish time. Filing as a separate decision if it ever becomes a real blocker.
Acceptance
Why ship both together
Both are small, additive, zero-risk-when-unset builder changes on the same code path. Together they let awesome-copilot collapse:
eng/merge-external-plugins.mjs (drops with Finding 2 closure)
- the post-process step that strips
pluginRoot (drops with Finding 1 closure)
plugins/external.json (drops with Finding 2 closure -- moves into apm.yml)
End state: apm pack becomes a true drop-in replacement for the bespoke generator -- which is the proof point this work was designed to deliver.
Tracking
Context
github/awesome-copilot#1570 adopts
apm packfor its marketplace generation (73 plugins from oneapm.yml). Code review on that PR (review #4202980531) surfaced two independent shortcomings in APM's marketplace builder that together prevent the PR from being a clean drop-in replacement for the bespoke generator:pluginRootdouble-prefix -- breaks runtime resolution.plugins/external.json.Filing both as one umbrella because they share the theme "make
apm pack's marketplace.json output spec-equivalent to what hand-rolled generators produce today" and both are small, additive builder changes.Finding 1 -- Local sources double-prefix
metadata.pluginRootSymptom
awesome-copilot's existing baseline (
stagedbranch) emits:{ "name": "ai-team-orchestration", "source": "ai-team-orchestration", ... }with
metadata.pluginRoot: "./plugins". A spec-compliant consumer joins the two as./plugins/ai-team-orchestration.apm packtoday emits:{ "name": "ai-team-orchestration", "source": "./plugins/ai-team-orchestration", ... }against the same
pluginRoot. The consumer joins to./plugins/./plugins/ai-team-orchestration-- a path that does not exist. This is a behavior break, not a cosmetic delta.Root cause
LOCAL_SOURCE_RE = re.compile(r"^\./")(yml_schema.py:72) requires the leading./for an apm.ymlsourcevalue to be classified as local -- so authors must writesource: ./plugins/fooin apm.yml.The builder then emits that source verbatim (
builder.py:730):MarketplaceConfig.plugin_rootis parsed and re-emitted intometadata.pluginRoot(models.py:85,199), but never consulted on the emit path. The result is structurally inconsistent output: APM declarespluginRootbut doesn't honor it in its own sources.Proposed fix
In the local-source emit branch of
builder.py, whenmetadata.pluginRootis set:Where
_strip_plugin_root("./plugins/foo", "./plugins")returns"foo". Idempotent (no leading./-> no strip), pure path arithmetic, no I/O.Why now
Workaround in awesome-copilot is a 5-line post-process in
eng/merge-external-plugins.mjs. Acceptable as a stopgap but it couples a downstream repo's bridge to APM's emit format. Native fix removes that coupling permanently.Acceptance
metadata.pluginRootis set, local sources inmarketplace.jsonare emitted with the prefix stripped.metadata.pluginRootcontinues to round-trip unchanged.pluginRootis unset (today's behavior preserved).pluginRootmatches, (b) pass-through when no match, (c) nopluginRoot-> verbatim emission.Finding 2 -- Remote
PackageEntrydrops author/keywords/license/repositorySymptom
awesome-copilot's
plugins/external.jsonentries publish four fields that vanish throughapm pack:{ "name": "azure", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" }, "keywords": ["azure", "cloud", ...], "license": "MIT", "repository": "https://github.com/microsoft/github-copilot-for-azure", "source": { ... } }apm packtoday emits onlydescription/version/tagsas pass-through; the four fields above are silently dropped.Root cause
_PACKAGE_ENTRY_KEYSinyml_schema.py:88-99does not list them. Even if a maintainer writes them inapm.yml, the parser rejects unknown keys.A second smaller issue stacked on the same surface: for remote entries,
description/versioncome from the remote repo's ownapm.ymlonly. Third-party plugin repos (microsoft/Dataverse-skills,dotnet/skills,figma/mcp-server-guide) don't ship one, so those fields render empty even when the maintainer supplied values in awesome-copilot'sapm.yml.Proposed fix
Schema (additive): extend
_PACKAGE_ENTRY_KEYSto permitauthor,keywords,license,repository. Validate types (object / array / string / string).Builder: for remote entries:
entry.description/entry.versionare set in apm.yml, prefer them over the remote-fetched values (today the remote fetch wins).Out of scope here
The source-object key delta (
{type, repository, ref, commit}vs legacy{source, repo, path}) is a downstream consumer concern. APM should keep emitting its own canonical shape; consumers adapt at publish time. Filing as a separate decision if it ever becomes a real blocker.Acceptance
marketplace.packages[]acceptsauthor,keywords,license,repository.entry.description/entry.versionare set on a remote entry, they override the remote fetch.Why ship both together
Both are small, additive, zero-risk-when-unset builder changes on the same code path. Together they let
awesome-copilotcollapse:eng/merge-external-plugins.mjs(drops with Finding 2 closure)pluginRoot(drops with Finding 1 closure)plugins/external.json(drops with Finding 2 closure -- moves intoapm.yml)End state:
apm packbecomes a true drop-in replacement for the bespoke generator -- which is the proof point this work was designed to deliver.Tracking