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
269 changes: 269 additions & 0 deletions .github/skills/release-ahp/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
---
name: release-ahp
description: Cut a coordinated AHP release (spec + all 5 clients) — bumps every manifest, dates the CHANGELOGs, opens the release PR with auto-merge, and walks through pushing the six per-artifact tags. Use when the user says "release AHP X.Y.Z", "publish AHP", "cut a release", "tag the release", or similar. The skill also captures the recovery procedure for tags whose publish workflow didn't fire.
---

# Releasing AHP

This skill is the operational counterpart to [`RELEASING.md`](../../RELEASING.md)
and the cross-cutting [`AGENTS.md`](../../AGENTS.md). `RELEASING.md` is the
canonical reference for **what** to do per artifact; this skill captures the
**order of operations** and the foot-guns that bite during a multi-artifact
release.

The repo ships six independently-versioned artifacts but they are released
together by convention — pin the same `X.Y.Z` across all five client
manifests and the spec on every release unless the user explicitly asks
otherwise.

## When to use this skill

The user wants to release AHP. Typical asks:

- "release AHP 0.3.0"
- "tag the release", "push the release tags", "run the publish workflows"
- "the next release will be X.Y.Z" (implies post-release bump)
- "the publish workflow didn't fire on tag T, can you re-trigger it"

If the user asks for a **single-client** release (e.g. "just bump Rust"),
prefer the `.github/prompts/publish-*.prompt.md` files instead — this skill
is for coordinated cross-artifact releases.

## Mental model

A release is **two commits in one PR**, plus **six tags pushed at the first
commit's SHA after merge**:

1. **Release commit** — date-stamps every `## [X.Y.Z]` CHANGELOG heading,
bumps every client manifest from the previous version → `X.Y.Z`, and
regenerates `release-metadata.json` for every client. **All six tags
must point at this commit's SHA.**
2. **Post-release commit** — bumps `PROTOCOL_VERSION` in
`types/version/registry.ts` to the next planned version, prepends it to
`SUPPORTED_PROTOCOL_VERSIONS`, regenerates each
`Version.generated.{rs,kt,swift,go}`, and reopens
`## [Unreleased]` in every CHANGELOG (plus a
`## [X.Y+1.0] — Unreleased` placeholder in the root spec `CHANGELOG.md`).

## Step-by-step

### 0. Capture the inputs

Before touching anything, confirm with the user:

- **Release version** (`X.Y.Z`) — must equal the `## [X.Y.Z]` heading
already accumulated under `## [Unreleased]` in every CHANGELOG.
- **Next development version** (default: bump minor, i.e. `X.(Y+1).0`).
- **Release scope** (default: all six artifacts; rare to release one in
isolation).

### 1. Branch + commit 1 (release commit)

Create `release/ahp-<version>` off `main`. Then in a single commit:

- **CHANGELOGs** — for each of the 6 files (`CHANGELOG.md`,
`clients/{rust,kotlin,typescript,swift,go}/CHANGELOG.md`):
- Replace `## [Unreleased]` (or `## [X.Y.Z] — Unreleased`) with
`## [X.Y.Z] — <today YYYY-MM-DD>`.
- In each per-client CHANGELOG, add a single line `Implements AHP X.Y.Z.`
under the new heading if not already present.
- **Manifests** — bump every native version file to `X.Y.Z`:
- `clients/rust/Cargo.toml` `[workspace.package].version` **and** the
`version = "X.Y.Z"` pins on `ahp-types`/`ahp` inside
`[workspace.dependencies]`.
- `clients/kotlin/gradle.properties` `VERSION_NAME`.
- `clients/typescript/package.json` `version`.
- `clients/swift/VERSION` (bare semver, no `v` prefix, trailing newline).
- `clients/go/VERSION` (bare semver, no `v` prefix, trailing newline).
- **Lockfiles** — refresh after the manifest bump:
- In `clients/typescript/`: `npm install --no-audit --no-fund` (no-op if
already up to date but writes the version into `package-lock.json`).
- In `clients/rust/`: `cargo update -w` (rewrites only the workspace
crates in `Cargo.lock` — outside crates stay pinned).
- **Metadata** — at repo root: `npm run generate:metadata`.
- **Verify** — at repo root: `npm test`. This runs both
`verify:release-metadata` and `verify:changelog`, which together gate
the release: a mismatch between a manifest version and its CHANGELOG
heading will fail here and not at tag-push time.

> **Do not** bump `PROTOCOL_VERSION` in this commit. The tag-push
> workflows validate that the registry version matches the tag's version,
> so commit 1 must still have `PROTOCOL_VERSION = X.Y.Z`.

Commit message:

```
release: AHP X.Y.Z
```

**Record this commit's SHA.** Every release tag points at it.

### 2. Commit 2 (post-release bump)

On the same branch, in a second commit:

- `types/version/registry.ts`:
- `PROTOCOL_VERSION = 'X.(Y+1).0'`.
- `SUPPORTED_PROTOCOL_VERSIONS = ['X.(Y+1).0', 'X.Y.Z', ...]` (newest
first; keep any older entries the previous list had).
- Run `npm run generate` at repo root — this regenerates every client's
`Version.generated.*` and every `release-metadata.json`.
- Reopen `## [Unreleased]` at the top of every CHANGELOG, **above** the
newly date-stamped `## [X.Y.Z]` heading. In the root spec
`CHANGELOG.md`, also add a `## [X.(Y+1).0] — Unreleased` placeholder
(with a `Spec version: \`X.(Y+1).0\`` line).
- Run `npm test` again.

Commit message:

```
chore: bump PROTOCOL_VERSION to X.(Y+1).0 for ongoing development
```

### 3. Open the PR with auto-merge

```sh
git push -u origin release/ahp-X.Y.Z
gh pr create --base main --head release/ahp-X.Y.Z \
--title "release: AHP X.Y.Z" \
--body-file <(...)
gh pr merge <num> --auto --merge
```

**Use `--merge`, not `--squash`** — the tags need commit 1's SHA to
survive the merge. A squash collapses both commits into a new SHA on
`main`, and the workflows' `PROTOCOL_VERSION` check would then fail
because post-merge HEAD is on the next-dev version. A merge commit
preserves both original commits as reachable parents.

If the repo's branch protection forces squash, fall back to either:

- Tag the SHA **before** the squash-merge completes (the commit stays
retained once tagged), or
- Use the "Rebase and merge" strategy (also preserves both SHAs).

Always confirm before merging which strategy preserves commit 1's SHA on
`main`, and resolve any ambiguity with the user.

The PR body should include:

- The release SHA in a copy-pastable variable assignment.
- The six tag-push commands (see step 5 below).
- A note that Kotlin and TypeScript publish via Azure DevOps pipelines
(they appear under [agent-host-protocol pipelines in
vscode-engineering](https://dev.azure.com/vscode/VSCode/_build)), not
GitHub Actions.

### 4. After merge — verify the release SHA is reachable

```sh
git checkout main
git pull --ff-only origin main
git cat-file -t <RELEASE_SHA> # must print "commit"
```

If it doesn't exist (squash happened despite intent), open a follow-up
discussion with the user before tagging. Do **not** tag the squashed
commit on `main` — its registry shows the post-release version, and
every publish workflow will reject the tag.

### 5. Push the six tags

```sh
RELEASE_SHA=<sha from step 1>
git tag spec/v<X.Y.Z> $RELEASE_SHA
git tag rust/v<X.Y.Z> $RELEASE_SHA
git tag kotlin/v<X.Y.Z> $RELEASE_SHA
git tag typescript/v<X.Y.Z> $RELEASE_SHA
git tag v<X.Y.Z> $RELEASE_SHA # Swift — bare per RELEASING.md
git tag clients/go/v<X.Y.Z> $RELEASE_SHA
git push origin \
spec/v<X.Y.Z> rust/v<X.Y.Z> kotlin/v<X.Y.Z> \
typescript/v<X.Y.Z> v<X.Y.Z> clients/go/v<X.Y.Z>
```

The six tag schemes are deliberate and not interchangeable. Bare
`vX.Y.Z` is reserved for Swift (SwiftPM only resolves root-level tags);
`clients/go/vX.Y.Z` is required by the Go module proxy's sub-module
resolution.

### 6. Confirm publish runs started

For each tag, verify the corresponding workflow run is queued or in
progress:

```sh
GH_PAGER=cat gh run list --limit 10 \
--json databaseId,event,headBranch,status,conclusion,workflowName,displayTitle
```

Expect to see four GH Actions runs (one per tag below) within seconds:

| Tag | Workflow | Publishes to |
| -------------------- | --------------------- | ------------ |
| `spec/vX.Y.Z` | `Publish Spec` | GitHub Release (schema assets) |
| `rust/vX.Y.Z` | `Publish Rust Crates` | crates.io (`ahp-types`, `ahp`, `ahp-ws`) |
| `vX.Y.Z` | `Publish Swift Package` | SwiftPM (tag-resolved) |
| `clients/go/vX.Y.Z` | `Publish Go Module` | Go module proxy (tag-resolved) |

The remaining two tags trigger Azure DevOps pipelines that don't appear
in `gh run list`:

| Tag | ADO Pipeline | Publishes to |
| -------------------- | ------------------------------------ | ------------ |
| `kotlin/vX.Y.Z` | `clients/kotlin/pipeline.yml` | Maven Central via ESRP |
| `typescript/vX.Y.Z` | `clients/typescript/pipeline.yml` | npm via ESRP |

For Kotlin/TypeScript, link the user to the AHP pipelines in ADO
(`vscode-engineering` tenant) to confirm the runs started. Both
pipelines can also be triggered manually from the ADO UI as a hotfix
escape hatch — the validation steps inside each pipeline are identical
to the tag-triggered path.

## Recovery — tag pushed but no publish run fired

This has happened. Symptom: tag exists on `origin` but `gh run list`
shows no corresponding workflow run.

None of the four GH Actions publish workflows currently declare
`workflow_dispatch`, so manual dispatch is not available. The recovery
is to **delete the remote tag and re-push it** at the same SHA — this
emits a fresh push event without changing the tagged commit:

```sh
git push origin --delete <tag>
git push origin <tag>
```

Do **not** include the `--delete`d tag in the same `git push` command as
the re-push — push them in separate `git push` invocations so each tag
emits its own push event. Re-pushing several tags in a single command
sometimes coalesces into a single event and only one workflow fires.

Once recovery is verified, suggest a follow-up PR adding
`workflow_dispatch:` to the four publish workflows so the next missed
trigger can be re-run from the Actions UI without tag thrash.

## Common foot-guns

- **GPG signing lock during `git commit`** — if you see
`gpg: keydb_search failed: Operation timed out`, run
`gpgconf --kill all && rm -f ~/.gnupg/public-keys.d/pubring.db.lock`
and retry the commit.
- **Bumping `PROTOCOL_VERSION` in commit 1** — the publish workflows
re-validate the tag against the tagged commit's `PROTOCOL_VERSION`,
so a release tag pointing at the post-release commit fails the "Verify
tag matches" step. Always keep the bump in commit 2.
- **Forgetting the Rust `[workspace.dependencies]` pins** — bumping just
`[workspace.package].version` leaves the cross-crate `ahp-types`/`ahp`
pins on the old version. `verify-release-metadata` does not catch this
but `cargo publish --dry-run` will.
- **Forgetting `cargo update -w`** — without it, `Cargo.lock` keeps the
old version for ahp-types/ahp/ahp-ws and the resulting commit is
partially-bumped. CI's per-language drift check catches it.
- **Forgetting the Swift/Go trailing newline in `VERSION`** — the
`read*PackageVersion` helpers trim, so functionally it's fine, but
the convention is `0.3.0\n`.
- **Mixing up the Swift tag namespace** — Swift uses **bare** `vX.Y.Z`
(no prefix). Every other artifact has a prefix. Putting `swift/vX.Y.Z`
on Swift's tag silently breaks SwiftPM resolution.
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ jobs:
- name: Build Go module
run: go build ./...

- name: Check Go formatting
run: |
OUT=$(gofmt -l .)
if [ -n "$OUT" ]; then
echo "::error::gofmt -l reported unformatted files. Run 'gofmt -w' on them and commit the result:"
echo "$OUT"
exit 1
fi

- name: Vet Go module
run: go vet ./...

Expand Down
10 changes: 5 additions & 5 deletions clients/go/ahp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (
// memTransport is an in-memory paired transport: the two ends share
// each other's send/recv channels. Used as a fake server in tests.
type memTransport struct {
inbox chan TransportMessage
outbox chan TransportMessage
closeMu *sync.Mutex
closed *bool
closeCh chan struct{}
inbox chan TransportMessage
outbox chan TransportMessage
closeMu *sync.Mutex
closed *bool
closeCh chan struct{}
}

func newMemTransportPair() (*memTransport, *memTransport) {
Expand Down
4 changes: 2 additions & 2 deletions clients/go/examples/reducers_demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func main() {

actions := []ahptypes.StateAction{
{Value: &ahptypes.SessionTurnStartedAction{
Type: ahptypes.ActionTypeSessionTurnStarted,
TurnId: "t1",
Type: ahptypes.ActionTypeSessionTurnStarted,
TurnId: "t1",
Message: ahptypes.Message{Text: "Hello!"},
}},
{Value: &ahptypes.SessionResponsePartAction{
Expand Down
Loading