# 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](/concepts/releases), 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 ```json { "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`](/cli/overview): ```bash $ 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: ```ts 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=`](/content-api/reading-content). 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. ```bash $ 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](https://cli.github.com). 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: ```bash $ 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](/webhooks/overview) 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: ```bash $ 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](/concepts/releases). ## 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.rank` is 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.