Publishing Artifacts

Accent’s artifact registry is an index, not a content store. It records where an artifact lives, what its bytes hash to, and who published it – while the artifact itself stays in your own repository, hosted by you. Publishing is therefore something you do, not something the registry does for you, and accent package is the command that produces what a submission needs.

One command handles all three artifact kinds, so an artifact you publish by hand and one published by a release pipeline are byte-identical.

What It Produces

accent package ./my-theme --author acme --version 1.2.0
Detected a theme in the given directory.
Packaged my-theme 1.2.0
  archive   /path/to/dist/my-theme-1.2.0.tar.gz
            25556 bytes, 16 files
  checksum  sha256:f76e604e022ed36695ddd8fb80b86d84cbe9ccdc95fc3285b43101c0376e03a0
  manifest  /path/to/dist/themes/my-theme/1.2.0.json

Two files, with different destinations:

OutputWhere it goes
<name>-<version>.tar.gzUpload it to your own release (a GitHub release asset, or any HTTPS URL you control)
<version>.jsonCopy it into a registry submission

The manifest is written under the path it will occupy in the registry (<kind>s/<name>/<version>.json), so submitting is a copy rather than a transcription.

Both land in dist/ unless you pass --out. If you package from inside a git repository, add dist/ to its .gitignore – otherwise the archive is untracked content of your repository, and an archive is small enough that nobody notices it until it is in a commit.

What Goes Into the Archive

Every archive contains exactly one top-level directory, named after the artifact. The contents are fixed per kind – anything else in your working directory is left out and reported:

KindContents
pluginplugin.toml, plugin.wasm, optional assets/ and models/
themetheme.yaml, templates/, optional assets/, shortcodes/ and models/
templateconfig.yaml, content/, themes/, optional media/, plugins/, models/ and .gitignore

A template may carry a root .gitignore, and should: its contents become the user’s new project, so the ignore file is what stops that project from committing its own caches and build output the first time its author runs git add -A. A plugin or a theme installs into a subdirectory of a project that already has one, so a root .gitignore is not part of either and is reported as left out. Ignore files inside an included directory – a theme’s assets/.gitignore, say – are ordinary content and are always archived.

.git, .DS_Store and .accent-hub.json are never archived at any depth. The last one matters most for a starter template: it is your machine’s trust record, written into every artifact accent theme install and accent plugin install unpack, and it carries your registry URL and the vendor’s licence key.

Names are matched case-sensitively against the directory listing, so a folder called Assets/ is not picked up as assets/ even on macOS, where the filesystem would happily answer to either. That is what keeps the same checkout packaging identically on your laptop and on a Linux release runner.

A required directory that is empty is refused: an archive stores files, so an empty templates/ would simply be absent from the result.

Build Inputs Are Refused

A directory holding src/, Cargo.toml, target/, wit/, or node_modules/ is rejected outright, naming the offending path:

Error: 'Cargo.toml' is a build input and artifacts may not carry one -- a
plugin ships its compiled component, not its source. Package a directory
holding only the artifact's own contents.

This is why you cannot package a plugin’s source repository directly. Build the component first, then stage the artifact’s own contents:

cargo component build --release
mkdir -p dist/my-plugin
cp plugin.toml dist/my-plugin/
cp target/wasm32-wasip2/release/my_plugin.wasm dist/my-plugin/plugin.wasm
cp -R assets dist/my-plugin/ 2>/dev/null || true

accent package dist/my-plugin --author acme

Cargo.toml, node_modules/, target/, and wit/ are refused at any depth – that is what catches an unbuilt plugin hiding inside a starter template’s plugins/. A nested directory merely named src is left alone, because assets/js/src/ is an ordinary thing for a theme to ship; only a top-level src/ marks the whole directory as a source tree.

Symbolic links are refused for the same reason build inputs are: the installer rejects links outright, so an archive containing one could never be installed.

Naming and Versions

The kind is inferred from the marker file in the directory (plugin.toml, theme.yaml, or config.yaml); pass --kind when a directory could be read as more than one.

Names are kebab-case and become both the archive’s top-level directory and the registry key. A plugin’s name comes from plugin.toml; a theme’s and a template’s from the directory name. Use --name to override.

A plugin’s name and version come from plugin.toml and travel inside the artifact, so --name and --version cannot override them – the published entry would disagree with what actually installs. Change plugin.toml instead. The directory name does not matter: packaging acme-forms-plugin/ whose plugin.toml declares acme-forms produces an acme-forms artifact.

Versions are never invented. A plugin takes its version from plugin.toml and a theme from theme.yaml; a starter template has no version of its own, so --version is required:

Error: cannot determine a version to publish: add a top-level 'version:' to
theme.yaml, or pass --version

That strictness is deliberate. A published manifest is immutable, so a version number is spent the moment it is submitted – defaulting to 1.0.0 would burn one silently.

Reproducible Archives

Packaging the same tree twice produces byte-identical archives. Entry order is sorted, timestamps and ownership are zeroed, and permissions are fixed, so the checksum you submit is one you (or your CI) can reproduce later. This is what lets anyone tell a rebuild apart from a substitution.

To make a whole run reproducible, including the manifest, pin the timestamp:

accent package ./my-theme --author acme --version 1.2.0 \
  --published-at 2026-08-08T00:00:00Z

Filling In the Download URL

The manifest needs the URL your archive will be downloaded from, which usually does not exist until you have uploaded it. Package first and add it afterwards, or pass it up front when the URL is predictable:

accent package ./my-plugin --author acme \
  --artifact-url https://github.com/acme/my-plugin/releases/download/v1.2.0/my-plugin-1.2.0.tar.gz

Without one you get a warning rather than an error, and the manifest still carries the checksum and size.

For artifacts you deliver out of band rather than publish, use --external: the manifest records the digest and no URL, and users install with accent plugin install <name> --from <path>, which still verifies the file against that digest. Publishing the digest is worth doing whenever the file is identical for every buyer — it is the difference between out-of-band delivery and unverified delivery, and the CLI tells your buyers which one they got.

Everything you supply is checked before the archive is written: --accent-version must parse as a semver requirement, --published-at as RFC 3339, --author as a kebab-case handle (it resolves to authors/<handle>.json), and --artifact-url and --purchase-url must be https. A published manifest is immutable, so a value that only failed at install time would have spent the version number carrying it.

One field is not taken from the artifact: with no --accent-version, the declared host range comes from the version of accent that packaged it, and the command says so. Repackaging an unchanged theme on a newer binary would otherwise quietly lock out every host below it.

Commercial Artifacts

Downloads are never gated – a licence governs use, not access. Declare the pricing so the CLI can tell a user where to buy when a licence check fails:

accent package ./my-plugin --author acme \
  --commercial \
  --purchase-url https://acme.example/my-plugin \
  --seller vendor

Islands

A plugin’s [islands.*] declarations are recorded in the manifest so the hub can surface them. Each island’s js entry point must actually be in the archive – otherwise the hub would advertise a component the installed plugin cannot load – so an island pointing at a missing file, or at a path outside the artifact’s contents, is refused.

Island names share one global registry per page, so a bare name silently collides with any theme island using the same word. package records what you declared and tells you when it is not namespaced:

Warning: island name(s) reading-time are not namespaced. Island names share one
global registry per page, so 'reading-time' would collide with any theme island
of the same name -- prefer 'my-plugin:reading-time'.

Claiming a Trust Tier

The manifest carries a trust tier, and it is community unless you say otherwise. community asserts nothing: the entry passed automated checks and nobody reviewed the code, which is what an open submission queue can honestly say about itself.

The other two tiers have requirements the registry checks on your pull request:

TierRequirement checked at submission
officialThe entry’s repository is owned by the AccentCMS organisation
verifiedThe registry’s authors/<handle>.json record is marked identity-verified by the maintainers
communityNone – the default
accent package ./my-plugin --author acme --trust community

So the flag is settable, and claiming a tier you do not qualify for buys nothing: the check runs on the submitted document regardless of what wrote the field, and a claim that fails is rejected rather than downgraded – the whole submission is refused until the tier is corrected. package says what each non-default tier requires at the moment it writes the claim, so you learn it before CI tells you.

Signatures Are Not Yours to Write

The manifest accent package emits deliberately carries no signature. Only the registry holds the signing key, and it stamps the entry when your submission is merged. A submission that arrived with a signature field would be rejected – so if you are hand-editing a manifest, do not add one.

Options

OptionMeaning
--kind <plugin|theme|template>Skip inference
--name <name>Override the artifact name
--version <semver>Exact version; required for a template
--out <dir>Output directory (default dist)
--author <handle>Author handle; required
--accent-version <req>Host range supported (default: this binary’s release series)
--artifact-url <url>Public download URL
--externalNo download URL; delivered out of band
--commercialRequires a licence to run
--purchase-url <url>Where to buy
--seller <vendor|accentx>Who takes the money
--trust <official|verified|community>Provenance tier claimed (default community)
--published-at <rfc3339>Pin the publication timestamp

Verifying Before You Submit

Every archive is unpacked back through the installer’s own extraction rules before the command returns, so an artifact the client would refuse fails here rather than at someone else’s install time. You can confirm the round trip yourself:

accent package ./my-plugin --author acme --out dist
accent plugin install my-plugin --from dist/my-plugin-1.2.0.tar.gz --allow-unverified

--allow-unverified is required here and only here: install --from checks the file against the checksum in the registry’s copy of your manifest, and before you have submitted there is nothing to check against. Once the entry is published, the same command works without the flag.

Submitting to the Hub

Upload the archive somewhere permanent under the same owner as the repository you are listing – a GitHub release of that repository is the normal choice – then open a pull request against AccentCMS/hub-registry.

A submission is two files: your manifest at <kind>s/<name>/<version>.json, which you write, and the discovery index at <kind>s/index.json, which you generate. hub-registry is attached to the registry’s own releases, and is the same binary its CI runs on your pull request:

hub-registry generate .

Commit what it writes. For a name the registry has not seen before it creates the index entry with description, license and repository left blank and tells you so – no manifest carries those, so it has nothing to copy. Fill in those three, then run generate again to confirm nothing else moved.

Submissions are open. Automated checks are the gate, and there is no human in the admission path: a pull request that passes CI is merged, and maintainer review is what earns a higher trust tier rather than what admits an entry. CI re-downloads your artifact, re-verifies its checksum, and unpacks it against the same limits the installer enforces, so an archive a client would refuse fails at review time instead of on somebody’s machine.

The registry’s CONTRIBUTING.md is the authority on the process, the full list of checks, what each trust tier means, and when an entry is delisted. Three things about it are worth knowing before you start:

  • The index is generated, and one part of it is not. Everything that decides something – latest_version, versions, trust, author, pricing, updated_at – is derived from your manifests, and CI regenerates the index and fails if the committed one disagrees. Editing those by hand is what fails: the index decides which version an unpinned install resolves to. The catalog fields (description, license, repository, homepage, tags, screenshots) describe the entry rather than a version, have no other home, and are yours to write and to keep current.
  • Do not add index_signature. The registry stamps it at merge, and a submission that carries one it did not receive from generate is rejected.
  • A published manifest is immutable. Only yanked may change. Fixing a mistake means publishing a new version; retracting one means setting "yanked": true and regenerating, which keeps that version resolvable by an exact pin, so an existing install keeps working, while never being what an unpinned install resolves to. Retracting every version of an entry is allowed: the entry stays listed, because a retracted artifact disappearing from discovery is the one thing yanking is defined not to do.