Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .claude/skills/release/README.md
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
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=<repo>
gh api "repos/osapi-io/$r/tags" --jq '[.[].name] | join(", ")'
gh api "repos/osapi-io/$r/compare/<latest-tag>...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

Read what landed. Do not count it, and do not guess from the diff size.

```bash
gh api "repos/osapi-io/$r/compare/<latest-tag>...main" \
--jq '.commits[].commit.message | split("\n")[0]' | grep '^feat'
```

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.

## 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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading