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:
| Output | Where it goes |
|---|---|
<name>-<version>.tar.gz | Upload it to your own release (a GitHub release asset, or any HTTPS URL you control) |
<version>.json | Copy 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:
| Kind | Contents |
|---|---|
plugin | plugin.toml, plugin.wasm, optional assets/ and models/ |
theme | theme.yaml, templates/, optional assets/, shortcodes/ and models/ |
template | config.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:
| Tier | Requirement checked at submission |
|---|---|
official | The entry’s repository is owned by the AccentCMS organisation |
verified | The registry’s authors/<handle>.json record is marked identity-verified by the maintainers |
community | None – 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
| Option | Meaning |
|---|---|
--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 |
--external | No download URL; delivered out of band |
--commercial | Requires 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 fromgenerateis rejected. - A published manifest is immutable. Only
yankedmay change. Fixing a mistake means publishing a new version; retracting one means setting"yanked": trueand 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.