Skip to content

marketplace: harden pack output for downstream marketplaces (pluginRoot subtraction + remote pass-through fields) #1061

@danielmeppiel

Description

@danielmeppiel

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:

  1. Local-source pluginRoot double-prefix -- breaks runtime resolution.
  2. 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

  • When metadata.pluginRoot is set, local sources in marketplace.json are emitted with the prefix stripped.
  • metadata.pluginRoot continues to round-trip unchanged.
  • No-op when pluginRoot is unset (today's behavior preserved).
  • Unit tests: (a) prefix stripped when pluginRoot matches, (b) pass-through when no match, (c) no pluginRoot -> verbatim emission.

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

  • marketplace.packages[] accepts author, keywords, license, repository.
  • Remote-source builder emits those fields verbatim when present on the entry.
  • When entry.description / entry.version are set on a remote entry, they override the remote fetch.
  • Unit tests: (a) all four pass-through fields round-trip, (b) maintainer description overrides remote, (c) absent fields produce identical output to today.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions