Publish¶
ai-rulez publish turns the generated plugin bundle into release artifacts: a reproducible tar.gz, a
manifest, checksums and a plan. Building is local and offline. Only --execute --yes sends anything, and it does so
through the platform's own CLI (gh, npm) or, for OCI, through oras-go with your Docker credentials. ai-rulez never
stores or logs a credential.
ai-rulez generate --plugin && ai-rulez lock # commit the result first
ai-rulez publish --to github-release --dry-run # preflight, then print artifacts and commands
ai-rulez publish --to github-release --execute --yes
ai-rulez publish verify dist
Pipeline¶
- Resolve:
[plugin] nameandversionare required (set the version yourself; see AR961). - Preflight, in-process, stopping before anything is written:
validate(same baseline and budget handling, never looser thanerror),lock --check,verify --plugin, and a secret scan of every bundle file with the security scan's patterns. A symlink in the bundle is an error. The machine-local overlay is never loaded. When the bundle holds an Agent Plugins package (a rootplugin.json),validate --strictand the build check it against the specification and fail withAR9O0-AR9O5, in--dry-runas well. - Policy gates from
[publish]:require_approved(AR9N8) andrequire_signature(AR9N7). They run before anything is signed or built. - Build the dist directory, signing the archive when asked.
- With
--execute --yes, upload.
The tree must be clean (--allow-dirty waives it; the manifest then records dirty = true). Changes under the dist
directory are ignored. Every bundle file must also be tracked: a gitignored bundle passes the clean-tree check but would be
missing from the commit a pinned marketplace names, so publish fails (AR9N3) unless --allow-dirty.
Dist directory¶
| File | Content |
|---|---|
<name>-<version>.tar.gz |
the bundle: every file generate --plugin writes for the published runtimes |
<name>-<version>.manifest.json |
files with digests, source (the repository is [plugin] repository or the origin URL without credentials; a local-path origin is left empty), lock tie, bundle digest, and the approval, signature and SBOM slots (schema) |
ai-rulez.lock |
a copy of the repository's lock without its [[approval]] records (reviewer emails and notes stay in the repository). Tree, content pins and output pins are unchanged, so ai-rulez lock --check against the copy behaves as against the original; only [[approval]] checks differ. With no approvals the copy is byte-identical |
<name>-<version>.tar.gz.sigstore.json |
with --sign-key or --sign-keyless: the signature, see Signing |
<name>-<version>.attestation.sigstore.json |
with signing: the signed statement that binds name, version and the archive, lock and SBOM digests |
<name>-<version>.sbom.cdx.json |
with --sbom: the CycloneDX SBOM of the project (SBOM) |
SHA256SUMS |
sha256sum -c format, every file except itself and the plan |
RELEASE_NOTES.md |
bundle, runtimes, lock tree, commit, and the changes since the previous release |
publish-plan.json |
artifacts with digests and the exact argv --execute runs; no timestamps or local paths |
marketplace/ |
with --marketplace: the pinned marketplace index |
emit/* |
output of --template files and emitters |
npm/, oci/manifest.json |
the npm package directory, or the packed OCI manifest, of those targets |
A dist directory must be new, empty, or the output of an earlier publish (its artifacts are replaced; other files are
kept, and any other non-empty directory is refused). The directory is staged beside the target and installed by rename, the
plan last, so a crash never leaves a directory publish cannot reuse. A symlink at an artifact path, at a directory
component or as --dist itself is refused, never written or removed through.
Determinism¶
Equal bundle files and mtime give a byte-identical archive across runs, operating systems and umasks: entries sorted
bytewise, regular files only, uid/gid 0 with no names, modes 0644 or 0755, GNU tar headers (no PAX), gzip level 9 with
no name or time in the header. The mtime is SOURCE_DATE_EPOCH, else the committer time of HEAD, else 0. The gzip
bytes depend on the Go toolchain that built ai-rulez (compress/flate), so reproducibility holds per toolchain:
build releases with one pinned Go version. publish verify checks digests and contents, never by recompressing.
internal/publish/testdata/archive-digests.txt pins the digest of a fixed archive per Go release line; after a toolchain
bump run UPDATE_GOLDEN=1 go test ./internal/publish -run TestBuildArchive_MatchesTheGolden and review the diff.
The source tree check lists every untracked file (--untracked-files=all) and git queries time out after 30 seconds.
The signature is the one artifact that differs between runs (signing is not deterministic); the archive it signs does not.
Targets¶
GitHub release¶
--to github-release uses --tag (default v<version>, which must already exist on the remote: --verify-tag) and
--repo (default [publish.github_release] repo, else [plugin] repository, else the origin remote; OWNER/REPO or
HOST/OWNER/REPO). Tag and repo are validated against an allowlist before reaching an argv. --execute checks
gh release view first and refuses an existing release (releases are immutable) unless --force, which runs
gh release upload --clobber instead. A signed release uploads the signature, an SBOM release the SBOM. gh gets
only a fixed set of variables: PATH, HOME, USER, TMPDIR, TMP, TEMP, TZ, LANG,
LANGUAGE, LC_* (non-secret), NO_COLOR=1, TERM=dumb (plus the Windows system variables), and GH_TOKEN,
GITHUB_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, GH_HOST, GH_CONFIG_DIR, XDG_CONFIG_HOME,
XDG_STATE_HOME, XDG_DATA_HOME, proxy and certificate settings. The printed would run line is shell-quoted; gh
missing is error AR9N4 with an install hint.
npm¶
--to npm writes npm/package/ (the bundle files plus a release package.json) and plans two commands with a fixed argv
(the plan lists them relative to the dist directory; --execute runs the hardened form described below):
npm pack --ignore-scripts --pack-destination npm ./npm/package
npm publish ./npm/<scope>-<name>-<version>.tgz --access restricted --ignore-scripts [--registry URL] [--tag CHANNEL]
The paths carry a leading ./ on purpose: npm reads a bare npm/package as the GitHub repository npm/package. A test
packs the planned directory with the real npm when it is installed.
- The package is
<scope>/<plugin name>. A scope is required ([publish.npm] scopeor--npm-scope), so a package never lands in the public unscoped namespace by accident. The plugin name must be a valid npm name (lower case) and the version a semantic version. - Access is
restrictedunless[publish.npm] access = "public"or--public.--channelis the dist-tag; a prerelease version (1.4.0-rc.1) needs one, because npm 11 refuses to publish it without (AR9N6). package.jsoncarriesfiles(the bundle's top-level entries), the repository, and anai-rulezkey with the archive digest, lock tree and runtimes. Existing runtime metadata is preserved, including Pi resources, OpenCode entry points and dependencies, keywords, and license. The plan owns the package name, version, file allow-list, andpublishConfig; generated runtime names such as@acme/pi-my-toolbecome@acme/my-toolwhen the scope is@acmeand the plugin name ismy-tool. Pi users then installpi install npm:@acme/my-tool.- Malformed manifests,
private: true, and anyscriptsfield are rejected. A native runtime manifest is rewritten, so the original root.ai-rulez-generated.jsonis omitted from the derived npm package; its hashes no longer describe that package. The original release archive retains its provenance sidecar. The npm package'sai-rulezmetadata links to that archive. --executefirst verifies the dist directory again (an edited package file stops the run), runsnpm view <package>@<version> versionand refuses a version the registry has (npm versions are immutable; only npm'sE404error counts as "not there"), then packs and publishes. npm runs from an empty temporary directory with the package directory and the tarball given by absolute path and--userconfig(yourNPM_CONFIG_USERCONFIGor~/.npmrc, wherenpm loginkeeps credentials) and--globalconfig(empty unlessNPM_CONFIG_GLOBALCONFIGnames one) named explicitly, so a.npmrccommitted in the repository, which could redirect the registry and receiveNODE_AUTH_TOKEN, never applies. A[publish.npm] registryis also set as the scope's registry.--executeasks npm itself for the effective registry (npm config list --json -lunder the same isolated flags, sonpm_config_*variables, scope registries and your user npmrc count the way npm applies them), prints it and refuses one that is nothttps://. The dry-run plan shows an approximation read from the environment and your user npmrc. A registry chosen by the committed[publish.npm] registryreceives your npm token, so it is refused unless you name it with--confirm-registry URL(the public registry needs none; a registry from your own npm configuration needs none either). The packed tarball is compared with the package directory as it verified (every entry must be a regular file with the same digest;package.jsonkeys must match the plan and carry no scripts), digested afternpm pack, checked again beforenpm publishand reported with its digest. npm gets a filtered environment: the base set plusNODE_AUTH_TOKEN,NPM_TOKEN,NPM_CONFIG_*for the user config, registry and cache, proxy and certificate settings. Its output is redacted before it is shown.- The signature covers the release archive, not the tarball npm packs, so
require_signaturefails an npm release (AR9N7) and a signed npm release warns. Publish the signed archive with--to github-releaseor--to ocifor a verifiable release. - npm provenance (
--provenance) is CI-only: npm signs it with the OIDC identity of the CI job (GitHub Actions or GitLab CI withid-token: write) and refuses it anywhere else, so it is not part of the plan, whose argv must not depend on where it runs. To publish with provenance, runpublish --to npmwithout--executein that CI job and run the two planned commands yourself, adding--provenancetonpm publish.
OCI¶
--to oci packs the bundle as an OCI image manifest with oras-go and pushes it to --oci-ref (or [publish.oci] ref),
a repository host/path without a tag; the tag is the plugin version (+ becomes _).
artifactType application/vnd.ai-rulez.bundle.v1
config application/vnd.ai-rulez.manifest.v1+json the bundle manifest
layers application/vnd.ai-rulez.bundle.v1.tar+gzip the archive
application/vnd.ai-rulez.lock.v1+toml the lock
application/vnd.cyclonedx+json the SBOM, with --sbom
application/vnd.dev.sigstore.bundle.v0.3+json the signature and the attestation, when signed
annotations org.opencontainers.image.{created,version,source,revision}
The media types are custom, as the design proposed; this is the one place to change if an agent-skill convention emerges.
created is the archive's fixed mtime, so the manifest is reproducible: oci/manifest.json is written at build time and its
digest is oci_digest in the plan, the digest the registry reports after the push. --execute rebuilds the manifest from the
dist files and refuses to push unless it matches the plan. Registry credentials come from the Docker credential store
(DOCKER_CONFIG, ~/.docker/config.json and its helpers), the same place docker login writes them, and are used only for
the registry the reference names. Plain HTTP is used for loopback registries only. Pin consumers by digest:
host/path@sha256:.... An OCI tag is mutable, so --execute resolves the tag first and refuses to replace an artifact with a
different digest unless --force is given; pushing the identical artifact again is a no-op.
Several plugins¶
A [marketplace] with members or domain plugins (from_domains, [[marketplace.plugins]]) publishes one bundle per
plugin. The plugins are the entries of the generated Claude marketplace index, so the claude runtime must be generated.
dist/plugins/<name>/ a complete dist directory per plugin (publish verify works on it)
dist/aggregate/ plugins.json, marketplace/ and emit/ for all plugins, with SHA256SUMS and a plan
--only NAME limits the plugins. --execute checks every plugin's target first (the GitHub release, npm version or OCI tag
must not exist, unless --force where it applies) and uploads nothing if any check fails; a failure part-way (a registry
outage) names the plugins already published and those not, since a release cannot be rolled back. Rerun after fixing the
cause: --force replaces the assets of a release that is already out. Two plugins with one name (case-insensitively) are refused (AR9N6), because their dist directories and release files would overwrite each other. --to runs per plugin: GitHub tags are <name>-v<version>, the npm package is
<scope>/<name>, the OCI repository is <ref>/<name>. --tag does not apply; a pinned index needs a ref from
[publish.marketplace] channels or --tag. --runtime applies to domain plugins and to members: each member keeps only those of its own [plugin] runtimes that
you list, so its bundle, archive and manifest carry just those; a member that ships none of them is refused (AR9N6,
naming the member), because publishing fewer plugins than the marketplace index lists must not be silent. aggregate/plugins.json is always written: it lists every plugin of the release
with its directory and the digests of its manifest and archive. publish verify dist verifies every plugin and the aggregate
checksums, and fails (AR9N5) when a listed plugin directory is missing or changed, when a directory is not listed, or when
the aggregate does not name the plugins at all, so a release with a plugin deleted does not verify clean. plugins.json is not signed: it catches an accident (a plugin deleted or left over), not an attacker who rewrites it along with SHA256SUMS. Trust comes from each plugin's own signed release, so verify each plugin with a named trusted signer.
Pinned marketplace and channels¶
--marketplace writes marketplace/.claude-plugin/marketplace.json: the Claude marketplace index of the bundle with every
relative source replaced by one pinned to the release commit. The source types and fields follow the
Claude Code marketplace reference (checked 2026-10-06):
{ "source": "github", "repo": "acme/skills", "ref": "v1.4.0", "sha": "<40-hex commit>" }
{ "source": "git-subdir", "url": "https://github.com/acme/skills.git", "path": "plugins/extra", "ref": "v1.4.0", "sha": "..." }
{ "source": "url", "url": "https://git.example.com/acme/skills.git", "ref": "v1.4.0", "sha": "..." }
github is used for a plugin at the repository root on github.com, url for one at the root on another host, and
git-subdir otherwise; sha is the commit of the tree being published, which must be clean. Claude Code checks out sha,
so the index stays valid after the tag moves. Every other field of the entry (version, category, relevance) is kept.
--channel NAME writes marketplace/<name>/.claude-plugin/marketplace.json instead, with the ref named in
[publish.marketplace] channels (a channel not listed pins the release tag). Commit the index of each channel to the branch
users register as a marketplace. --channel is also the npm dist-tag.
Emitters¶
An emitter renders the files a managed channel needs from the published plugin. It is a pure function: no network, no clock,
no file system. --emit NAME (repeatable) or [[publish.emitters]] runs it into emit/<name>/;
ai-rulez publish emit NAME [--output-dir dir] writes only those files and no release. Output is byte-reproducible.
| Emitter | Output | Status |
|---|---|---|
cursor-team-marketplace |
.cursor-plugin/marketplace.json and plugins/<name>/ ready to commit to the repository a Cursor team marketplace imports |
verified |
port |
one Port entity JSON per plugin and skill, plus index.json naming the request each file is the body of |
experimental |
aws-agent-registry |
one CreateRegistryRecord request body per skill (SKILL, agentSkillsDefinition with the SKILL.md) and one CUSTOM record per plugin |
experimental |
agent-plugins |
<name>/plugin.json, skills/, mcp.json and extension namespaces: one validated Agent Plugins directory per plugin, without the files of other runtimes; option spec (1.0.0 or 1.1.0) |
verified |
ard |
ard.json: the Agentic Resource Discovery manifest of the skills, MCP servers and plugin, and skills/<name>/SKILL.md with [ard] base_url; needs the [ard] table |
verified |
kiro-steering |
.kiro/steering/*.md from the root rules and the skills, and distribution.json listing the files and digests for MDM packaging |
experimental |
| template | --template FILE or [[publish.emitters]] name = "template": any text from a Go template over the manifest |
verified |
- Cursor. Cursor documents
.cursor-plugin/marketplace.json(namein kebab-case,owner.name,plugins[].nameandsource) at the repository root and no way to pin a git ref, so the release is the commit that carries the tree. Tests check the index against that documented shape. The bundle must carry the cursor runtime. - Agent Plugins. The bundle must carry the
agent-pluginsruntime (orcopilot, orcodexwith the root layout). The emitter imports the package withinternal/agentplugins, validates it against the vendored official schemas and writes it again with the version ofoptions = { spec = "..." }, or the versionplugin.jsondeclares. The specification has no archive, registry or signature, so the directory is distributed like the rest of the dist (release archive, npm, OCI) and signed with it. See Agent Plugins. - Experimental emitters write formats that vendors document but publish no schema ai-rulez can test against, so they
refuse to run without
--experimental(AR9N6) and then reportAR9N9. They are compiled in, tested by golden files, and never call the target system. Sources and the dates they were last read: - Port: the entity body
identifier,title,properties,relations; the blueprint is yours (options = { blueprint = "..." }, defaultagent_skill), the property names (kind,version,description,repository,commit, digests) are a proposal you map onto it. Read 2026-10-06. - AWS Agent Registry: the CreateRegistryRecord body and its name, version, description and tag constraints, which the emitter enforces. The registry id is the URI parameter, supplied when you create the record. Read 2026-10-06.
- Kiro: steering files with the front matter
inclusion(always,fileMatchwithfileMatchPattern,manual,autowithnameanddescription). A rule's activation chooses the mode. Kiro only reads~/.kiro/steeringfor global steering and.kiro/steeringper workspace; how the files reach a machine is your MDM's job. Read 2026-10-06.
Signing¶
--sign-key FILE signs the archive with a PEM key (ECDSA or ed25519; cosign keys work; the password comes from
AI_RULEZ_SIGNING_KEY_PASSWORD or COSIGN_PASSWORD, or the variable named by --sign-key-password-env). --sign-keyless
signs with a Fulcio certificate and a Rekor entry (--sign-token-env, --sign-interactive, --fulcio-url, --rekor-url;
the certificate names your identity and goes to a public log, so use a key for a private repository). --dry-run signs
nothing and contacts nothing: it prints would sign and shows the signature files in the plan as placeholders. Both go through
internal/signing, the same code as ai-rulez sign.
The signature is a Sigstore bundle holding a message signature over the exact bytes of the tar.gz, the form
cosign sign-blob --bundle writes, so cosign verify-blob --bundle <name>.tar.gz.sigstore.json --key cosign.pub <name>.tar.gz
verifies a release signed here. The manifest's signature records the file and what the bundle claims about its signer.
A signature over the archive alone does not bind the manifest, the lock copy or the SBOM, so a release is signed twice:
the archive (above) and a DSSE in-toto statement, <name>-<version>.attestation.sigstore.json, of predicate type
https://github.com/Goldziher/ai-rulez/attestations/publish/v1. Its subjects are the archive, the lock copy and the SBOM
(sha256) and its predicate carries the plugin name, version, lock tree, those digests, the source (repository, commit, dirty flag) and the approval summary. The manifest cannot be signed
itself (it records the signature), so the statement is what ties it to the signed files. Keyless signing makes two
certificates and two log entries. publish verify with a trusted signer checks the statement's signature and signer, that
it names the manifest's name, version, source and approval summary, and that the digests equal the files in the directory; a signed release without
the statement is a mismatch (AR9N7). Independently of signing, verify compares the name and version in the archive's
own runtime manifests (.claude-plugin/plugin.json and the like) with the manifest's, so an archive relabeled as another
plugin or version is flagged.
publish verify <oci-ref> prints the digest the reference resolved to and warns when it is a tag (its owner can move it, and
the SHA256SUMS it checks are computed from what arrived): pin by @sha256: and name a trusted signer.
publish verify reports a signed bundle as unverified until you name who to trust: --key PUBLIC.pem (repeatable), or
--identity and --issuer for a keyless signature, with --trusted-root or the root ai-rulez trust update cached. A valid
signature alone only says who signed. --require-signature fails an unsigned or unverified bundle; naming a trusted signer
(--key, --identity) implies it, so a release whose signature was stripped fails instead of verifying clean.
Policy gates¶
[publish]
require_signature = true # AR9N7: publish fails unless it signs the archive
require_approved = true # AR9N8: publish fails unless the governance policy's items are approved
require_approved reuses approvals: every item the [governance] policy selects must have a valid
approval in ai-rulez.lock (missing, stale, expired, unauthorized and insufficient approvals fail it, naming the items). With no
policy selecting anything it fails, because there is nothing to approve against. When a policy is active the manifest records
approval = { required, approved } whether or not the gate is on; the shipped lock never carries the approval records.
SBOM¶
--sbom runs ai-rulez sbom on the project and ships the CycloneDX document next to the archive. The manifest's sbom
names the file and its digest, the OCI artifact carries it as a layer, and verify checks it against SHA256SUMS. The SBOM is
deterministic (no timestamp) and leaves out the approval status, so no reviewer identity leaves the repository; it does not
disturb a reproducible release.
Release notes¶
RELEASE_NOTES.md lists what changed in the lock since the previous release: added, changed and removed authored items
(kind, id, domain, first 12 hex of the digest) and remote pins, never content. The previous release is the closest tag
reachable from HEAD other than the release tag (in a multi-plugin release, the closest earlier <name>-v* tag of that
plugin); --since TAG names another (a plain tag name; options, ranges and expressions are refused) and is an error when that tag holds no lock.
Without a previous tag, or one without a lock, the section is omitted; so is it (with a warning) when the lock at the previous tag
cannot be parsed. A --since lock that cannot be read is an error.
Runtime filtering¶
--runtime R (repeatable) or [publish] runtimes limits the bundle to some of the plugin runtimes. The full set is
verified against the files on disk first; the subset is rendered again by the same generator from a copy of the
configuration, so shared files are identical. A runtime that is not in [plugin] runtimes is an error (AR9N6).
--runtime wins over the table.
Configuration¶
[publish]
runtimes = ["claude", "cursor"] # default: the [plugin] runtimes
require_signature = false
require_approved = false
allow_dirty = false
[publish.github_release]
repo = "acme/skills" # default: [plugin] repository, else origin
[publish.oci]
ref = "ghcr.io/acme/skills/conventions" # no tag; the tag is the version
[publish.npm]
scope = "@acme"
access = "restricted" # or "public"
registry = "https://npm.example.com"
[publish.marketplace.channels]
canary = "main" # channel -> the git ref its index pins
[[publish.emitters]]
name = "port"
options = { blueprint = "agent_skill" }
[[publish.emitters]]
name = "agent-plugins"
options = { spec = "1.1.0" } # optional; default: the version of the bundle's plugin.json
[[publish.emitters]]
name = "template"
template = "tools/port-entity.tmpl" # inside the project, never through a symlink
output = "port-entity.json"
There are no credential keys: forges and registries authenticate through their own CLIs and environment, and an unknown key
under [publish] fails the schema check. Flags win over the table, and a flag never lowers a policy the table sets.
Verify¶
publish verify <dir|oci-ref> checks SHA256SUMS against the files (a duplicate entry is a mismatch), flags every file in the
directory that SHA256SUMS does not list (the plan and SHA256SUMS itself excepted), checks the manifest against the
archive (every file, size and digest), the lock copy against lock.file_digest, and its tree and version against
the manifest's lock.tree and lock.version, and the archive against the determinism rules. The signature and SBOM files
the manifest names must be listed. The plan is checked too: each artifact's path, size and digest against the file and
SHA256SUMS, and its commands against what --execute would build for the manifest (the gh release create argv, the npm
pack and publish argv, or the OCI reference and a manifest rebuilt from the dist files), so an edited plan cannot smuggle in a
command. Files and the archive's total uncompressed size are capped at 512 MiB. An OCI reference is pulled into a temporary
directory first. Exit 0 verified, 2 mismatch, 1 unreadable directory.
Testing without a registry¶
Tests use fake gh and npm runners, an in-memory OCI registry (go-containerregistry's registry package behind
httptest), and golden files for every emitter. A live round trip against registry:2 runs with
AI_RULEZ_LIVE_PUBLISH=1 go test ./internal/publish/oci -run Live and needs Docker. Nothing in the repository publishes
anything.