Releasing - joeylking/agent-runtime GitHub Wiki

Releasing

This page is for whoever tags a release. The procedure is in CONTRIBUTING.md's "Releasing" section, which is authoritative; this page gives each step the reason it exists, because every one of them was learned from something that went wrong or nearly did.

Why releasing needs a procedure at all

The core and each nested module tag independently: v0.5.0 for the core, mcp/v0.2.1, providers/ollama/v0.2.0, providers/anthropic/v0.2.0, providers/openai/v0.2.0, bench/v0.1.1, export/otel/v0.1.2, and proxy/v0.2.0 for the others. Each nested module's go.mod requires a specific core release, except bench, which requires nothing, not even the core. proxy requires mcp as well as the core.

A nested module is tagged only where its code changed. The v0.4.0 release tagged mcp/v0.2.1, export/otel/v0.1.1, and proxy/v0.1.0, which was new. It did not tag the three providers or bench: their code did not change, so they are re-pinned to the new core on main without a new tag, and their last tags still pin the core they were released against, which the new core only adds to. The v0.5.0 release tagged proxy/v0.2.0 and export/otel/v0.1.2, because the proxy gained recheck and Config.PolicyID and the exporter two attributes. It did not tag mcp, the providers, or bench, which are re-pinned only where their go.mod was updated, without a new tag.

Day to day, nobody builds that graph. Development across modules uses a go.work workspace, which overrides every nested module's pinned core version with the in-tree core, because a nested module mid-development legitimately needs core symbols newer than the release it pins. That is what makes working across modules practical, and it is also why ordinary CI never builds the dependency graph a consumer actually resolves: go.mod's pinned core version, not the workspace override. A nested module could be tagged pinning a core it does not compile against, and nothing would notice until a consumer ran go get.

The release check exists to close that gap, and the order of the procedure exists so the graph is proven to build, with no workspace, before anything is tagged.

The release check

.github/workflows/release-check.yml builds each nested module against the core version it pins, with GOWORK=off so nothing overrides the pin. Its module list is providers/ollama, providers/anthropic, providers/openai, mcp, bench, export/otel, and, since v0.4.0, proxy. For each module it runs:

GOWORK=off go mod verify
GOWORK=off go mod tidy -diff
GOWORK=off go build ./...
GOWORK=off go vet ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off govulncheck ./...

on Go 1.26 and 1.27. It does not run on an ordinary push, because "needs a newer core than it pins" is the normal state of a module mid-development, not a bug. It runs:

  • on a push of a nested-module tag, one directory level (mcp/v0.2.0, bench/v0.1.0) or two (providers/ollama/v0.2.0, export/otel/v0.1.0), matched by the filters */v* and */*/v*; they deliberately do not match a core tag, because when the core is tagged the nested modules still pin the previous core and the check could only fail there;
  • weekly, with the examples included;
  • on demand with workflow_dispatch, with an examples input that also checks examples/live and examples/mcp, which pin the nested modules' own tags.

The manual run on the commit being tagged is the gate. The tag-push run is a second look.

The procedure

1. Tag the core, only once CI is green on the exact commit being tagged. Not a later commit, and not a commit CI has not run against. A tag names content forever (see below), so it must name content that was tested.

2. Re-pin the nested modules to the new core version. For each nested module that requires the core, providers/ollama, providers/anthropic, providers/openai, mcp, and export/otel. proxy also requires the core, but it is re-pinned in step 5, as below:

# Wait until the proxy serves the tag just pushed.
until curl -sf https://proxy.golang.org/github.com/joeylking/agent-runtime/@v/vX.Y.Z.info; do sleep 5; done

(cd providers/ollama && go get github.com/joeylking/[email protected])

# Confirm the go.sum lines go get just wrote, independently, against the checksum database.
curl -sf https://sum.golang.org/lookup/github.com/joeylking/[email protected]

Then go mod tidy in each, and push the result as an ordinary commit. v0.5.0 is tagged at 79e661d, and the commit after it, 38346a6, is the one that re-pinned the nested modules and the examples to it. The proxy and the checksum database can lag a freshly pushed tag by a few minutes; that is why the loop waits, rather than racing them.

Three of them need a word. export/otel must be re-pinned to a core release that contains the export package its example imports; a core older than v0.3.1 does not, so the module cannot be tagged against one. bench has no requirements at all, not even the core, so it has nothing to re-pin: it is tagged after the core like the others, and this step does not apply to it. The release check still builds it with GOWORK=off, which for bench proves it needs nothing outside itself. proxy requires mcp as well as the core, so like the examples it can be re-pinned only once the new mcp tag exists. It is re-pinned in step 5 and tagged after that. Until proxy first built against released versions it was deliberately left out of the release check's list, because it needed the gate and mcp.ReadOwned, which no release carried yet; the release that carried both added it to the list, and it is there now.

3. Wait for both CI and the release check to go green on that commit. The release check does not trigger on an ordinary push, so run it by hand, with the examples off, because the examples cannot be re-pinned until the nested tags exist:

gh workflow run release-check.yml -f examples=false

This is the step that actually builds each nested module against its newly pinned core with no workspace.

4. Tag the nested modules, now that both are green, and push the tags one at a time. GitHub creates no push event when more than three tags arrive in one push, so the release check would run for none of them. There are now seven nested modules, so tagging them together can be exactly that case, and each tag is pushed on its own.

5. Re-pin the examples (examples/live, examples/mcp) and proxy to the new nested-module tags, tidy, and push. Then run the release check once more with its default inputs, which include the examples, and once it is green for that commit, tag proxy.

6. Create a GitHub release for every tag, the core's and each nested module's that was tagged. The changelog's ## [Unreleased] section becomes that release's dated section in CHANGELOG.md, the GitHub release body is that section's content, and ## [Unreleased] is empty again for the next cycle.

Never bypass the checksum database

When a re-pin fails because the proxy or the checksum database has not caught up, the temptation is to route around them. Do not: GONOSUMDB, GOPRIVATE, GOFLAGS=-mod=mod, and GOPROXY=direct all skip the verification that catches a tag whose content changed after the fact, or a proxy serving something other than what was pushed. None of them is needed: the modules are public, and go get at default settings re-pins and verifies both together. If the proxy or the checksum database has not caught up, wait.

Tags are immutable

A pushed tag is never moved, force-pushed, or re-pointed at a different commit, on the core or on a nested module. A consumer's go.sum pins the content a tag names, and the checksum database records it; moving the tag breaks that promise silently for anyone who fetched it before, and loudly for everyone after. A mistake in a tagged commit is fixed by tagging a new patch version.

v0.3.0 itself carries a failed run of the release check, because the tag filter at the time (**/v*) also matched a core tag, where the check could only fail: **/ also matches nothing, so v0.3.0 matched. The fix was to the workflow's filters, which are now */v* and */*/v*, one and two directory levels, not to the tag.

A release that adds a migration

A schema migration changes what OpenStore writes to a database on first open, and old and new schema versions are not read together. Before such a release's database is opened by anything, stop the operator binary and every consumer process that has the database open, on every host that shares it, and restart them on the new build once the migration has run. OpenExisting refuses a version mismatch rather than guessing, but only after something has tried to open the file. The release notes say so in their first lines, as v0.3.0's did; see Upgrading to v0.3. v0.4.0 added no migration to the runtime's schema, and says so in its first lines; see Upgrading to v0.4. v0.5.0 added migration 6, which backfills the event chain, and its first lines say to stop every older process first, because one still writing after the migration appends events with no hash and breaks the chain at the first; see Upgrading to v0.5.

The migration pin is folded in at release. TestStore_ReleasedMigrationsAreUnchanged pins the released migrations by a hash of their text, so a released migration cannot be edited. A migration is folded into that pin when the release that carries it is tagged, as migrations 5 and 6 were, so that no released migration is edited.

Next

Contributing is how a change gets to the point of being released. Testing has the commands CI runs.