Concepts
Pinning content to a release
Pin a repository to a release with myna.lock so builds are reproducible, and take new content on as a pull request that CI has already checked.
Every input to a build is version-controlled except one. Source is a commit. Dependencies are a lockfile. Content is "whatever the API said when the runner started" — so the same commit builds two different sites on two different days, and content that breaks the build surfaces as a failed deploy rather than a failed check.
myna.lock closes that gap. It names a release, the client reads at that release, and the build becomes reproducible: the same commit plus the same lock produces the same bytes. Moving to newer content is then an edit to a tracked file — which is to say a pull request.
The lockfile
{
"project": "my-site",
"release": 42,
"publishedAt": "2026-03-01T09:12:44.301Z",
"title": "Spring pricing update"
}
Commit it. apiUrl appears only when it is not https://api.myna.sh. Key order is fixed and the file ends in a newline, so a bump is a two-line diff rather than a reshuffle.
One per repository. Reads walk up from the build and stop at the first lockfile they find, so a second one nearer the build silently wins and the site is pinned to a release nobody chose. myna pull moves the lockfile that already exists — wherever in the repository it is, and whichever directory you run it from — and refuses to run at all if it finds more than one.
Create or move it with myna pull:
$ myna pull # pin to the newest release
$ myna pull --release 42 # pin to a specific one
$ myna pull --check # is the pin behind? non-zero exit if so
--check is the CI form. It prints the releases the repository has not taken on yet and exits non-zero, so a scheduled job can tell you the site is a week behind without changing anything.
Reading at the pin
The public client takes a release, and @myna-sh/sdk/lock reads the file:
import { createMyna } from "@myna-sh/sdk";
import { readLock } from "@myna-sh/sdk/lock";
const lock = readLock(); // undefined when there is no lockfile
const myna = createMyna({
project: "my-site",
...(lock ? { release: lock.release } : {}),
});
const posts = await myna.entries.list("posts"); // at release 42
const latest = await myna.entries.list("posts", { release: null }); // live
@myna-sh/sdk/lock is a separate, Node-only entry point, so importing it cannot pull node:fs into a browser bundle. A per-call release overrides the client default, and null opts one read back out to live — the escape hatch for the handful of things that should not be pinned, like a "latest release" banner.
Over plain HTTP this is ?at=. No lockfile means no release, which means live reads: pinning is opt-in, and a repository that has not adopted it behaves exactly as before.
Content as a pull request
Install the Myna GitHub App, connect a repository to the project under Settings → GitHub, and publishing opens the pull request. There is nothing to add to your CI: no token to mint, no workflow to write, no secret to rotate.
The pull request bumps myna.lock and its body is the field-level diff of every release being taken on — entry by entry, path by path, with each change summary. Your repository's own checks run against the pinned content. Merging is what deploys.
The App asks for Contents and Pull requests, both read and write, on the repositories you select. Myna stores only the installation id; the token that writes the pull request is minted per operation and expires within the hour, so there is no long-lived credential to leak. Uninstalling the App is what revokes access.
If GitHub suspends the installation, or you switch the connection off, publishing simply stops proposing — the mapping is kept, so restoring it does not mean picking your repository again.
From CI instead
myna pull --open-pr does the same thing from a workflow, and is the right choice in three cases: your repository is not on GitHub, you would rather not grant a third party write access to your source, or your build generates something from content and the generated file has to travel with the pin. Myna cannot run your build, so the App can only move the lockfile.
Pick one of the two per project. Both proposing the same release means one of them opens the pull request and the other finds the branch taken and stops — which is safe, but whichever won may have written only half of what the repository needed.
$ myna pull --open-pr
It commits the bumped lockfile onto its own branch and opens the same pull request, with the same body, using git and the GitHub CLI.
One caveat if you go this route: a pull request opened with a workflow's built-in GITHUB_TOKEN does not trigger other workflows, so your checks will not run on it. Use a personal access token or a GitHub App installation token for the checkout and for gh. The Myna App does not have this problem — its pull requests trigger checks like anyone else's.
If the repository generates anything from content — a snapshot, a prerendered page, a search index — --run regenerates it at the new pin and commits it in the same pull request:
$ myna pull --open-pr --run 'pnpm build:content'
The lockfile moves before the command runs, so the command reads the new release; whatever it changes is committed alongside the pin that produced it, rather than in a second commit that can disagree with it. With --run the whole working tree must be clean, because the command may write anywhere and a check narrower than the commit is not a check.
Wire it to publishing with a webhook on change_set.published pointed at a GitHub repository_dispatch, and a workflow that runs myna pull --open-pr.
This is how myna.sh/changelog works: it commits a snapshot derived from the content, so it uses --run rather than the App.
--open-pr refuses to run if anything else in the working tree is uncommitted, and it puts the tree back the way it found it if nothing is opened.
One release, one branch
Both paths name the branch after the project and the target release, using the same function. That is what makes a redelivered webhook, a retried job, a nightly safety net, and the App and CLI both watching one project converge instead of colliding: whoever gets there second finds the branch and opens nothing, rather than stacking duplicates or force-pushing over a review already in progress.
Rolling back is the same command pointed backwards:
$ myna pull --release 41 --open-pr
Nothing is unpublished and no content is touched — this changes only which release the repository reads, which makes "get the build green again" a revert of a lockfile bump rather than a content emergency. Undoing the content itself is a release revert.
What a pin does and does not fix
A pinned read reconstructs what a release served, from the release history. Two things stay live on purpose:
- Visibility. A collection made private since is private now. Pinning reproduces content, not access decisions.
- Editorial ordering.
entries.rankis arrangement rather than content and carries no revision, so a pinned list uses the current arrangement.
Because the reconstruction is built from releases, it sees exactly what releases published — content that reached the published state without one is not part of any release and is not visible to a pinned read.
Pinned responses are Cache-Control: public, max-age=31536000, immutable, so re-reading the same pinned content on every CI run is served from cache.
