Skip to content

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
The dispatch workflow SHA must equal the authorized source SHA before any publication write, because GitHub's provenance derives that identity from the workflow. The orchestrator dispatches against 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.writeText calls, behind the Copy code and Copy link buttons of Pair a new device (plugin/src/ui/modals.ts). Nothing reads the clipboard. Disclosed there.
  • multicolumn at styles.css:20. column-gap on the recovery-phrase grid is also a multi-column property, which is what the scanner keys on. It is now the gap shorthand, which lays out the same two columns of twelve.
  • Extra Release assets. obsync-X.Y.Z-release-manifest.json and obsync-plugin-X.Y.Z.zip are 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.json against the submission requirements: the description is one action statement of 91 characters ending with a period; minAppVersion is 1.13.0 because the settings tab is declared to Obsidian from 1.0.2 (CHANGELOG.md); isDesktopOnly is false because the bundle imports obsidian and nothing from Node or Electron; fundingUrl is absent because no donations are taken; authorUrl and helpUrl are 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.