From 096e05d5405a51c0403cfa108a708677ed6bc8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 1 Oct 2026 09:17:18 -0700 Subject: [PATCH 1/3] docs: add a release skill Releasing turned out to have four traps and we hit three of them in one afternoon, so they belong somewhere other than a transcript. A valid tag is not a current one. gohai had a perfectly good v1.0.0 and eighty-one commits sitting behind it, and the check that would have caught that is ahead_by rather than the tag list. Go requires three-part versions. nats-client and nats-server were tagged v1.0, which the module proxy ignores, so both served pseudo-versions while looking released. A tag that fires a workflow which cannot authenticate leaves a tag with no release, and the tag is the half that cannot be taken back. The GH_PAT secret expired and nats-client's v1.0.1 landed without one. A version the proxy has fetched is in sum.golang.org permanently. Deleting and re-pushing that tag makes go get report a checksum mismatch, which Go treats as tampering. The skill says to create the missing release against the existing tag instead, and records that re-running the failed workflow does not work because a re-run uses the workflow file as it was at that tag's commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c --- .claude/skills/release/SKILL.md | 131 ++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .claude/skills/release/SKILL.md diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..4915ae2 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,131 @@ +--- +name: release +description: Cut a release for an osapi-io Go repository. Works out whether a tag is needed and which number it should be, checks the release workflow will actually publish, pushes the tag, and confirms the result reached the Go module proxy. Use when asked to release, tag, cut a version, bump a version, or publish a repository, and when asked why a release failed, why a badge shows no release, or why `go get` resolves a pseudo-version. +compatibility: Requires the gh CLI authenticated against osapi-io, and a checkout of the repository being released. +license: MIT +metadata: + author: osapi-io + source: https://github.com/osapi-io/specs +--- + +# Release + +A release here is one thing: a git tag. GoReleaser watches for `v*`, builds +whatever the repository builds, writes the changelog, and creates the GitHub +release. Nothing else is run by hand. + +Two facts decide everything in this skill. Go resolves versions from tags and +has never heard of GitHub releases. And a version, once the module proxy has +fetched it, can never be changed. + +## 1. Decide whether a tag is needed at all + +A valid tag is not the same as a current one. + +```bash +r= +gh api "repos/osapi-io/$r/tags" --jq '[.[].name] | join(", ")' +gh api "repos/osapi-io/$r/compare/...main" --jq '.ahead_by' +curl -s "https://proxy.golang.org/github.com/osapi-io/$r/@v/list" +``` + +The three answers disagree more often than not. gohai had a perfectly valid +`v1.0.0` and eighty-one commits sitting behind it. nats-client and nats-server +were tagged `v1.0`, which the proxy ignores, so both served pseudo-versions +while looking released. + +An empty proxy list on a repository that has tags means the tags are not valid +Go versions. Go requires three parts: `v1.0` is not a version, `v1.0.0` is. + +Not every repository takes a tag. osapi-justfiles is consumed by curling from +`refs/heads/main` and specs is documentation. Neither has release machinery and +neither should get a tag. + +## 2. Pick the number + +Count what landed, do not guess from the diff size. + +```bash +gh api "repos/osapi-io/$r/compare/...main" \ + --jq '[.commits[].commit.message | split("\n")[0]] + | map(select(startswith("feat"))) | length' +``` + +Any `feat:` means a minor bump. Only `fix:` and `chore:` means a patch bump. A +repository that has never been tagged starts at `v0.1.0`, because `v1.0.0` is a +promise about API stability, and a library makes that promise once. + +The first release of an application is a product decision rather than a +mechanical one. Ask instead of picking. + +## 3. Check the workflow will publish + +A tag that fires a workflow that cannot authenticate leaves a tag with no +release, and the tag is the half that cannot be taken back. + +```bash +gh api "repos/osapi-io/$r/contents/.github/workflows/release.yml" --jq .content \ + | base64 -d | grep -n 'GITHUB_TOKEN:' +``` + +It must read `${{ secrets.GITHUB_TOKEN }}`. A `secrets.GH_PAT` here is a bug. +No repository in this organization publishes outside itself, so none of them +needs a personal token: + +```bash +gh api "repos/osapi-io/$r/contents/.goreleaser.yaml" --jq .content | base64 -d \ + | grep -E '^(brews|dockers|nfpms|aurs|scoops|publishers|winget):' +``` + +If that prints nothing, the built-in token is sufficient and the workflow +already grants `contents: write`. A PAT expires; the built-in token cannot. + +## 4. Tag it + +```bash +cd ~/git/osapi-io/$r +git checkout main && git pull +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +The push is the release. Watch it land: + +```bash +gh run watch -R "osapi-io/$r" "$(gh run list -R "osapi-io/$r" \ + --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')" +``` + +## 5. Confirm it reached the proxy + +The GitHub release is for people. This is the one consumers use. + +```bash +curl -s "https://proxy.golang.org/github.com/osapi-io/$r/@v/vX.Y.Z.info" +``` + +The `@v/list` endpoint lags by minutes, so a specific `.info` is the real +answer. + +## When a release fails after the tag landed + +Do not delete the tag. If the proxy fetched the version, it is in +`sum.golang.org` permanently, and that database is append-only: + +```bash +curl -s "https://sum.golang.org/lookup/github.com/osapi-io/$r@vX.Y.Z" +``` + +Anything there is published forever. Re-pointing the tag at a different commit +makes `go get` report a checksum mismatch, which Go treats as tampering rather +than as a mistake, and every consumer sees it. + +Fix the cause, then create the missing release against the tag that already +exists: + +```bash +gh release create vX.Y.Z -R "osapi-io/$r" --generate-notes +``` + +Re-running the failed workflow does not work. A re-run uses the workflow file +as it was at that tag's commit, so a token fix merged afterwards is not in it, +and it fails again identically. From 2763f7d7e7c09dc6a406caf265da9bcf67e80959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 1 Oct 2026 09:26:37 -0700 Subject: [PATCH 2/3] docs: pick the version by reading the features, not counting them The rule said any feat: is a minor bump, which is how it produced exactly the wrong answer for both nats repositories on the same afternoon it was written. nats-client had seven feat: commits and nats-server two, so counting made nats-client look like the smaller release. nats-client's seven were Object Store support, core Subscribe and PublishCore, KV CreateOrUpdate and OTel trace propagation. nats-server's two were a justfile recipe and a coverage gate, which no consumer can call. The recommendation that came out was v1.0.1 for the repository with seven API additions and v1.1.0 for the one with none. The rule is now to read the list and ask which entries changed something a consumer can call. The worked example is in the skill, because the failure is not obvious from the rule alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c --- .claude/skills/release/SKILL.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4915ae2..569ddd1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -43,17 +43,28 @@ neither should get a tag. ## 2. Pick the number -Count what landed, do not guess from the diff size. +Read what landed. Do not count it, and do not guess from the diff size. ```bash gh api "repos/osapi-io/$r/compare/...main" \ - --jq '[.commits[].commit.message | split("\n")[0]] - | map(select(startswith("feat"))) | length' + --jq '.commits[].commit.message | split("\n")[0]' | grep '^feat' ``` -Any `feat:` means a minor bump. Only `fix:` and `chore:` means a patch bump. A -repository that has never been tagged starts at `v0.1.0`, because `v1.0.0` is a -promise about API stability, and a library makes that promise once. +Then read the list and ask which of those changed what a consumer can call. +A `feat:` that adds a justfile recipe or a coverage gate is a patch. A `feat:` +that adds an exported function is a minor bump. + +Counting gets this backwards, and did. nats-client had seven `feat:` commits +and nats-server two, which looked like the larger release was nats-client's by +a wide margin and the smaller one nearly nothing. In fact nats-client's seven +were Object Store support, core Subscribe and PublishCore, KV CreateOrUpdate +and OTel trace propagation, all API, while nats-server's two were a justfile +recipe and a coverage gate and touched no consumer at all. The recommendation +that came out of counting was `v1.0.1` for the one with seven API additions +and `v1.1.0` for the one with none. + +A repository that has never been tagged starts at `v0.1.0`, because `v1.0.0` is +a promise about API stability, and a library makes that promise once. The first release of an application is a product decision rather than a mechanical one. Ask instead of picking. From 1ee200ae353ed8425a8741f3662d8895a5f75791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 1 Oct 2026 10:14:42 -0700 Subject: [PATCH 3/3] docs: give the release skill a README and an index row Every other skill has a README and a row in the repository index. This one shipped with neither, so it was findable only by an agent already looking in .claude/skills. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c --- .claude/skills/release/README.md | 60 ++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 61 insertions(+) create mode 100644 .claude/skills/release/README.md diff --git a/.claude/skills/release/README.md b/.claude/skills/release/README.md new file mode 100644 index 0000000..dca6bb7 --- /dev/null +++ b/.claude/skills/release/README.md @@ -0,0 +1,60 @@ +# release + +Answers "does this repository need a tag, which number, and will the tag actually +produce a release?" so cutting one is a prompt rather than a guess that publishes +something you cannot withdraw. + +A release here is one thing: a git tag. GoReleaser watches for `v*`, builds +whatever the repository builds, writes the changelog, and creates the GitHub +release. The skill covers deciding and checking, not running anything by hand. + +## Install + +Nothing to install. The skill lives in this repository and any skills-aware agent +working from the repository root finds it. It needs the [gh] CLI authenticated +against [osapi-io] and a checkout of whatever is being released. + +## Usage + +Ask in plain language, or invoke it directly with `/release`. + +| Ask | You get | +| --------------------------------------------- | ----------------------------------------------------------------- | +| "release gohai" | Whether it needs one, the number with the reasoning, and the tag | +| "why does the badge say no releases?" | Usually a tag that landed while the workflow failed to publish | +| "why does go get give me a pseudo-version?" | Usually a tag the module proxy rejects, like `v1.0` | +| "can we re-tag that, it was the wrong number?" | No, and what the checksum database does to anyone who tries | + +## What it does + +Five steps: work out whether a tag is needed at all, pick the number by reading +what landed, check the workflow can authenticate, push the tag, and confirm the +version reached the Go module proxy. + +## What it is for + +Four traps, three of which this organization hit in one afternoon. + +A valid tag is not a current one. gohai had a good `v1.0.0` and eighty-one +commits sitting behind it, and the tag list looks healthy in exactly that case. + +Go requires three-part versions. `v1.0` is not one, so nats-client and +nats-server served pseudo-versions while appearing released. + +A tag that fires a workflow which cannot authenticate leaves a tag with no +release, and the tag is the half that cannot be taken back. + +A version the module proxy has fetched is in `sum.golang.org` permanently. +Moving that tag makes `go get` report a checksum mismatch, which Go treats as +tampering rather than as a mistake. + +The version rule is to read what landed rather than count it. Seven `feat:` +commits that add exported functions are a minor release; two that add a justfile +recipe are a patch. Counting gets this backwards, and did. + +## License + +The [MIT](../../../LICENSE) License. + +[gh]: https://cli.github.com +[osapi-io]: https://github.com/osapi-io diff --git a/README.md b/README.md index 4d3d4a5..d9131fb 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ and wrong after the next change, with nothing marking the moment. | [document](.claude/skills/document/README.md) | Where a design goes, what the page looks like, and whether one already covers it | | [org-status](.claude/skills/org-status/README.md) | Open pull requests, Dependabot bumps, security alerts, whether CI is green, and working the merge queue across [osapi-io] | | [add-a-domain](.claude/skills/add-a-domain/README.md) | Adding an osapi domain: the provider and every layer it has to appear in, in the order that avoids rework | +| [release](.claude/skills/release/README.md) | Whether a repository needs a tag, which number it takes, and whether the tag will actually publish | Each follows the [Agent Skills] format: a slim `SKILL.md` that routes, with the detail in reference files an agent reads only when the question calls for them.