Skip to content

feat(registry)!: release CLI, idempotent publish, promote, quarantine review, signed downloads and GitHub OIDC trusted publishing - #99

Merged
mrgoonie merged 11 commits into
mainfrom
sx-phase2-registry
Oct 19, 2026
Merged

mrgoonie merged 11 commits into
mainfrom
sx-phase2-registry

Conversation

@mrgoonie

@mrgoonie mrgoonie commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Phase 2 of the registry (#34, #54): the release CLI surface, publish idempotency, channel promotion, quarantine review, signed downloads for private releases, and GitHub OIDC trusted publishing, plus a manual-dispatch workflow that publishes an AgentKit foundation skill. The new routes sit under the release API, and production has had SKILLX_RELEASES_API set to "1" since #70, so they are live as soon as the worker deploys. Migration 0018 is additive only, but it must be applied to remote D1 before the deploy. SKILLX_TOKEN_SECRET gates only OIDC trusted publishing and signed downloads.

Breaking (CLI): skillx release publish --json now prints the {ok, data} / {ok:false, error} envelope instead of the bare release object (commit feat(cli)! with a BREAKING CHANGE footer).

#34 checklist → evidence

Task Status Evidence
Archive layout, deterministic packer + SHA-256, R2 immutable, package/release APIs, publisher namespaces + authorization Already on main (Phase 1) unchanged; skillx pack reuses the packer
Rename GitHub publish → import Done packages/cli/src/commands/import.ts; publish owner/repo / GitHub URL still works with a stderr deprecation warning (publish.ts publishMode, tests in release-commands.test.ts, built-cli.test.ts)
skillx validate Done commands/validate.ts: packs, requires SKILL.md frontmatter with name/description, checks name/semver/kind/channel, exit 2 with details.problems
skillx pack Done commands/pack.ts; built CLI packs the same commit twice → identical releaseDigest and identical tar bytes (built-cli.test.ts, also checked by hand: sha256:b224…3cc0 twice)
Release skillx publish <dir> Done commands/release-publish.ts; release pack/publish kept as aliases; JSON envelope + standard exit codes (0/1/2/3/5/6)
skillx promote Done commands/promote.ts → POST …/releases/:version/promote (release-promote.ts): audited compare-and-set of the channel (subject_kind release_channel); scoped keys / OIDC tokens may only promote, demotion needs owner or admin (release-promote.test.ts)
Publish idempotency keys Done release-idempotency.ts + release_publish_idempotency table (24 h): same key + same request replays the stored answer; same key + different request → 422 idempotency.key_reused; in-flight duplicate → 409 (release-idempotency.test.ts)
CI-scoped publisher credentials Done scoped keys (main) + OIDC publish tokens sxo_ scoped releases:publish:@handle, 15 min
GitHub OIDC trusted publishing Done (safe subset, see below) github-oidc.ts (RS256 only, JWKS from token.actions.githubusercontent.com, iss/aud/exp/iat/nbf, claim checks), oidc-exchange.ts, trusted-publishers.ts admin API; oidc-trusted-publishing.test.ts (15 tests with a generated RSA key + JWKS)
Quarantine and review state Done GET /api/v1/admin/quarantine, POST …/releases/:version/review approve/reject; approve restores the pre-quarantine state (a yanked release stays yanked unless restoreTo: "published"), audited in release_state_audit + release_reviews (release-review.test.ts)
Yanking and release-resolution policy Done yank on main; policy written in docs/release-resolution-policy.md (states, channel rules, operator review)
Signed short-lived artifact download Done POST /api/v1/releases/:digest/artifact/url → 5 min HMAC token, GET /api/v1/downloads/:token; public releases keep the plain artifact route (release-download-url.test.ts)
Publish one AgentKit skill manually Not done here needs production access; the operator runbook is in docs/deployment-guide.md
Publish the same AgentKit artifact through CI Workflow ready, not run .github/workflows/publish-agentkit-foundation.yml (dispatch only, expected-digest input fails the job before upload if the CI pack differs from the manual one)

Acceptance criteria:

  • Same version cannot receive different bytes: unchanged create-only R2 + version uniqueness from main; idempotent replay never rewrites.
  • Manual/CI publish of same input → same digest: the workflow uses the same skillx pack and compares with expected-digest; deterministic-pack test above.
  • Retried publish is safe: Idempotency-Key; the workflow sends the same key on the OIDC attempt and on the key fallback.
  • Publisher token cannot change commerce/account state: sxo_ tokens and scoped keys are verified only in release-auth.ts (publish, promote). Legacy routes look keys up by plain hash and never match them (scoped-key-isolation.test.ts); the state API refuses scoped keys; review and trusted-publisher APIs are admin-secret only.
  • AgentKit skill published without altering ak: no installer code touched.

OIDC: what is safe and what was left out

Shipped: verification of GitHub's RS256 JWT against its JWKS (cached 10 min, refetch on unknown kid at most once a minute, keys up to 70 min old still verify while GitHub's JWKS endpoint fails), audience pinned (SKILLX_OIDC_AUDIENCE, default skillx), a max token lifetime of 1 h with 60 s skew, matching on the numeric repository_id (resists repo rename/resurrection) plus repository, workflow path, and optional ref and environment; only push, workflow_dispatch and release events may publish (allowlist); jti is stored for 7 days, so a replayed JWT gets 409. The exchanged token is HMAC-signed, scoped to one publisher, and lasts 15 minutes.

Not shipped: OIDC for anything other than publish and promote, and wildcard workflow or ref patterns. Both widen trust in ways that need a product decision.

AgentKit foundation naming (@bestagentkits/<x>)

The workflow publishes the kit skill directory kits/<kit>/skills/ak-<x> as @bestagentkits/<x>. The ak- prefix is dropped, and the version must equal the version in kits/<kit>/kit.yaml (the job fails otherwise). Slugs starting with ak- or ak: are refused by the job and by the registry. The packed file set is the skill directory at the pinned commit, which is the tree AgentKit hashes, so the releaseDigest equals AgentKit's agentkit.skill-tree/v1 digest. AgentKit references these releases as skillx:bestagentkits/<x>.

This PR adds no @agentkit/* exception. Reservation, contract and vectors are unchanged. Three things block it:

  1. The signed skillx.release/v1 contract always validates identities as provider skillx, so @agentkit/* is invalid. The golden vectors also require an @agentkit manifest to be rejected.
  2. sourceProvenance is a closed {repo, commit, path}. Adding provider would break every v1 verifier, including the npm CLI 0.5.0 and the AgentKit Go client.
  3. AgentKit itself refuses SkillX releases under @agentkit.

The real fix is a skillx.release/v2 with kit provenance, tracked in #100. SKILLX_AGENTKIT_PUBLISH_KEY keeps its name; its value must be a key scoped to releases:publish:@bestagentkits.

Migrations

Env / secrets

  • SKILLX_TOKEN_SECRET (new Worker secret, ≥ 32 chars). It gates only OIDC trusted publishing (503 oidc.unavailable without it) and signed download URLs (503); key-based publish, idempotency, promote and review work without it.
  • SKILLX_OIDC_AUDIENCE (optional var, default skillx).
  • GitHub environment skillx-publish: restrict deployment branches to main and require reviewers. Secrets: SKILLX_AGENTKIT_PUBLISH_KEY (fallback key scoped to releases:publish:@bestagentkits) and AGENTKIT_READ_TOKEN (only while agentkit is private).
  • Deploy order and the trusted-publisher setup: docs/deployment-guide.md → "Registry publishing additions".

Verification

  • pnpm typecheck ✅ · pnpm test ✅ (103 files, 2127 passed, 2 skipped) · apps/web/tests/releases 8 consecutive runs, 246/246 each · release-review + release-download-url 25 consecutive runs, 0 failures · pnpm --filter web build 4 consecutive runs, all exit 0 · drizzle-kit generate: no schema drift
  • An earlier full run once hit the 5 s timeout in packages/cli/src/lib/git-tree-tar.test.ts (94-file git pack under full-suite load on Windows; untouched here; passes alone).
  • Packed CLI installed with npm install --ignore-scripts --omit=dev from the pnpm pack tarball runs --version and pack (the workflow's new install path).
  • Built CLI: validate exit 0; pack ×2 → same digest, same tar hash.
  • wrangler d1 migrations apply DB --local ✅ (no remote run).

Review fixes (head cd10964 → 5829807)

  • M1 approve restores the pre-quarantine state from release_state_audit (walks the same-timestamp edge chain, since yanked → quarantined runs through published); a yanked release stays yanked, {"restoreTo":"published"} publishes it; the approval is pinned to the quarantine it read via quarantined_at. Test: yank → quarantine → approve, plus validation of restoreTo. Documented in docs/release-resolution-policy.md and the API reference.
  • m1 Idempotency stores only 2xx, 400, 415 and 409 release.version_exists (isReplayable); everything else releases the claim (test: a 413 subrequest-budget refusal, then a successful retry with the same key). An oversize upload is 413 before any claim.
  • m2 the idempotent route passes the bytes it read and the authorized publisher into ingest (PreparedUpload): one buffer, one auth.
  • m3 PR body and deployment guide corrected (flag already on since feat(releases): enable the release API in production #70; migration first; token secret scope).
  • m4 workflow: build-cli job (contents: read, no id-token, no environment) runs pnpm install/build/pnpm pack; publish installs the tarball with --ignore-scripts. Runbook: skillx-publish limited to main with required reviewers.
  • m5 OIDC event allowlist push, workflow_dispatch, release (tests for 6 refused and 2 more accepted events).
  • m6 feat(cli)! commit with the BREAKING CHANGE footer for the --json envelope; the CLI README documents it and the import rename.
  • m7 X-SkillX-Release-Channel only on the short-lived version route, never on immutable digest routes; policy states the channel is an unsigned listing field (signed statement: skillx.release/v2: kit provenance (provider/origin) to allow @agentkit foundation releases #100).
  • n1 key fallback also on oidc.unavailable (read from error.details.code). n2 :: runs and line breaks stripped from server text before ::error::. n3 stale JWKS grace (60 min past the TTL). n4 expired release_publish_idempotency rows and oidc_token_exchanges older than 7 days deleted in batches of 50 on the write path. n5 registry-schema.ts audit comments updated.

Re-review fixes (head 5829807 → d486263)

  • M-A stateBeforeQuarantine reads the last two applied audit edges by rowid (true write order; the table is append-only) and treats only the exact composite pair yanked → published + published → quarantined from one batch (same created_at) as a yank; a direct yanked → quarantined edge also counts. Anything else is published. Tests: yank and quarantine in the same millisecond with frozen Date (fails on the previous code), and an approval racing a re-quarantine (quarantined_at guard → 409, no review row).
  • m-A fromBase64url in release-tokens.ts accepts only canonical encodings (re-encoding must equal the input), so a MAC has one spelling. The tamper test changes a middle MAC character and also checks a padding-bit respelling of the last one → 404.
  • m-B during a JWKS outage the refetch is retried at most every JWKS_MIN_REFRESH_MS (lastFailureAt); the test counts fetch attempts.
  • Nits: oidc-exchange.ts indentation and JSDoc placement fixed; cleanup indexes folded into migration 0018 (schema, SQL, snapshot, rollback lines); PR retitled feat(registry)!:.

Notes for review

  • sx-phase1-discovery (not merged) adds lib/output.ts + lib/run-command.ts helpers. This branch keeps its own small lib/release-cli.ts with the same envelope and exit codes; once phase 1 lands, the helpers can be consolidated. A dry merge with that branch conflicts only in packages/cli/src/built-cli.test.ts. A dry merge with sx-bugs-47-49-61-79 is clean.
  • release-read.ts, release-state-http.ts and ingest-release.ts were already near or over the 200-line guideline on main; this PR grows them slightly.
  • .github/workflows/publish-release.yml now reads the envelope (j.data ?? j).

Refs #34 #54 #88 #100

🤖 Generated with Claude Code

mrgoonie and others added 4 commits October 19, 2026 19:58
…d downloads and GitHub OIDC trusted publishing

Adds migration 0018 (additive tables plus release_channel), the Idempotency-Key replay store, audited channel promotion, admin approve/reject of quarantined releases, short-lived signed artifact URLs for private releases, and GitHub Actions OIDC exchange for scoped publish tokens with a trusted-publisher config API. All routes stay behind SKILLX_RELEASES_API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dir> and promote

publish owner/repo keeps working as a deprecated alias of import with a stderr warning; release pack/publish remain aliases. Release commands share the JSON envelope and standard exit codes, and publish supports --idempotency-key and --oidc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se resolution policy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sion

Kit directories ak-<x> map to @bestagentkits/<x> because @agentkit/* and ak- slugs stay reserved by the skillx.release/v1 contract. The workflow refuses a version that differs from kits/<kit>/kit.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

This PR overlaps with #100 (skillx.release/v2 kit provenance, branch claude/modest-wozniak-p7fbwy) in ingest-release.ts, packages/cli/src/commands/release-publish.ts, docs/api-reference.md, docs/cli-reference.md and CLAUDE.md.

#100 makes these changes:

  • Adds provenance to the X-SkillX-Release metadata.
  • Picks the provider before authorization (requestedProvider).
  • Signs kit releases as v2, using manifestSchemaVersionOf.
  • Adds --kit-origin to skillx release publish.

Whichever lands second should keep both sets of changes when resolving the conflict. After #100 lands, foundations can publish as @agentkit/<slug> with --kit-origin remote-signed and a releases:publish:@agentkit key, instead of @bestagentkits/<x>.


Generated by Claude Code

mrgoonie and others added 5 commits October 19, 2026 20:37
…h paths

- Approving a quarantined release restores the state it had before the
  quarantine (from release_state_audit): a yanked release stays yanked
  unless the admin sends restoreTo "published". The approval is pinned to
  the quarantine it read by quarantined_at.
- Idempotency-Key stores only answers that are a function of the request
  bytes (2xx, 400, 415, 409 release.version_exists) and releases the claim
  for everything else; the body is read and the caller authorized once.
- The OIDC exchange allows only push, workflow_dispatch and release events,
  keeps verifying with recently fetched JWKS while GitHub is unreachable,
  and expired exchange and idempotency rows are deleted in small batches.
- The current channel header is sent only on the short-lived version route,
  never on immutable digest routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI is installed and built in a job without id-token or the
skillx-publish environment, and the publish job installs the packed
tarball with lifecycle scripts disabled. The API key fallback also covers
a registry answering oidc.unavailable, and server text is stripped of
line breaks and '::' before it reaches a workflow command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules

The registry publishing routes are live on deploy (SKILLX_RELEASES_API has
been on since #70), so migration 0018 must be applied first, and
SKILLX_TOKEN_SECRET gates only OIDC and signed downloads. Documents
restoreTo on quarantine approval, the unsigned listing channel, the OIDC
event allowlist, the JWKS grace period, retention, the idempotency replay
rules and the skillx-publish environment protection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BREAKING CHANGE: `skillx release publish --json` (now an alias of
`skillx publish <dir>`) prints the `{"ok": true, "data": {...}}` /
`{"ok": false, "error": {...}}` envelope instead of the bare release
object, and failures use the documented exit codes instead of always 1.
Read the release from `.data`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… token gaps

- Approval reads the last two applied audit edges by rowid and treats only
  the composite yanked -> published -> quarantined batch as a yank, so a
  yank and a quarantine in the same millisecond no longer re-publish.
- Token segments must be canonical base64url, so a MAC has one spelling;
  the tamper test changes a middle character.
- While the JWKS endpoint fails, the refetch is retried at most once a
  minute instead of on every request.
- Indexes for expired-row cleanup on release_publish_idempotency.expires_at
  and oidc_token_exchanges.created_at, folded into migration 0018 (not
  applied anywhere yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrgoonie mrgoonie changed the title feat(registry): release CLI, idempotent publish, promote, quarantine review, signed downloads and GitHub OIDC trusted publishing feat(registry)!: release CLI, idempotent publish, promote, quarantine review, signed downloads and GitHub OIDC trusted publishing Oct 19, 2026
…ented

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant