Releases contract
Status: accepted, version 0.3.0 (0.3.0 adds optional category metadata;
0.2.0 adds exec_wrapper). Verified by
services/releasesd/tests/releases_test.mix (offline logic) and
services/releasesd/tests/releasesd_test.mix (every verb over a private
broker).
releasesd follows GitHub repositories named in a per-user catalogue and
installs their Linux release tarballs for that user. This page fixes the
catalogue schema, the Bus verbs and the on-disk layout that other tools may
rely on.
Bus identity
| Item | Value |
|---|---|
| Service | releases (mix --serve releasesd.mix --name releases) |
| Verbs | releases.status, .list, .check, .notes, .install, .update, .rollback, .remove, .link |
| Bodies | JSON objects; {} or an empty body where no field is required |
| Success | rc 0, JSON reply |
| Refusals | rc 10 bad request, rc 14 unknown app, rc 15 the operation failed; body {error_code, message} |
Error codes: USAGE, ROOT, UNKNOWN_APP, CATALOGUE, NO_RELEASE, NO_ASSET,
AMBIGUOUS_ASSET, DOWNLOAD, DIGEST, UNVERIFIABLE, LAYOUT,
NO_PREVIOUS, PENDING, BUSY.
| Verb | Body | Reply |
|---|---|---|
releases.status | — | {catalogue, apps, installed, root, share, bin, state, authenticated, arch} |
releases.list | {apps?} | rows from the last check |
releases.check | {apps?} | rows after asking GitHub |
releases.notes | {app} | {app, tag, published, url, notes} |
releases.install | {app, tag?} | result |
releases.update | {apps?} | results; default every installed app |
releases.rollback | {app} | result |
releases.remove | {app} | result |
releases.link | {apps?} | results |
A row is {app, repo, category, category_label, installed, latest, published, checked, status}, with
category the catalogue's optional group and category_label its display label
(null when absent), and
status one of current, update, not installed, unchecked or
error: <text>. A result is {app, action, version?, previous?, verified?, links?, skipped?, error?}; verified is {asset, sha256, proofs, tag}.
Catalogue, schema 1
The optional top-level categories map supplies display labels for supported
groups, for example {creative: "Graphics"}. Labels must be non-empty strings
of at most 64 characters without control characters. Labels are projected onto
rows so Prefs can use catalogue terminology without knowing external products.
A strict-data document, by default $XDG_CONFIG_HOME/mixos/releases.mx
(~/.config/mixos/releases.mx). A missing file is the default catalogue,
MixOS itself: mixos-desktop and mixos-server, both from markc/mixos. It
is re-read on every request.
{
schema: 1,
apps: [
{name: "example", repo: "owner/example"},
{name: "other", repo: "owner/other", asset: "(?i)linux-x86_64-full\\.tar\\.gz$"}
]
}
| Field | Rule |
|---|---|
name | ^[a-z0-9][a-z0-9-]{0,63}$, unique; it becomes a directory name |
repo | owner/name on GitHub |
asset | optional regex choosing the tarball when the default is wrong or ambiguous |
category | optional mixos-desktop, mixos-server, creative or other, used by Prefs to group rows; omitted entries remain in Other, with legacy MixOS bundle identifiers recognised |
exec_wrapper | optional list of 1–32 non-empty strings without control characters, the first an absolute path without =; see below |
exec_wrapper. When link writes the app's desktop entries, every
Exec= becomes Exec=<wrapper…> -- <the entry's own Exec>. The entry's own
Exec keeps its field codes (%F, %U …) and is made absolute as usual.
Each wrapper argument is written per the Desktop Entry Specification: % is
doubled; an argument with a reserved character is double-quoted, with
" ` $ \ escaped inside the quotes; then every backslash is
escaped once more as a string value. The literal -- separates the wrapper
from the app's command, so a wrapper can always fall back to running that
command as it stands. Every Exec is wrapped, Desktop Actions included,
with whitespace before the key and around = allowed. The main group and
every Desktop Action must have a non-empty Exec, or the entry is refused
(LAYOUT). An install, update or rollback checks this before the version
becomes current, so a refusal changes nothing. If a wrapper is added while
an install or rollback is interrupted and the target cannot be wrapped, the
retry abandons that change: current returns to the other recorded version
(if that one passes), previous is cleared, and meta.json records
abandoned: {op, target, reason}. A rollback that does this reports it as
its result. A wrapped entry gets DBusActivatable=false, since D-Bus
activation would start the app without its Exec. TryExec stays the
app's real program. The entry is
rebuilt from the app's own copy on every link, so it is never wrapped twice.
The link manifest records the final content. Removing the field unwraps the
entry on the next link.
The default asset is the one release file matching linux[-_.]<arch>.tar.gz
(x86_64, amd64 or x64; aarch64 or arm64), excluding web builds.
Verification
An install proceeds only if every checksum source the release publishes
matches, and at least one exists: the asset's API digest, and a
SHA256SUMS/checksums file (itself matched to its own API digest) that
lists the tarball exactly once. Anything else refuses with DIGEST or
UNVERIFIABLE, and nothing changes on disk.
Trust rules
- Never as root. Every operation that changes files refuses with
ROOTwhen run as uid 0, before taking any lock or creating any file. Installs are per user, by that user. - The release is checked before use. It is unpacked into a stage without
owners, special permissions, ACLs, extended attributes or file flags, then
refused (
LAYOUT) if any symlink does not lead, step by step and through no other symlink, to an existing file or directory inside the release; if it holds a special file; or if any file is setuid or setgid. Desktop entries are read only when they are regular files inside the release. - Downloads are bounded. The tarball may not exceed its published size (2 GiB when none is given), a checksum file 1 MiB, and each file must arrive within 30 minutes.
- A manifest is evidence, not permission. Each entry is
{path, states}, every state a link target or a sha256. A path is replaced or removed only if it is inside this target and still in one of its states; anything else is left and reported (skipped,kept). Before changing anything, a relink publishes an intent manifest listing each path's old and new state, so an interrupted relink is recognised and finished. - Only managed versions are touched. A version directory is managed when
it carries
.verified-<version>.json. Pruning and removal touch nothing else; an app directory holding anything unmanaged is kept.currentand names ending in.jsonare never versions. - A retry finishes the job. Before
currentmoves,meta.jsonrecords the new version and previous one pluspending: {op, target}, cleared once the links are done. A later install, rollback or link first resumes a pending transition exactly: a resumed rollback is the rollback asked for; a resumed install is finished before a new rollback starts from it. Several link targets may share one opt directory (a session seeing it read-only):pendingrecords its originating target by a random id kept in the target's own<share>/mixos/releases/.target-id(never by path: two mount namespaces can show different directories at one path). Only that target resumes and clears it, another target'slinkonly reconciles its own links, and its install, rollback or remove refuses withPENDINGuntil the origin has finished. Installing the version that is already current reconciles its links. - Serialised, in one order. Locks are always taken installation first
(
<root>/opt/.releases.lock, shared by every target using that opt directory, whatever its state directory), then target (<share>/mixos/releases/.lock). Install, update, rollback and remove hold both. A link holds its target and, when it can open it, the installation; through a read-only opt it cannot, and it then only reconciles its own links and never resumes a pending transition. The service also handles requests in order: while an install downloads, other requests wait, so callers allow minutes forinstallandupdate.
Layout
Root defaults to ~/.local.
| Path | What |
|---|---|
<root>/opt/<app>/<version>/ | the release prefix: bin/, share/ |
<root>/opt/<app>/current | symlink to the active version |
<root>/opt/<app>/meta.json | {name, repo, version, previous, verified, installed_at, pending?, abandoned?} |
<root>/opt/<app>/.verified-<version>.json | the proof of each kept version |
<share>/mixos/releases/<app>.links.json | what was made in this target: [{path, states: [{link} or {sha256}]}] |
$XDG_STATE_HOME/mixos/releases/<app>.json | the last check: ETag, latest release |
The current and previous versions are kept; older ones are pruned. Links:
bin/* into <root>/bin; desktop entries into <share>/applications with
Exec/TryExec made absolute through current; hicolor icons and MIME
packages as symlinks. A path that is not in the target's manifest is never
replaced or removed.
Environment
MIXOS_RELEASES_ROOT, MIXOS_RELEASES_VIEW (the root as the apps see it,
for installs into a container), MIXOS_RELEASES_SHARE, MIXOS_RELEASES_BIN,
MIXOS_RELEASES_CATALOGUE, MIXOS_RELEASES_STATE, MIXOS_RELEASES_API
(default https://api.github.com) and GITHUB_TOKEN (else gh auth token).
The token is sent only to https://api.github.com.