Release path¶
Internals, for contributors and reviewers.
Dated 2026-09-07. Requirement 10 in AGENTS.md, made operational.
Lockstep locks (seven)¶
VERSION, Cargo.toml workspace version, chart/Chart.yaml version
and appVersion, chart/values.yaml image.tag (vX.Y.Z),
root manifest.json version, and the CHANGELOG.md heading X.Y.Z.
scripts/ci/release_contract.py walks every commit in base..head and
denies skips, reversions, and mixed ranges without exactly one release step.
A STEP is one patch (X.Y.Z+1), one minor (X.Y+1.0), or one major
(X+1.0.0), and nothing else: a step zeroes every field below the one it
advances, so X.Y+1.1 and X+1.0.1 deny beside X.Y.Z+2. One step at a
time, never a skip, is the whole rule -- which is what makes 1.0.0 reachable
from 0.Y.Z without a gate edit in the pull request that needs the gate.
Five followers move with the locks and are held by gates, not by the
classifier: plugin/package.json version and its two copies in
package-lock.json (the plugin's bundle test compares the built manifest to
both sources); Cargo.lock, refreshed by cargo check (--locked in the
image and gate refuses a stale one); root versions.json; and
chart/README.md, whose cosign verify and helm install --version lines
name the release a reader installs. helm package bundles that file INSIDE the
published chart, so a stale literal ships with the chart that contradicts it;
scripts/ci/test_chart_pins.py refuses any version in it but VERSION's.
versions.json is the ledger Obsidian's community-plugin installer reads to
decide WHICH release a given Obsidian version may install: the newest plugin
version whose recorded minAppVersion that app satisfies. It is not an eighth
lock, because it does not carry one version — it accumulates one row per
published version and its older rows must not move when the head advances.
scripts/ci/versions_json.py decides and scripts/ci/test_versions_json.py
runs it over the committed tree in the security job and in make check: the
head version must be the last row and must carry exactly root
manifest.json's minAppVersion, every key and value must be a bare X.Y.Z,
the rows must ascend, and no row may name a version above the head. A gap is
admissible and 0.1.15 is one: it was built but never published, so a row for
it would promise the installer a download that does not exist. The floors this
plugin has published are 1.7.0 (0.1.11-0.1.12), 1.7.2 (0.1.13-0.1.14),
1.12.4 (0.1.16-1.0.1) and 1.13.0 (1.0.2 onwards), each read from that
release's own manifest.json.
Classifier¶
Two verdicts, no flag: artifact (any path outside the documentation
allowlist changed, and every lock advanced exactly one release step) or
no-artifact (every commit confined to root AGENTS.md, README.md,
.gitignore, and Markdown under docs/; no lock touched).
Genesis. A range whose base carries NONE of the seven locks — the state a
repository born from GitHub's own root commit is in, which the release-step
rule cannot classify because there is no version to advance — is artifact
only if the head carries all seven locks agreeing on one version, every commit
that introduces a lock introduces it at that same version, and no commit
removes one; anything else from a lock-less base denies by name. A base
carrying any lock takes the ordinary rules unchanged, so genesis governs
exactly one range and is unreachable once main has a VERSION.
Publisher¶
release-after-main.yml (holds actions: write, contents: read; cannot
create refs) dispatches release-publisher.yml with the successful run id.
The publisher's read-only authorization job verifies the run, repository,
workflow path, push event, main branch, source SHA, and PR-gate job
inventory; then its write/packages/OIDC job builds the multi-arch image
(linux/amd64, linux/arm64) with checksum-pinned tools, signs image and OCI
chart keyless (identity refs/heads/main of this repository), attaches
obsync-plugin-X.Y.Z.zip and the individual main.js, manifest.json, and
styles.css files from the same image build. The v2 evidence manifest binds
the ZIP digest and each file's digest, size and content type.
Obsidian's installer downloads only the three individual files and ignores
the ZIP and the evidence manifest; both remain required by the inventory
below and by the read-only audit.
The static server, from 1.1.4. The same job exports the Dockerfile's
server-dist stage once per production platform -- the binary the image
runs, the dashboard and plugin files it serves, deploy/systemd/obsyncd.service
and the licence, copied from the same server and bundle stages the image
copies -- and release_contract.py server-archive packs each into
obsync-server-X.Y.Z-linux-amd64.tar.gz and …-linux-arm64.tar.gz: sorted
entries, one fixed time, owner root, no extended headers, gzip without a name
or time, so a re-run reproduces the bytes it uploaded. The evidence manifest
records each archive's name, digest and size under server_archives, and the
contract reads an archive as untrusted input before recording it: bounded
before decompression and, as one gzip member with nothing after it, bounded
again when inflated, before tar parses any of it, headers included; every entry
one plain file or directory under a POSIX USTAR header, with no extended
header, back to back from the first byte, the archive ending as the publisher
ends it (two zero blocks, then zeros to a whole 10,240-byte record), under its
one top directory, owned by root and writable by no one else, carrying an
executable for its own platform (the ELF machine) and plugin files identical
to the released plugin's. Both archives join the build-provenance attestation
below and the Release, as application/gzip, making seven assets. Releases
before 1.1.4 keep their five-asset inventory, evidence and notes byte for
byte; the boundary is the version, never the presence of a file.
The Release body. From 1.0.1 the notes lead with that version's own
CHANGELOG.md section, read out of the SOURCE COMMIT rather than out of a
working tree, then the one line that installs or updates the plugin and the one
that upgrades the server by digest, and fold the artifact table, the signing
identity and the evidence digest under Supply-chain evidence. Releases
through 1.0.0 keep the body they published, byte for byte: the read-only audit
re-derives the notes from the sealed manifest and compares them, so a format
change that reached backwards would fail against a release nobody can edit.
scripts/ci/test_community_release.py pins both shapes and the boundary
between them. The publisher
requires the exact asset inventory for the version (five assets, seven from
1.1.4) and reads every uploaded byte back before immutable publication. It scans source and final image for
high/critical findings, and publishes one immutable Release.
From 0.1.15, the publisher also creates GitHub Actions SLSA v1 build
provenance for main.js, manifest.json and styles.css, and from 1.1.4 for
both server archives. A reader verifies an archive before unpacking it with
the same identity the verifier below uses:
gh attestation verify obsync-server-X.Y.Z-linux-amd64.tar.gz --repo snaraj/obsync \
--cert-identity https://github.com/snaraj/obsync/.github/workflows/release-publisher.yml@refs/heads/main \
--cert-oidc-issuer https://token.actions.githubusercontent.com
main; if main advances before that
dispatch binds its workflow commit, publication of the superseded source is
refused before the first tag or artifact write. Automatic publication requires
the dispatch context to match the validated source.
The exported bytes are verified against the attestation bundle before release
publication; the read-only audit later verifies downloaded bytes through the
attestation API. This adds no Release asset and preserves earlier evidence.
The GitHub tag is exactly the root manifest version, X.Y.Z, as required by
Obsidian's native installer. Container image tags remain vX.Y.Z; chart tags
remain X.Y.Z. These names are distinct inputs, and the scheduled audit
rebinds each alias to the digest in the sealed evidence.
Releases through v0.1.10 are immutable history. The read-only audit retains
their exact v1 schema, notes, two-asset inventory and prefixed tags. The
historical Git reader accepts plugin/manifest.json only for those versions
and rejects duplicate manifests. Publishing with the migrated workflow
requires the root manifest and v2 evidence. A missing new asset never selects
legacy behavior. The audit also checks the source commit's complete release
locks, so a manifest cannot choose an older publication format for new code.
The plugin ID is version-bound independently of the GitHub tag format:
releases through 0.1.11 retain obsync; releases from 0.1.12 require
obsync-private-sync in both the source manifest and bounded plugin archive.
Downloaded metadata cannot select a different identity or an older format.
The native provenance verifier uses an exact certificate identity containing
the repository, workflow path and main ref. GitHub CLI makes that selector
mutually exclusive with --signer-workflow; combining them refuses the
command before any cryptographic verification. The separate repository,
source ref/digest, signer digest, issuer, hosted-runner and SLSA-v1 checks
remain required. The local argument regression invokes real gh against a
malformed local bundle; live signed-attestation verification is separate.
Native installation, for the person at the device, is Install the plugin; the directory listing and its submission are below. Publication alone does not prove directory acceptance, installation, or device synchronization.
The community directory listing¶
The native installer relies on Obsidian's directory and GitHub release distribution. It does not document verification of this project's Cosign evidence before executing plugin code. The publisher signs the server image and chart and binds plugin bytes in immutable release evidence; that is producer-side evidence, not a separate signature verifier in the Obsidian client. Treat installing a community plugin as trusting its code with the vault. The native build provenance above is verified by the publisher and the read-only audit against the exact protected-main source; directory acceptance is still a separate observed result, not implied by producing an attestation. A new install and a subsequent native update both require real-device validation; an archive test alone proves neither.
Listing review findings (2026-09-22)¶
Obsidian's review scan of the 1.0.6 listing reported one network call, vault enumeration, clipboard access, one stylesheet warning and two extra Release assets. Where each one stands:
- One network call. Obsidian's
requestUrl, injected once into the transport (plugin/src/main.ts), reaching the configured Server URL and nothing else. Disclosed in the README under "What this plugin accesses". - Vault enumeration.
vault.getFiles()decides which files are in scope for sync. Disclosed there. - Clipboard. Two
navigator.clipboard.writeTextcalls, behind the Copy code and Copy link buttons of Pair a new device (plugin/src/ui/modals.ts). Nothing reads the clipboard. Disclosed there. multicolumnatstyles.css:20.column-gapon the recovery-phrase grid is also a multi-column property, which is what the scanner keys on. It is now thegapshorthand, which lays out the same two columns of twelve.- Extra Release assets.
obsync-X.Y.Z-release-manifest.jsonandobsync-plugin-X.Y.Z.zipare not plugin files, and Obsidian does not download them. They stay: the publisher's five-asset inventory requires them, the read-only release audit downloads both to re-verify the image, chart and bundle digests, and a deployer reads the image digest out of the manifest before running it ("Publisher" above). The scanner's line is informational, not a refusal. manifest.jsonagainst the submission requirements: the description is one action statement of 91 characters ending with a period;minAppVersionis 1.13.0 because the settings tab is declared to Obsidian from 1.0.2 (CHANGELOG.md);isDesktopOnlyisfalsebecause the bundle importsobsidianand nothing from Node or Electron;fundingUrlis absent because no donations are taken;authorUrlandhelpUrlare set. Nothing to change.
Maintainer submission¶
Obsidian's current requirements were checked on 2026-09-11 against Submit your plugin and Set up and claim.
The default branch must contain README.md, LICENSE and the canonical root
manifest.json. The matching published GitHub release must use its exact
unprefixed version as the tag and carry the three individual plugin files.
The publisher supplies them from the same build as the server's bundle and
verifies their hashes before sealing the release.
The default branch also carries root versions.json, the ledger the installer
reads to offer an older Obsidian the newest release it can actually run. This
plugin's floor has moved three times (1.7.0 at 0.1.11, 1.7.2 at 0.1.13,
1.12.4 at 0.1.16, 1.13.0 at 1.0.2), so without the ledger an Obsidian below
1.13.0 is offered nothing at all rather than 1.0.1. It is held as a release follower rather than a lock;
"Lockstep locks" above states the rule and the gate that enforces it.
After the owner merges and the release is verified, sign in to
community.obsidian.md with the maintainer's Obsidian account, connect the
GitHub account, and submit https://github.com/snaraj/obsync under Plugins →
New plugin. Review the developer terms and continuing-support commitment as
the maintainer. Resolve the directory's review feedback and publish the
listing before claiming that the app is installable from Browse.
The directory reads the default branch and requires a published release. Neither a Draft PR nor a local bundle satisfies that prerequisite. Owner merge, directory acceptance and real-device acceptance are distinct results. This procedure does not waive the repository's runtime live-validation gate.
Governance receipt¶
Before the first Release under this path the repository owner activates:
immutable releases, strict required checks at the exact head, no core bypass
actor, signed commits on main. release_contract.py settings-preflight
re-reads them with GET requests only and prints the receipt settings-receipt
validates, so the settings are re-read rather than remembered.
Rulesets layer, so the preflight reads main's protection from its CORE
rulesets: active branch rulesets that name main exactly (refs/heads/main,
~DEFAULT_BRANCH or ~ALL), exclude nothing that could be main, and have no
bypass actor. A bypassable ruleset such as an owner-only update restriction,
the release-tag ruleset, and a ruleset that reaches main only through a glob
are tolerated and never counted. It refuses a default branch other than main,
no core ruleset, a pull-request or required-checks rule set by two core
rulesets, a condition it does not model, a bypass list it cannot read, and a
ruleset that changes while it reads. scripts/ci/test_release_contract.py
pins each case.
Deployment¶
Publication is never deployment. A deployer's own platform selects the
digest, reconciles it, and reports drift if the promotion never lands. See
docs/platform-onboarding.md.