<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://portaki.app/logo-dark.svg">
<img src="https://portaki.app/logo-light.svg" width="177" height="48" alt="Portaki">
</picture>
</p>
<h1 align="center">portaki-cli</h1>
<p align="center">
<strong>Command-line toolchain for Portaki Wasm modules</strong><br>
Binary name <code>portaki</code> — init, build, lint, test, and OCI publish.
</p>
<p align="center">
<a href="https://crates.io/crates/portaki-cli"><img src="https://img.shields.io/crates/v/portaki-cli.svg" alt="crates.io"></a>
<a href="https://docs.rs/portaki-cli"><img src="https://img.shields.io/docsrs/portaki-cli" alt="docs.rs"></a>
<a href="https://github.com/PortakiApp/portaki-sdk/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License Apache-2.0"></a>
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.75+-dea584?logo=rust&logoColor=white" alt="Rust 1.75+"></a>
<a href="https://extism.org/"><img src="https://img.shields.io/badge/Extism-Wasm-7C3AED" alt="Extism"></a>
<a href="https://portaki.app"><img src="https://img.shields.io/badge/site-portaki.app-f59e0b" alt="portaki.app"></a>
</p>
<p align="center">
<a href="#install">Install</a> ·
<a href="#commands">Commands</a> ·
<a href="#typical-workflow">Workflow</a> ·
<a href="#related-crates">Crates</a> ·
<a href="#license">License</a>
</p>
---
Authors write modules against [`portaki-sdk`](https://crates.io/crates/portaki-sdk). At build time this binary compiles to `wasm32`, merges proc-macro emissions from `OUT_DIR/portaki-emissions/`, and packages the OCI layers `portaki release` pushes to Portaki's OCI repository.
## Install
```bash
cargo install portaki-cli
# or tip of main:
cargo install --git https://github.com/PortakiApp/portaki-sdk --locked portaki-cli
```
Requires the Wasm target:
```bash
rustup target add wasm32-unknown-unknown
```
## Commands
| `portaki status` | The developer space home, in the terminal: the five steps, the repository link, the sandbox, the last version (channel, `available`/`draft` and what is missing, signature, review), errors over 24 h, open reports — and the exact next command |
| `portaki doctor [--offline]` | Check this machine, one line per check with its fix: session for the targeted platform, CLI vs locked SDK vs latest, toolchain and wasm32 target, cosign, the email Sigstore would certify vs your verified Portaki email, the repository link, `.cargo/config.toml` / `rust-toolchain.toml` that redirect `cargo` |
| `portaki init` | Scaffold a module from a template, `listing.json` included — asks for its name, description, tagline, category and author in a terminal |
| `portaki login [--no-browser]` / `portaki logout` | Open a developer session with the device grant / end it here and revoke it on the platform that issued it |
| `portaki build` | Compile Wasm + merge emissions → `manifest.json`, tamponne la version SDK liée |
| `portaki check [--fix] [--only fmt,clippy,tests,build,lint,i18n] [--channel preview\|stable]` | The gate `portaki release` applies, the same code: `cargo fmt --check`, `cargo clippy -D warnings`, tests with the conformance battery, the wasm build, the manifest, every text in every language. `--fix` formats and applies clippy's fixes first. `lint` and `i18n check` are hidden aliases |
| `portaki add permission <perm>` | Turn on the `portaki-sdk` feature that declares the permission (`permissions add` is a hidden alias) |
| `portaki add connector <id>` | Declare a built-in connector (`open-weather`, `nuki`, …) with its `#[portaki_sdk::connector]` in `src/lib.rs` |
| `portaki add language <lang>` | Write `i18n/<locale>.json` (and `email_i18n/`) with every key of the other bundles, texts to write |
| `portaki upgrade [--to <v>] [--dry-run]` | Move to another SDK version, then build, test, lint and compare the sandbox renders; `--dry-run` touches no file and no sandbox. `sdk upgrade` is a hidden alias |
| `portaki connectors` | Show each declared egress, its permission and its credential |
| `portaki dev --watch` | Rebuild and redeploy on every save, follow the sandbox logs, replay the 7 scenarios after each deploy |
| `portaki dev --forget` | Remove this module from the sandbox — a tried-once module leaves a row otherwise |
| `portaki dev` | Build, deploy to the **hosted sandbox** of your account, and show what the run did — there is no local gateway |
| `portaki run <operation> [--params <json>] [--as host\|guest] [--stay <id>]` | Run a query or a command on the build `portaki dev` deployed, and print its trace; bare, list the operations. `dev --dispatch` is a hidden alias |
| `portaki scenarios [run\|reset]` | The Scenarios tab: every surface against the seven pathological stays — the last grid, `run` to replay it, `reset` to put the sandbox fixtures back |
| `portaki preview <surface> [--input <json>] [--stay <id>]` | Render a surface of the sandbox build; bare, list the surfaces. A sandbox build is unsigned: it never runs in production |
| `portaki reports [--open] [--type error\|problem\|suggestion]` / `portaki reports resolve <id> --note "…"` | What the runtime and hosts report on the module; resolve one once fixed, with an internal note the host never reads |
| `portaki logs [--module <id>] [--code <code>]` | Follow the module's sandbox logs, optionally only the lines naming an error code |
| `portaki ci <modules\|sdk-version\|check\|info\|build\|release\|report>` | What a CI workflow used to ask in bash — see [From a CI workflow](#from-a-ci-workflow) |
| `portaki test` | Forward to `cargo test` in the module crate |
| `portaki release [--channel preview\|stable] [--notes …] [--no-sign] [--require-available] [--dry-run]` | Test, build, push to Portaki's OCI repository with a short-lived push right from the registry, sign, and announce — locally with `portaki login`; from CI, `ci build` then `ci release`. `publish` is a hidden alias |
| `portaki release status <v>` | Where a published version stands: draft or available, signature, review, what is missing — and the next command |
| `portaki release notes <v> [--complete] [--notes …] [--permission-reason …] [--host-action …]` | Read a version's release notes; with a flag, complete them — from the flags, then `CHANGELOG*.md` — and let the registry publish the draft |
| `portaki link [--all]` | Open the dashboard page that links this module; with `--all`, link every module of the monorepo like this one |
| `portaki catalog` | Dump the SDUI primitive catalog |
| `portaki inspect <url>` | GET a URL and pretty-print it when it is JSON — no registry authentication |
| `portaki docs` | Print how to open the local SDK documentation |
## Scaffolding a module
`portaki init <id>` writes a buildable module crate and its `listing.json` — the public listing,
published with each release. There is no catalogue manifest to write: see below. In a terminal it asks five
questions; Enter skips one and keeps the default:
```
display name [<id>]:
description, in one sentence:
catalogue tagline, 90 characters at most:
category [stay]: (1. arrival 2. stay 3. around 4. formalities)
author name [<git config user.name, or TODO>]:
```
A tagline past 90 characters is asked again. `--display-name`, `--description`, `--tagline` and
`--category` answer ahead of time and are not asked; `--yes` — or no terminal, as in a CI — asks
nothing and keeps the template for the rest. What is not answered keeps an instruction starting
with « À compléter » / "To be completed", which the `listing` conformance check refuses to publish:
fill `listing.json` in, or delete it to write the listing in the dashboard.
## The manifest is written from the code
`portaki build` writes the catalogue manifest the platform reads; nobody writes it by hand.
| `id`, `version` | the crate's `name` and `version` in `Cargo.toml` |
| `name`, `description` | `module.displayName` / `module.description` in `i18n/*.json` (keys set by `portaki_module!`) |
| `author`, `icon`, `type`, `maturity`, `sortOrder` | `portaki_module!(author, author_url, icon = IconName::…, module_type = ModuleType::…, maturity = Maturity::…, sort_order)` |
| `hostSurfaces`, `guestSurfaces` | `#[surface(host, id, placement = HostPlacement::…, design_id = DesignId::…, label_key, icon = IconName::…, path)]` / `#[surface(guest, id, path, label_key, role = GuestRole::…, embeds = HostFragmentId::…)]` |
| `emails` | `#[email(id, audience, …)]` above the `#[command]` that sends it |
| `permissions` | the features enabled on `portaki-sdk` (`kv`, `repo`, `email`, `events`, `platform`, `guest-files`, `stay-guest-contact`) and each `#[connector]` id |
| `sdkVersion`, `requiresModuleSdk` | the `portaki-sdk` version cargo resolved |
| `dispatchExamples` | `example(label = "…", input = r#"{…}"#)` on a `#[query]` / `#[command]` |
Every closed list is a Rust enum in `portaki_sdk::vocab` (all in the prelude): the macros take
`Enum::Variant` and refuse a string, so a typo does not compile. i18n keys and query names stay
strings, and `portaki build` stops when one does not exist.
A module that still keeps a `portaki.module.json` is read as before: what it says wins, and the
code fills what it leaves out. Delete a field there and the code takes over.
## Monorepos
A repository whose modules live under `modules/*/` — crates on `portaki-sdk` (the layout of
`portaki-modules`) — is a monorepo. Inside `modules/<id>/`, every command acts on that module as
before. From the repository root, every command that acts on a module takes the same two flags:
`--module <id>` and `--all` — `build`, `check`, `test`, `connectors`, `add`, `release`, `dev`,
`run`, `scenarios`, `preview`, `reports`, `logs`, `link`, `upgrade`, `status`, `doctor` and every
`ci` subcommand. `dev` and `logs` hold one module at a time and refuse `--all` (exit 2). With
neither flag, a terminal asks which one; anything else — a CI, `--json` — gets a usage error
(exit 2) listing the ids. The former `portaki logs <module>` and `portaki ci … --root <dir>` are
still accepted, hidden.
`portaki upgrade` moves the whole monorepo when the SDK is inherited from the workspace
(`portaki-sdk = { workspace = true }`): the root `Cargo.toml`, `Cargo.lock` and
`requiresModuleSdk` in every `portaki.module.json` still kept, then builds and tests the workspace, and
assembles and lints each module in turn. Run it from the repository root, or from any module —
the render comparison, which needs one module's sandbox, only runs in the latter case.
`portaki release --all` publishes each module on its own — one push right, one digest — prints a
result per module, keeps going after a refusal, and exits non-zero if any module failed. Modules
refused with `module_not_linked` are gathered into one link:
```text
✖ 403 module_not_linked — « access-guide » n'est lié à aucun dépôt
Modules non liés dans ce dépôt : access-guide, nuki
→ Liez-les en une fois : https://developer.portaki.app/access-guide/repository?also=nuki
```
The CLI never creates the link itself: it needs a GitHub installation chosen in the dashboard.
`portaki link` opens that same page for the current module and the other modules of the
repository (`--no-browser` prints it). The page's address comes from the registry — the
`linkUrl` of the refusal, or `GET /registry/v1/modules/{id}/link-page` for `portaki link` — so
the CLI never guesses where the developer console lives.
## Output
Every command writes the same way: a line under the title saying what it actually does, one step
per line, a spinner while it runs, the elapsed time once it is done, and a `next` block naming
what to run afterwards and what each one gives you. The tools the CLI drives (`cargo build`,
`cargo test`) stay quiet unless they fail — then their whole output surfaces, because that is
what you were looking for.
The CLI speaks the system's language: French when `PORTAKI_LANG`, else `LC_ALL`, `LC_MESSAGES`
or `LANG` starts with `fr` — with the developer space's words — English otherwise
(`PORTAKI_LANG=en` forces it). Only what a person reads changes: `--json` is always English, and
error codes and exit codes are the same in both languages.
The explanatory lines earn their place: `dev` does not start a local gateway, `publish` does not
just push, and `init` leaves a tree whose halves (`ids.rs` and `i18n/`) only make sense together.
Saying so costs a line each.
| `--plain` | Bare output for scripts and CI — no logo, no headings, no glyphs, no advice. Implies `--no-color` |
| `--json` | One JSON document on stdout, nothing else; everything a person reads goes to stderr. Implies `--plain` |
| `--api <url>` | The Portaki API to talk to. Defaults to `PORTAKI_API_URL`, then production |
| `--env <name>` | A named platform: `prod`, `staging`, `local`, or one of `~/.config/portaki/config.toml` |
| `--no-color` | Same layout, without colour or spinners |
| `-v`, `--verbose` | Stream the raw output of the tools the CLI drives |
`--no-color` and `--plain` answer different questions. The first keeps the layout and only drops
what a terminal paints. The second drops the layer written for a person who is discovering the
command — the logo, the heading, the `next` block, the glyphs, the margins — and keeps what
another program would come to read: the steps, the fields, the results, the errors. Failures
there are prefixed `error:` rather than marked with a cross, so a log stays greppable.
Warnings go to stderr, so stdout keeps what a script reads. One exception, kept for the release
action v1 and the `portaki-modules` workflow that `grep` stdout for it: the « already in the
registry » line of `publish` — on stdout without `--json`, on stderr with it.
### `--json`
Under `--json`, stdout carries exactly one JSON document (one per line for `logs`, as NDJSON)
and nothing else — steps, warnings, advice and the output of the tools the CLI drives go to
stderr. Every document carries `schemaVersion: 1`; fields are only ever added within a version.
A command that renders no information writes nothing to stdout.
| `status` | `{ schemaVersion, api, signedIn, journey: { cliConnected, deployed, rendered, conformant, published }, modules: [{ id, repository, linked, sandbox: { watchConnected, lastDeploy, lastRun } \| null, err24, openReports, failedChecks, latest: { version, digest, channel, state: available\|draft, missing: [{ kind, lang, permission? }], signature: signed\|unsigned\|unverified, signatureSource: ci\|author\|null, review, publishedAt } \| null, next }], next: { command \| null, reason } }` |
| `doctor` | `{ schemaVersion, api, ok, checks: [{ id, module \| null, status: ok\|warn\|fail\|skip, summary, fix \| null }] }` — ids: `session`, `cli`, `cosign`, `signing`, then per module `toolchain`, `sdk`, `registry`, `link`, `overrides` |
| `connectors` | `{ schemaVersion, modules: [{ id, connectors: [{ id, kind: builtin\|custom, baseUrl?, operations?: [{ id, method, path }], permission, credentialProviderId?, auth? }] }] }` |
| `publish` | `{ schemaVersion, modules: [{ id, version, channel, state: published\|draft\|already-published\|pushed\|dry-run\|failed, digest, reference, missing, url, error }] }` |
| `logs` | one `{ ts, level, src, msg }` per line |
| `check` | `{ schemaVersion, controls: [fmt\|clippy\|tests\|build\|lint\|i18n], modules: [{ id, ok, error }] }` |
| `run` | `{ schemaVersion, module, operation, kind, run: <devapi dispatch response> }` |
| `scenarios`, `scenarios run` | `{ schemaVersion, module, cells: [{ surface, case, status: ok\|watch\|fail, code?, message? }] }` — `scenarios reset`: `{ schemaVersion, generation }` |
| `preview` | `{ schemaVersion, module, preview: { surfaceId, guest, type, types, rendered, tree, errorCode } }` — bare: `{ schemaVersion, module, surfaces }` |
| `reports` | `{ schemaVersion, modules: [{ id, reports: { items, page, size, total, counts } }] }` — `reports resolve`: `{ schemaVersion, report }` |
| `release status` | `{ schemaVersion, module, version, channel, digest, state, review, yanked, signature, signatureSource, missing, next: { command, reason } }` |
| `release notes` | `{ schemaVersion, module, version, digest, state, langs, added, notes, missing }` |
| `ci modules` | `{ schemaVersion, modules: [id], any, reason }` |
| `ci sdk-version` | `{ schemaVersion, version, rev, key }` |
| `ci info` | `{ schemaVersion, modules: [{ id, version }] }` |
| `ci check` | `{ schemaVersion, modules: [{ id, warnings: [{ file, message }] }] }` |
| `ci report` | `{ schemaVersion, modules: [{ id, outcome, reported, error }] }` |
### Exit codes
| 0 | Done |
| 1 | Failed — including a `doctor` with a failing check |
| 2 | Usage: a refused argument, an ambiguous or unknown module, an unknown `--env` |
| 3 | Nothing to do, or already done — `add permission` of a declared permission, `logout` without a session, `publish --json` when every version was already in the registry |
| 130 | Interrupted (ctrl-c) |
`publish` without `--json` keeps exiting 0 on « already in the registry »: the release action v1
reads that case from stdout and would take any other code for a failure.
Colour and animation turn themselves off when the output is not a terminal, and `NO_COLOR` is
honoured. `portaki catalog` and `portaki inspect` write nothing but their JSON to stdout, so
they stay pipeable into `jq`.
`portaki` with no arguments, and `portaki --help`, open on the logo and close on the licence and
the copyright — they travel with the binary, which often circulates without its repository.
`portaki --version` adds where the source lives and the Apache-2.0 "AS IS" disclaimer, while
`-V` stays a single parseable line for scripts.
After a command has done its work, `portaki` says so when a newer version is published:
```
▲ portaki 2.4.0 → 2.5.0 is available
cargo install portaki-cli --locked --force
PORTAKI_NO_UPDATE_CHECK=1 silences this
```
The answer is cached for a day, and the one command that refreshes it gives up after a second
and a half — the notice must never be the reason a build feels slow. It is printed after the
command, not before, so it delays nothing and does not push the line you came to read out of
sight. It stays quiet under `--plain`, when the output is not a terminal, and when
`PORTAKI_NO_UPDATE_CHECK` is set.
Only one `portaki dev` runs at a time — with or without `--watch`. A second one refuses before it compiles anything
and says who holds the session:
```
✖ a portaki dev session is already running on weather (pid 41999) — stop it first
```
A one-shot `portaki dev` overwrites the sandbox exactly as a looping session does — once
instead of endlessly, which makes it no less surprising for whoever's module just vanished. It
takes the place too, and hands it back as soon as it is done.
Two sessions push different modules into the same sandbox in turn, each undoing what the
other just did — and one is rarely started on purpose: it is forgotten in a tab, and another is
started elsewhere. The lock lives next to the credentials, so it covers you on this machine,
not a repository.
A session ended with ctrl-c never releases it — nothing runs then. The next launch takes it over
as soon as the recorded process is gone, so an interruption never wedges the command.
The same exclusion holds **account-wide**, from the dev platform: two laptops on one account
share one sandbox, and only the server sees both. It is a lease, not a lock — nothing can ask a
remote process whether it is still alive, so the session pushes the deadline back while it runs
and the account frees itself when it stops.
The lease is held by the service that owns the sandbox, so it is the deploy itself that gets
refused, not merely the request to hold the place. A client that skips the lease — or that could
not reach the server to take it — still cannot overwrite a session that holds it.
What a network failure does, since it will happen:
| Unreachable when the session starts | Warns and carries on. Refusing to work because a lock service is down costs more than the nuisance it prevents — the local lock still covers this machine, and the server refuses the deploy anyway if someone else holds the account |
| A renewal fails | Retries in silence. The lease outlives several missed renewals; only a long outage is reported |
| The lease was taken over | Stops. Carrying on would be exactly the mutual clobbering this exists to prevent |
`portaki run`, with no operation name, lists what the module exposes — queries and
commands, each with the Rust function behind it — read from the manifest, without building or
deploying. And when an argument is refused, the refusal is rendered like everything else: the
CLI's own commands follow when the question was *which command*, `clap`'s suggestion is kept,
and the pointer goes to the help page of the command you were actually in.
`portaki logout` ends the session **on the platform**, not only here: it hands the stored
refresh token back for revocation. Clearing the file alone left that token valid until it
expired, so anyone holding a copy stayed signed in. The local credentials go first and
unconditionally — a logout that leaves them behind because the network hiccuped is the worse
half of both, since you believe you are out and you are out nowhere.
`portaki login` opens the browser on the verification URL — pre-filled with the code when the
platform returns one, so there is nothing left to paste. The code is printed either way; use
`--no-browser` over SSH or on a headless box.
## From a CI workflow
`portaki ci` answers, from the CLI, what a workflow used to ask in `bash`, `jq` and `curl`.
Every subcommand prints for a human and writes `GITHUB_OUTPUT` when it exists, so the same
invocation serves both.
| `portaki ci modules [--changed-since <ref>] [--only a,b] [--module <id> \| --all]` | Which modules this run should build — one repo per module, or `modules/*` in a monorepo |
| `portaki ci sdk-version` | The Portaki SDK this checkout resolves to, and the CLI version to install with it |
| `portaki ci check [--offline]` | Warns about an outdated SDK, a deprecated capability, or a manifest the shell has moved past |
| `portaki ci info` | This module's id and version — one per line under `--plain` |
| `portaki ci report --outcome <…>` | Tell the platform how this module's CI run ended |
`ci modules` reads the layout from the crates, not from a flag: a crate on `portaki-sdk` at the
root means one module, crates under `modules/*/` mean several. A change to the shared workspace
(`Cargo.toml`, `Cargo.lock`, `.cargo/`, `rust-toolchain`) rebuilds everything; a change to a
workflow file rebuilds nothing.
`ci sdk-version` reads `Cargo.lock`, not `Cargo.toml`: a module may declare the SDK by semver, by
git branch or by path, and only the lock says what will actually compile. The key it prints is
the version alone, because the CLI installs from crates.io — `cargo install portaki-cli@<key>` —
so the cache turns over when the SDK does, not on every commit to its branch.
Deprecations come from the registry, which serves them per SDK version alongside the other
contracts. A module is warned only about what it actually declares — capabilities required,
optional or provided, and built-in connectors. A registry that cannot be reached, or an SDK
version it has never seen, is reported and never fails the run: neither is a defect of the
module.
```bash
portaki --plain ci modules --changed-since "$BASE" # ["access-guide","weather"]
portaki --plain ci sdk-version # 2.2.0
```
## Typical workflow
```bash
cd modules/weather
portaki check
PORTAKI_PUBLISH_VERSION=0.3.5 portaki release
```
The artifact lands in Portaki's OCI repository, `<oci host>/modules/<module-id>:<semver>` — the
host is the one the registry names when it grants the push, never configured here.
`release` runs the module's tests first — `cargo test` in the module crate, on the host — and
refuses to build or push anything when they fail, or when the conformance battery
(`tests/conformance.rs` with `portaki_test_utils::conformance!();`) is not among them. `--dry-run`
runs them too. Only `ci release` skips them: it pushes what `ci build` built and tested in a job
without rights, runs nothing of the module, and checks that the artifact names the module,
version and SDK of the sources and `Cargo.lock`.
`release` refuses a symbolic link, or a file resolving outside the module, in `i18n/`,
`db/migrations/` and the previews: what it reads there lands in a public OCI layer.
## Platforms and sessions
Every command talks to one API base, resolved once: `--api`, then `--env`, then
`PORTAKI_API_URL`, then production. `PORTAKI_DEV_URL` is a deprecated alias of
`PORTAKI_API_URL` (read only when the latter is unset, with a warning), and the per-command
`--url` flags are hidden aliases of `--api`. The sandbox, the registry and sign-in share that one
origin, so a stored session can no longer belong to one and be sent to the other.
Named platforms: `prod` (`https://api.portaki.app`), `staging` (`https://api-staging.portaki.app`)
and `local` (`http://localhost:8080`). Add or override one in `~/.config/portaki/config.toml`:
```toml
[env.preprod]
api = "https://api-preprod.example"
```
Sessions are stored **per origin**: `portaki login --env staging` leaves the production session
alone, `portaki logout --env staging` ends only that one, and a command towards an origin without
a session says which login opens one — `portaki login --env staging`. A session is only ever sent back to its own origin, over https
(plain http to localhost only). A credentials file from an earlier version is read as the session
of the origin it recorded.
`release` refuses a version the registry already holds, **before** pushing anything. Publications
are immutable, so a second push could only leave the OCI tag pointing at something the catalogue
does not reference. That covers a re-run; two jobs starting together both look before either
announces, so serialise them with a `concurrency:` group per module in the workflow.
The push right comes from the registry (`POST /registry/v1/publications/push-token`, with the
session or the CI's OIDC credential): 15 minutes, this module and version only, for the repository
`modules/<module-id>` of the OCI host the answer names. The pushed digest is then signed and
announced as `oci://<host>/modules/<module-id>@<digest>`. A failed announcement publishes nothing:
replay `release` (or the job), which asks for a new right, pushes and signs again.
### Signing
`release` signs by default, after the push and before the announcement — a failed signature stops
the publication: nothing is announced. `--no-sign` publishes unsigned, with a warning on **every**
channel: production requires a signature everywhere, so the version never runs there — it stays
usable in the sandbox.
From a workstation, it attests the pushed digest as its author, with your GitHub identity — cosign
keyless, public Fulcio and Rekor — and prints `signé par <address> (hors CI)`. It is a **Portaki
author attestation**, not provenance: `cosign attest --type
https://portaki.app/attestations/author/v1` with the predicate
`{"moduleId": "<id>", "version": "<version>"}`. It says who published which module and version,
nothing about how it was built.
From CI, `portaki ci release` attests with the job's OIDC identity: SLSA v1 provenance, and the
`cargo audit` report given with `--audit` (type `https://portaki.app/attestations/cargo-audit/v1`).
The registry checks the certificate — workflow, repository, commit — against the module's link.
- It needs [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) v3.1.3 or a later
3.x in the `PATH` (or `PORTAKI_COSIGN`): `brew install cosign`, or
`go install github.com/sigstore/cosign/v3/cmd/cosign@v3.1.3`. It is checked before the build.
- From a workstation, the browser opens on Sigstore's sign-in, GitHub only. The CLI does that flow
itself (cosign's own lets you pick Google or Microsoft), then hands the token to `cosign attest`
through `SIGSTORE_ID_TOKEN`: never on the command line, never printed. The key is ephemeral.
- cosign gets the push right through a temporary `DOCKER_CONFIG`, and the predicate through a
temporary file (both mode 0600), deleted afterwards.
- In CI (`CI` or `GITHUB_ACTIONS` set) `portaki release` refuses to sign as an author: use
`ci build` and `ci release`.
### What is new
`release` writes `changelog` into the published manifest: two or three lines a host with an older
version sees before updating, one entry per line with every language side by side. For each
language, `--notes` wins (`--notes fr:"Code clavier la veille"`, repeatable, at most 5 lines of 160
characters per language; untagged lines are in `--notes-lang`); otherwise the lines come from this
version's section of `CHANGELOG.<lang>.md`, and of `CHANGELOG.md` **in English** (`## [x.y.z]` or
`## x.y.z`, then bullets — the release-please format, scope and commit link dropped), the first
five kept. A French line therefore lives in `CHANGELOG.fr.md`.
`--notes-lang` — the language of untagged `--notes` — defaults to the **first language of the
listing** (`publishedLangs[0]` in `listing.json`), else `fr`, the registry's fallback language. It
does not change the language of `CHANGELOG.md`.
A line that reads like a commit message — starting with `bump`, `chore`, `fix(`, `fix:`, `feat(`,
`feat:`, `refactor` or `deps`, whatever the case — is refused in `--notes`, before anything is built
or pushed. In a `CHANGELOG*.md` section, often generated by release-please, it is dropped with a
warning that lists it; a language left without a line counts as absent, and the registry keeps a
stable version as a draft, as before. A file never blocks the push.
Beside the manifest, the announcement carries what only the author can say: why a permission is
added (`--permission-reason email=fr:"Pour envoyer le code"`, one per permission and language) and
whether the host has to act after updating (`--host-action-required`, `--host-action fr:"…"`).
A **stable** version stays pending at the registry — kept, but invisible to hosts — until its
changelog has a line in every language of the module's listing and each permission added since the
previous stable is justified in each. `release` says it plainly — « brouillon — invisible des
hôtes » — with the list of what is missing (for a changelog, the file or flag to add:
`CHANGELOG.fr.md` or `--notes fr:…`) and the console link where the version is completed
and published; a CI that passes everything publishes straight away. Preview channels never wait.
A draft is not a failure by default, so existing workflows stay green. `--require-available`
makes it one on `--channel stable`: the command exits non-zero once the version is in the registry
as a draft (the listing is still sent).
### Public listing
A module can version its public catalogue listing in `listing.json`, at the crate root
(`category`, `tagline`, `description`, `guestSurface`, `hostSurface`,
`configItems`, `capabilities`, `publishedLangs`). Its schema is
[`schema/listing.v1.json`](https://raw.githubusercontent.com/PortakiApp/portaki-sdk/main/schema/listing.v1.json)
— point `"$schema"` at it and an editor completes and checks the file, each field describing what
to write. The `listing` check of the conformance battery validates the file against it, so
`release`, which runs the battery, refuses an off-schema listing — or one still holding the
`portaki init` instructions — before pushing. `release` reads it before anything else — invalid JSON stops the
run before a push — and sends it as is once the version is in the registry: after the
announcement, and also when the version was already there, so a fixed listing does not wait for
the next release. An already-published version is not a failure: nothing is pushed, a warning says so,
the listing goes out and the command succeeds — re-running the publish workflow is enough to push
a fixed listing. `--dry-run` sends nothing. In CI it takes a fresh
OIDC exchange, the announcement having used its single-use credential.
The file **overwrites the listing edited in the dashboard** — the repository is the source of
truth. A refused listing does not undo the publication, but fails the run with the registry's
reasons. No `listing.json`, nothing changes.
### Publishing from CI
No publication secret to store, and no registry credential: in a GitHub Actions job with
`id-token: write`, the CLI exchanges the job's OIDC token at the registry for single-use
credentials — one for the push right, one for the announcement, one for the listing.
Two jobs, so that the module's code never runs where the rights are:
```yaml
jobs:
build: # runs the module's code — no rights
permissions: { contents: read }
steps:
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown, components: "rustfmt, clippy" }
- run: portaki ci build # the gate of portaki check, then packaging
- uses: actions/upload-artifact@v7 # target/portaki/ + the wasm
release: # holds the rights — runs nothing of the module, not even cargo
needs: build
environment: release # the GitHub environment the link requires for the stable channel
permissions: { contents: read, id-token: write }
steps:
- uses: actions/download-artifact@v8
- run: portaki ci release --audit "$RUNNER_TEMP/cargo-audit.json"
```
`ci release` writes `outcome` (`published`, `draft`, `already-published`), `digest`, `reference`,
`id` and `version` to `GITHUB_OUTPUT`. [`portaki-release-action@v2`](https://github.com/PortakiApp/portaki-release-action)
does both jobs, the audit included (produced in the release job, from `Cargo.lock`).
Create the `release` environment in the repository (Settings → Environments, with its required
reviewers) and name it in the link: the stable channel is refused from any other job —
`environment_required`. `portaki doctor` warns when the link names another one.
Link the module to its repository from the dashboard first: the registry authorises on the
repository *id* recorded there, and checks the workflow file, the triggering event and the
runner. The token says where it comes from; the link says what it may publish.
`PORTAKI_DEV_TOKEN` still wins when it is set — an explicit choice beats a mechanism that turns
itself on.
Official modules: [`portaki-modules`](https://github.com/PortakiApp/portaki-modules).
## Related crates
| [`portaki-sdk`](https://crates.io/crates/portaki-sdk) | Host APIs + SDUI |
| [`portaki-sdk-macros`](https://crates.io/crates/portaki-sdk-macros) | Manifest emissions |
| [`portaki-connectors`](https://crates.io/crates/portaki-connectors) | Typed connector ops |
| [`portaki-test-utils`](https://crates.io/crates/portaki-test-utils) | Mock host for tests |
## License
[Apache-2.0](https://github.com/PortakiApp/portaki-sdk/blob/main/LICENSE) · Copyright 2026 Syntax Labs