Branches and Releases

Work collects on dev, and a release pull request moves it to main, which builds and deploys. Pushes and merges to any other branch run nothing.

Where work lands

A merge to main after a version bump publishes the lnk release (release.yml, on macOS runners, which cost the most). So main only moves when a release is ready. No workflow publishes the relay: an operator builds it (Relay).

dev

Where work lands. Branch from dev, with one branch and one PR per batch of related work, each change its own commit, and the PR against dev. Don't bump the version in these PRs. Say in the description how to try the branch.

A PR's Release notes

Fill in the PR template's Release notes (Breaking, Added or Fixed, one line per user-visible change) and its Semver line, and keep them and the description current as commits are added, because the release PR reads them to pick the version.

main

What's released. It takes release PRs from dev, and the rare hotfix described below.

Trying unreleased work

git fetch && git checkout <branch> && cargo build, or an install of lnk and its plugins from a branch with cargo, as Install lnk says.

Cutting a release

A release is a version bump on dev and a pull request from dev into main.

  1. List what dev adds: the PRs merged into dev since the last release (git log --merges --first-parent origin/main..origin/dev), and their Release notes.
  2. Pick the version from the largest change, against the current version in the public workspace's Cargo.toml. Before 1.0, Breaking or Added bumps the minor version (0.11.0 -> 0.12.0) and Fixed bumps the patch (0.11.0 -> 0.11.1). From 1.0, Breaking is major, Added minor and Fixed patch. If nothing is user-visible, merge without a bump, and nothing is published.
  3. On dev, commit the bump (The bump). That commit is the only change the release makes itself.
  4. Open a PR from dev to main titled Release v<version>. Its body is the combined Release notes (Breaking, Added, Fixed), each line with its PR number.
  5. Before merging, run the checks on dev's head. checks.yml runs them again after the merge and publishes nothing if they fail.
  6. Merge with a merge commit, never a squash or rebase, or dev and main stop sharing history. The merge publishes v<version>.

The bump

version under [workspace.package], then cargo build so Cargo.lock follows. The release also ships Link Harness, Link's tools, the OpenClaw, Hermes and DeepSeek Harness adapters and the connections' plugins, which lnk installs only at its own version. Every public package inherits the workspace version.

set-version.sh

From core/, ../../.github/set-version.sh ../.. <version> changes it and updates the one lockfile's workspace entries offline. Fetch dependencies first if they are not cached.

A hotfix

A hotfix that can't wait for dev branches from main and goes in with its own PR and a patch bump. Then main is merged back into dev.

What a release publishes

A release publishes lnk with its plugins, signs it, and deploys the website.

checks.yml

Runs fmt, clippy, tests and shellcheck on release PRs into main alone; website.yml builds the website for them. Nothing into dev runs CI, dependency updates included: Actions minutes cost money. release.yml calls checks.yml first and publishes nothing if it fails.

lnk (release.yml)

Publishes when the workspace version has no release yet. It makes v<version>, the latest release of so-local/link-releases, with one archive per program and platform (lnk-core-<target> and lnk-<plugin>-<target>) for macOS and Linux, each on arm64 and x86_64, with Linux arm64 built on GitHub's arm64 runner, all under one signed SHA256SUMS.

Its plugins

The plugins come from local Cargo component declarations, as scripts/release/components.sh lists them, but a plugin a release holds back (What a release ships), which only a QA or dev build packages. An explicit LNK_COMPONENTS build input adds first-party component directories to the core's trusted registry and the product's archives. Build, packaging and sealing read the same selection, so a declared setup component has a signed asset.

How users get it

Users get lnk from curl -fsSL https://local.link/install.sh | sh, which the relay serves from core/cli/install.sh, built into its binary. They get plugins from lnk up and lnk plugin add, and updates from lnk upgrade. The relay advertises its own version as the latest client in Welcome, so after a bump connected CLIs print Update lnk X is available.

The website

The last step of release.yml calls the VERCEL_DEPLOY_HOOK secret, so the website goes live with the release it describes. Without the secret it warns and skips.

The bare domain

The bare domain's /_link/* and /install.sh belong to the relay. With LINK_WEBSITE set, which is --website in bootstrap, any other path there redirects to the website (link-web, on Vercel).

How a release is signed

Both releases sign SHA256SUMS with the RELEASE_SIGNING_KEY secret, using the repository's link-public/scripts/release/sign-release.sh.

Sealing

lnk's is sealed by link-public/scripts/release/seal-release.sh, as a QA build is: it checks the archives are exactly what the build makes, adds install.sh trusting the signers files it's given (the release key's alone), VERSION and SHA256SUMS, and signs them, checking the signature against the last signers file.

Only from main

The job runs only from main, in the release environment, which only main may use and a reviewer approves. release.rs checks that every job using a secret does the same.

Built without a cache

The jobs that build what's signed restore no cache, so a poisoned cache can't alter a release, and release.rs checks that release.yml names none. A release's build compiles from the checkout alone, slower than a cached one.

Who trusts the key

Relay servers, lnk upgrade (link_plugin::release) and install.sh accept only the keys in scripts/release/release-signers. install.sh and bootstrap.sh embed a copy, and tests/e2e/tests/release.rs checks they match. A release trusts that key alone.

build-release.sh

release.yml's build runs link-public/scripts/release/build-release.sh for each target with LNK_BUILD=release, the build and its archives, which a QA build runs too. Built for the machine it runs on, its programs must run, say the core's version, and lnk be a QA build exactly when LNK_BUILD says so.

Package existing programs

package-release.sh packages the selected components from their owning workspaces' target directories. The builder calls it after compiling; local QA can package checked native debug programs with package-release.sh <target> debug .. The same archive helper is used by isolated signed-release fixtures. Sealing still requires the complete selected set for every target and the release's expected signer.

A QA build

A QA build is a release in every way but its key, so what fetches a release (install.sh, lnk upgrade, lnk plugin add) is checked before the release. It's built from a commit with the release's own packaging, on your machine or a cloud session, never by a workflow, and published to a bucket of your own laid out as a release.

Its key

It's signed with the QA key, whose public line is in scripts/release/qa-signers. lnk --version says on its second line when it's a QA build, and its install.sh trusts both keys. A release's binary holds none of the QA key's bytes.

LNK_BUILD

What a build is, LNK_BUILD says as it's compiled, read once (link_plugin::Build): release (release.yml sets it), qa, dev (A dev build), or unset for a build from source; any other value doesn't compile.

Which keys it trusts

Which keys lnk trusts follows from it (release::TRUSTED_SIGNERS): a QA build trusts the release key and the QA key, so it installs QA builds and releases; any other, as every release, the release key alone, so a release never installs a QA build.

What release.rs checks of it

That no workflow builds a QA build or names the QA key, that the QA key isn't the release key, and that a QA build installs with its own install.sh and is refused by a release's.

Its version

Its own: the release's it comes before, then -qa. and when it was built, in UTC to the minute (0.15.0-qa.202610131900). So two QA builds never share one, a later one is newer than an earlier one, and the release is newer than both, as lnk upgrade and a move's upgrade on a box order them. Each build is a folder of its own, named by its commit.

Make a QA build

qa-release.sh builds, seals and uploads one:

# from core/, on a clean checkout of the commit, with the QA key in a file only you can read
LNK_QA_SIGNING_SECRET_FILE=~/.config/link/qa-signing-secret ../../link-public/scripts/release/qa-release.sh --to s3://my-qa-builds/qa
# or a Google Cloud bucket, or a folder here served over https at the url you give
LNK_QA_SIGNING_SECRET_FILE=~/.config/link/qa-signing-secret ../../link-public/scripts/release/qa-release.sh --to gs://my-qa-builds/qa

Its worktree

It builds the commit in a worktree of its own outside the checkout, its locked crates fetched first (cargo fetch --locked), so a fresh checkout needs nothing cached, its version set in the public workspace's Cargo.toml and lockfile (set-version.sh), and removes the worktree and what it made however the build ends: the checkout is left as it was.

Its targets

It builds Linux on x86_64 and arm64 (musl) with build-release.sh and LNK_BUILD=qa, the target that isn't this machine's with cargo zigbuild (pip install ziglang, cargo install cargo-zigbuild), so this machine's programs are checked as a release's are and another target's said to be unchecked. --targets picks others. The macOS targets are built on a Mac, the same way, with --targets.

Sealing and uploading

It seals them with seal-release.sh, as a release is, trusting the release key and the QA key and signed with the QA key, and uploads them to <folder>/<commit>/download/v<version>/ and <folder>/<commit>/latest/download/, the checksums last.

--built

--built <dir> --version <version> seals and uploads archives build-release.sh made before, once an lnk among them that runs here says it's a QA build of that version, in a folder named built- and the start of their checksums' hash.

The key file

The key is a file of yours (chmod 600, never a link, and yours), copied into place only to sign and removed straight after. The build runs without its name in its environment, and it's refused in the environment as LNK_QA_SIGNING_SECRET, where the build, the lnk it checks and the clouds' CLIs would inherit it.

Keeping the key from the build

The file is still yours to read while it builds, so a build's code that went looking could find it: to keep it from the build entirely, build first, fetch the key, then seal with --built.

What a seal trusts

A release's version is sealed trusting the release key alone, and a QA build's signed by the QA key, whatever signers files seal-release.sh is given.

Where it's put

<to>/<commit> is where lnk reads it, as it is: a bucket of yours with your own login, so it needn't be public, or a folder here (Where releases are read from). --url names a public https:// address it's served at instead.

Install a QA build

It ends saying how to install it; from a private bucket, a copy here first, then its own install.sh run against the copy:

aws s3 cp --recursive --only-show-errors s3://my-qa-builds/qa/<commit>/download/v<version> ~/.cache/lnk-qa/<commit>/download/v<version>
LNK_RELEASES_URL=file://$HOME/.cache/lnk-qa/<commit> LNK_VERSION=<version> sh ~/.cache/lnk-qa/<commit>/download/v<version>/install.sh
# from a QA build already, since the lnk of a release refuses one
LNK_RELEASES_URL=s3://my-qa-builds/qa/<commit> lnk upgrade <version>

On Google Cloud

The copy is gcloud storage cp --recursive into ~/.cache/lnk-qa/<commit>/download/. The copied install.sh checks the signature on SHA256SUMS with the keys sealed into it, and each archive against the signed sums, as from anywhere.

From a public address

curl -fsSL <url>/download/v<version>/install.sh | LNK_RELEASES_URL=<url> LNK_VERSION=<version> sh, checked the same way.

A dev build

A dev build is a QA build whose second key is yours, not the QA key: for testing your own commit of dev on boxes without Link's keys or a push to staging.

Why boxes need one

lnk box create installs lnk on the box from the releases this machine's lnk reads, so a build from source, which has no published release, can't make a box; a dev build is one, published to a bucket of your own. The three kinds: main builds releases, staging QA builds, and each contributor dev builds from dev.

Its key

An Ed25519 key of yours (ssh-keygen -t ed25519), never committed, never the release key or the QA key. lnk --version says on its second line when it's a dev build.

LNK_DEV_SIGNER

LNK_BUILD=dev builds lnk trusting the release key and the one allowed-signers line in LNK_DEV_SIGNER, read as it's compiled (release::TRUSTED_SIGNERS): a dev build without it, or with anything but one link-release namespaces="link-release" ssh-ed25519 <key> line, doesn't compile, rather than trust nothing or anything. No other build reads it, so a release or a QA build never installs a dev build.

What release.rs checks of it

That no workflow builds one or names LNK_DEV_SIGNER, that no committed file holds a release signer that isn't the release key, the QA key or a test's, and that a dev build installs with its own install.sh and is refused by a release's and by the lnk of a release and of a QA build.

Its version

A QA build's, with -dev.: the release's it comes before, then when it was built (0.15.0-dev.202610131900), ordered as a QA build's is.

Make a dev build

For a single-command local build and cloud box, use Developer Boxes. The steps below expose the release location and signing options for custom workflows.

Custom release workflows

dev-release.sh is qa-release.sh with your key: it derives the signer line from the key, builds with LNK_BUILD=dev and that line in LNK_DEV_SIGNER, seals trusting the release key and yours, and signs with yours.

ssh-keygen -q -t ed25519 -N '' -C lnk-dev -f ~/.config/link/dev-signing-secret
# from link-public/, on a clean checkout of the commit
scripts/release/dev-release.sh --key ~/.config/link/dev-signing-secret --to s3://my-dev-builds/dev
# then, with that build of lnk installed here, boxes install it
LNK_RELEASES_URL=s3://my-dev-builds/dev/<commit> lnk box create --cloud aws

As a QA build's

The key file is handled as the QA key's is (chmod 600, never a link, copied into place only to sign, removed after), and its other options, the bucket's layout, <folder>/<commit>/..., and how to install it here are qa-release.sh's. The bucket is yours and private: nothing about it is public, and a box needs no login to it.

On a box

The box's install.sh is checked against your lnk's keys before the box runs it, and trusts the keys sealed into it, so it installs your build. Your lnk reads the bucket with your aws or gcloud login and copies the release to the box over its SSH, where it installs from the copy; every lnk command run on the box follows the same LNK_RELEASES_URL.

Where releases are read from

lnk installs from Link's own releases unless LNK_RELEASES_URL names others (release::Source). A release, a QA build and a dev build all take all four: what's installed is trusted only as the signature on its SHA256SUMS says, checked against the keys lnk was built with, never by where it's stored.

https://<address>

Read over HTTPS, at an address anyone may read: it holds no login, \, query or fragment.

s3://<bucket>/<folder> and gs://<bucket>/<folder>

Read with aws s3 cp or gcloud storage cat, as your aws or gcloud is logged in.

file:///<folder>

The folder here. Each part of a folder is letters, digits or ._~+-, never . or ..; anything else is refused.

A cloud's login

A cloud's CLI reads its own settings and files, and is given the variables of its login, which Link hands no other program: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN, or CLOUDSDK_AUTH_ACCESS_TOKEN and GOOGLE_OAUTH_ACCESS_TOKEN.

On a box

A box can't read a bucket with your login, or a folder here, so lnk box create copies the release it installs to the box first: its signed SHA256SUMS and signature, VERSION, install.sh, and every archive for the box's processor, each checked against the signed sums, to ~/.local/share/lnk/releases/download/v<version> there.

Reading the copy

The box installs from that copy, checking each signature again, and every lnk that lnk box run runs there reads it as LNK_RELEASES_URL=file://$HOME/.local/share/lnk/releases. It holds the version the box was made with; a box for another version is made again.