AI-349: V1 catalog manifest schema + signed release pipeline#36
Conversation
Producer side of the V1 agent surface (Spine RFC 6.8): make every recipe carry
the catalog field contract and emit a signed catalog.json in the EndorAgent wire
shape that apiserver (AI-351) consumes.
Schema + validator:
- Add audience (appsec|developer), short_description, and authors (display names
only; PII rejected) to recipe.py + validator.py.
- Require requires_endorctl as a canonical-semver version constraint (>=/>),
validated inline with no new dependency; convert all recipes to ">=1.0.0".
Catalog emitter:
- New publication/catalog_wire.py projects Catalog Manifest records into
catalog.json {schema_version, agents:[EndorAgent]}: snake_case keys, lowercase
enums, claude-managed-agents->claude-managed, install[] from the real layout
(claude-code + claude-managed only), endorctl_min_version operator-stripped,
cadence/fetched_at/stale omitted. Committed + drift-checked like manifest.json;
catalog_version stamped at release. Carry catalog metadata on CatalogAgent.
Merge gate + release:
- id-immutability gate (id_stability.py + CLI, PR-only) blocks renaming/removing
an id that exists on main (telemetry join key).
- catalog_signing.py: offline ES256 verify + raw-P1363->DER conversion; release
workflow signs via Azure Key Vault over OIDC (gated behind CATALOG_SIGNING_
ENABLED until AI-418 provisions the key), verifies against the pinned public
key, and publishes catalog.json + .sig to a GitHub Release on agents-v* tags.
- CODEOWNERS gates the recipes, validator, emitter, signing, release workflow,
and pinned key. RELEASES.md + docs/contributing-agents.md document the contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keys/README.md referenced ENDOR_ARTIFACT_SIGNING_ENABLED (the pre-existing ai-plugins provenance flag); the catalog release signing gate is CATALOG_SIGNING_ENABLED, matching the workflow and RELEASES.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| return editions[0] | ||
|
|
||
|
|
||
| def _install_command(repo_host: str, agent_id: str, bundle_path: str) -> str: |
There was a problem hiding this comment.
@dannykim-endor make the Claude Code install command self-contained? generated catalog command currently assumes .claude/agents already exists. Existing Claude Code READMEs include mkdir -p .claude/agents, and a fresh repo will fail on the bare cp.
Suggested shape:
return (
f"mkdir -p .claude/agents "
f"&& cp {bundle_path}/{agent_id}.md .claude/agents/{agent_id}.md"
)
Then update tests/test_catalog_wire.py expectations and regenerate catalog.json.
There was a problem hiding this comment.
Fixed in 31e9cab. The Claude Code install command is now mkdir -p .claude/agents && cp <bundle>/<id>.md .claude/agents/<id>.md. Regenerated catalog.json and updated the test_catalog_wire.py expectations.
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
There was a problem hiding this comment.
@dannykim-endor Before install/test/signing, can we add a hard gate that proves the agents-v* tag target is already merged to main? Otherwise a tag pushed to an unmerged commit could run the signed release path without normal PR/CODEOWNERS review.
Suggested step after checkout:
- name: Verify release tag points at main
run: |
git fetch origin main --no-tags
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error title=Invalid release tag::agents-v* tags must point to commits already merged to main."
exit 1
fi
There was a problem hiding this comment.
Fixed in 31e9cab. Added a "Verify tagged commit is on main" step right after checkout that runs git merge-base --is-ancestor "$GITHUB_SHA" origin/main and fails the release if the tag is not contained in main. (Fetching main as +refs/heads/main:refs/remotes/origin/main so origin/main resolves under the tag checkout.)
| --signature catalog.json.sig \ | ||
| --public-key keys/catalog-signing-public-key.pem | ||
|
|
||
| - name: Publish GitHub Release |
There was a problem hiding this comment.
@dannykim-endor Do we want agents-v* releases to publish unsigned catalogs when CATALOG_SIGNING_ENABLED is false? For the signed catalog release path, I’d prefer fail-closed unless we explicitly need a non-prod escape hatch. full fix to enforce signing:
- name: Require catalog signing
if: ${{ vars.CATALOG_SIGNING_ENABLED != 'true' }}
run: |
echo "::error title=Catalog signing disabled::CATALOG_SIGNING_ENABLED must be true before publishing agents-v* releases."
exit 1
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$CATALOG_TAG" \
catalog.json catalog.json.sig keys/catalog-signing-public-key.pem \
--title "$CATALOG_TAG" \
--notes "Endor agent catalog for ${CATALOG_TAG}. Wire shape: internal.endor.ai/endor/v1 EndorAgent."
There was a problem hiding this comment.
Fixed in 31e9cab, fail-closed as you suggested — no escape hatch. A "Refuse to publish an unsigned catalog" step fails the job when CATALOG_SIGNING_ENABLED is not true, and the publish step now always attaches catalog.json + .sig + public key. RELEASES.md updated: until AI-418 lands the Key Vault key, tagging a release fails rather than shipping unsigned; the catalog is still committed + drift-gated on every PR.
From Matt's review on PR #36: - catalog_wire.py: Claude Code install command is now self-contained (mkdir -p .claude/agents && cp ...) so a fresh repo without .claude/agents doesn't fail on the bare cp. Regenerated catalog.json + updated tests. - agent-kit-release.yml: add a hard gate that the agents-v* tag target is already merged to main (git merge-base --is-ancestor) before build/sign, so a tag on an unmerged commit can't run the signed release path around CODEOWNERS. - agent-kit-release.yml: fail-closed on signing — refuse to publish when CATALOG_SIGNING_ENABLED is not 'true' instead of shipping an unsigned catalog. RELEASES.md updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tl;dr: producer side of the V1 agent surface (Spine RFC 6.8). Every recipe now carries the catalog field contract, and CI emits a signed
catalog.jsonin theEndorAgentwire shape that apiserver (AI-351) consumes. This extends the existing validator + test suite + promote workflow -- no new schema system.What's in here
Schema + merge gate
audience(appsec|developer),short_description,authors(display names only, PII rejected) -- added to all 13 recipes.requires_endorctlis now required and validated as a canonical-semver constraint (>=/>), no new dependency.main(it is the telemetry join key).CODEOWNERSover the trust set: recipes, validator, emitter, signing, release workflow, and the pinned key.Catalog emitter
catalog.jsonis generated, committed, and drift-checked likemanifest.json, in the section-6.8 wire shape: snake_case keys, lowercase enums,claude-managed-agents->claude-managed,install[]from the real published layout,endorctl_min_versionoperator-stripped,cadence/fetched_at/staleomitted.catalog_versionis stamped at release.Release + signing
agents-v*): full gate -> build -> sign (Azure Key Vault, ES256, over OIDC) -> verify against the pinned public key -> GitHub Release withcatalog.json+.sig.CATALOG_SIGNING_ENABLEDuntil AI-418 provisions the Key Vault key + federation. Releases ship unsigned-but-drift-gated until then.Decisions worth a look
vulnerability-explainer,repository-dependency-reviewer) now advertiseendorctl_min_version: 1.0.0. That is the required proto field meeting the "always requirerequires_endorctl" rule -- there is no "none" value, and V1 does not gate installs on it. Flagging in case we want to handle it differently.Testing
catalog.json+manifest.jsonregenerate clean.Depends on
Lmk if the
endorctl_min_versionbit or the signing approach needs another look.🤖 Generated with Claude Code