Skip to content
Closed
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
51 changes: 51 additions & 0 deletions .agents/skills/release-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: release-cli
description: Use when cutting a tagged release of the bw CLI from main, watching the goreleaser + bump-formula pipeline, and confirming the Homebrew tap PR opens and passes strict audit.
---

# Release the bw CLI

Tag-driven release. Pushing `vX.Y.Z` triggers `.github/workflows/release.yml`, which runs three sequential jobs:

1. `test` — runs `go test ./...` on linux/mac/windows
2. `release` — goreleaser builds binaries, creates the GitHub release, pushes Docker images
3. `bump-formula` — `mislav/bump-homebrew-formula-action@v3` opens a PR on `Bandwidth/homebrew-tap` updating `url` + `sha256` to the new tag's source archive

The tap formula is **source-build**: `brew install` runs `go build` (~22s, vs. ~5s on the old pre-built tarball flow). The formula structure lives canonically in the tap repo — the action only bumps version/sha.

## Pre-flight (on the cli repo)

1. Confirm `gh auth status` is on the **kshahbw** account (Bandwidth writes need it).
2. On `main`, clean tree, pulled: `git checkout main && git pull && git status`.
3. Last release: `git describe --tags --abbrev=0`.
4. Decide next version (semver against the changes since that tag).

## Cut the tag

5. `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
6. `git push origin vX.Y.Z`

## Watch the cli release pipeline

7. `gh run watch` (or `gh run list --workflow=release.yml --limit 1`) — wait for the workflow to finish. All three jobs (`test`, `release`, `bump-formula`) need to go green.
8. Confirm the GitHub release exists: `gh release view vX.Y.Z`.

## Watch the homebrew-tap PR

`bump-formula` opens a PR on `Bandwidth/homebrew-tap` with title `band vX.Y.Z`. The tap repo's `ci.yml` runs `brew audit --strict band` (the `CI / audit` check) on every PR — don't run it locally.

9. Find it: `gh pr list --repo Bandwidth/homebrew-tap --search "band vX.Y.Z in:title" --state open`.
10. Wait for the `audit` check to go green: `gh pr checks <pr-number> --repo Bandwidth/homebrew-tap --watch`.
11. Merge it: `gh pr merge <pr-number> --repo Bandwidth/homebrew-tap --squash`.

## Smoke test

12. `brew update && brew upgrade band && band version` — confirm the new version installs and prints. First-time install on a clean machine takes ~22s (Go compile); upgrade of an existing install is faster.

## If something fails

- **Tests fail in release workflow** → the tag is already pushed; delete it (`git push --delete origin vX.Y.Z && git tag -d vX.Y.Z`), fix on a branch, re-tag.
- **`release` (goreleaser) fails** → check `gh run view --log-failed` on the release run; usually a missing secret or changelog filter issue.
- **`bump-formula` fails** → most often a `HOMEBREW_TAP_TOKEN` scope/expiry issue or a rate limit on the tap repo; the GitHub release still went out, so fix the token and re-run just that job (`gh run rerun <run-id> --job bump-formula`).
- **Tap audit fails strict** → fix `Formula/band.rb` directly in the tap repo and merge that as a separate PR; the bump-formula PR can be rebased after. The cli repo no longer owns the formula structure, so don't try to fix it from there.
- **Repro audit locally** before re-tagging: clone the tap repo, then `brew audit --strict band`.
2 changes: 1 addition & 1 deletion cmd/customerprofile/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ var restoreCmd = &cobra.Command{

Sends softDeleted: false. Note the published API docs describe restoring with
{"deleted": false} — that form returns 404 "Customer profile not found" even
though the record is retrievable. Reported as MV-23429.
though the record is retrievable. Reported to the API team.

No --confirm needed: restoring is not destructive.`,
Example: ` band customer-profile restore 3IIzIFnRRQBE3AMzPpMTNo --plain`,
Expand Down
2 changes: 1 addition & 1 deletion cmd/tendlc/brand_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ of a new registration. Sending any other key would turn it back into a
create, so nothing else is added. Use it after making a change directly in
TCR, or to pick up a brandId that was still null when the brand was
created.`,
Example: ` band tendlc brand refresh BGJR2BA --plain`,
Example: ` band tendlc brand refresh BEXMPL6 --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
svc, err := service(cmd)
Expand Down
8 changes: 4 additions & 4 deletions cmd/tendlc/brand_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,12 @@ func TestBrandCreateRejectsStrayPositional(t *testing.T) {
}
}

// Test 10: refresh posts exactly {"brandId": "BGJR2BA"} — no other keys. A
// Test 10: refresh posts exactly {"brandId": "BEXMPL6"} — no other keys. A
// refresh body carrying any extra key turns it back into a create.
func TestBrandRefreshPostsExactBody(t *testing.T) {
srv, bodies := stubBrandCreateCapturing(t, "WET8JUY8H0")

out, _, err := runBrandCmd(t, srv, "brand", "refresh", "BGJR2BA")
out, _, err := runBrandCmd(t, srv, "brand", "refresh", "BEXMPL6")
if err != nil {
t.Fatalf("brand refresh: %v", err)
}
Expand All @@ -378,8 +378,8 @@ func TestBrandRefreshPostsExactBody(t *testing.T) {
if err := json.Unmarshal([]byte((*bodies)[0]), &sent); err != nil {
t.Fatalf("request body is not JSON: %v", err)
}
if len(sent) != 1 || sent["brandId"] != "BGJR2BA" {
t.Errorf("posted body = %v, want exactly {\"brandId\":\"BGJR2BA\"}", sent)
if len(sent) != 1 || sent["brandId"] != "BEXMPL6" {
t.Errorf("posted body = %v, want exactly {\"brandId\":\"BEXMPL6\"}", sent)
}
got := decodeStdout(t, out)
if got["bandwidthId"] != "WET8JUY8H0" {
Expand Down
4 changes: 2 additions & 2 deletions cmd/tendlc/brand_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ IS success here, the only place in this command set where that is true — and
only then does "deleted" flip to true. A --wait timeout (exit 5) prints the
same unconfirmed receipt as no-wait; it never claims deleted:true merely
because --wait gave up waiting.`,
Example: ` band tendlc brand delete BGJR2BA --confirm --plain
band tendlc brand delete BGJR2BA --confirm --wait --timeout 60 --plain`,
Example: ` band tendlc brand delete BEXMPL6 --confirm --plain
band tendlc brand delete BEXMPL6 --confirm --wait --timeout 60 --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireConfirm(brandDeleteConfirm,
Expand Down
22 changes: 11 additions & 11 deletions cmd/tendlc/brand_delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func stubBrandDeleteStillExistsServer(t *testing.T, deleteStatus int) (*httptest
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":{"brandId":"BGJR2BA","bandwidthId":"WET8JUY8H0"}}`))
_, _ = w.Write([]byte(`{"data":{"brandId":"BEXMPL6","bandwidthId":"WET8JUY8H0"}}`))
})
return srv, &methods
}
Expand All @@ -53,7 +53,7 @@ func stubBrandDeleteStillExistsServer(t *testing.T, deleteStatus int) (*httptest
// server, so this fails loudly (not silently) if the confirm gate ever moves
// after the service/DELETE call.
func TestBrandDeleteWithoutConfirmMakesNoRequests(t *testing.T) {
_, _, err := runBrandCmd(t, nil, "brand", "delete", "BGJR2BA")
_, _, err := runBrandCmd(t, nil, "brand", "delete", "BEXMPL6")
if err == nil {
t.Fatal("want an error when --confirm is missing")
}
Expand Down Expand Up @@ -86,16 +86,16 @@ func TestBrandDeleteWithoutConfirmMakesNoRequests(t *testing.T) {
func TestBrandDeleteWithConfirmIssuesDeleteAndPrintsReceipt(t *testing.T) {
srv, methods := stubBrandDeleteServer(t, http.StatusNoContent)

out, _, err := runBrandCmd(t, srv, "brand", "delete", "BGJR2BA", "--confirm", "--plain")
out, _, err := runBrandCmd(t, srv, "brand", "delete", "BEXMPL6", "--confirm", "--plain")
if err != nil {
t.Fatalf("brand delete --confirm: %v", err)
}
if len(*methods) != 1 || (*methods)[0] != http.MethodDelete {
t.Fatalf("want exactly one DELETE, got %v", *methods)
}
got := decodeStdout(t, out)
if got["id"] != "BGJR2BA" {
t.Errorf("stdout = %v, want id BGJR2BA", got)
if got["id"] != "BEXMPL6" {
t.Errorf("stdout = %v, want id BEXMPL6", got)
}
if got["deleted"] != false {
t.Errorf("stdout = %v, want deleted false — accepted is not confirmed, and there was no --wait to confirm it", got)
Expand All @@ -116,16 +116,16 @@ func TestBrandDeleteWithConfirmIssuesDeleteAndPrintsReceipt(t *testing.T) {
func TestBrandDeleteWaitTreats404AsSuccess(t *testing.T) {
srv, methods := stubBrandDeleteServer(t, http.StatusNoContent)

out, _, err := runBrandCmd(t, srv, "brand", "delete", "BGJR2BA", "--confirm", "--wait", "--timeout", "5", "--plain")
out, _, err := runBrandCmd(t, srv, "brand", "delete", "BEXMPL6", "--confirm", "--wait", "--timeout", "5", "--plain")
if err != nil {
t.Fatalf("brand delete --confirm --wait: %v", err)
}
if len(*methods) < 2 || (*methods)[0] != http.MethodDelete || (*methods)[1] != http.MethodGet {
t.Fatalf("want a DELETE then at least one GET, got %v", *methods)
}
got := decodeStdout(t, out)
if got["id"] != "BGJR2BA" {
t.Errorf("stdout = %v, want id BGJR2BA", got)
if got["id"] != "BEXMPL6" {
t.Errorf("stdout = %v, want id BEXMPL6", got)
}
if got["deleted"] != true {
t.Errorf("stdout = %v, want deleted true — the follow-up 404 confirmed it", got)
Expand All @@ -142,7 +142,7 @@ func TestBrandDeleteWaitTreats404AsSuccess(t *testing.T) {
func TestBrandDeleteWaitTimeoutKeepsReceiptHonest(t *testing.T) {
srv, methods := stubBrandDeleteStillExistsServer(t, http.StatusNoContent)

out, _, err := runBrandCmd(t, srv, "brand", "delete", "BGJR2BA", "--confirm", "--wait", "--timeout", "0", "--plain")
out, _, err := runBrandCmd(t, srv, "brand", "delete", "BEXMPL6", "--confirm", "--wait", "--timeout", "0", "--plain")
if err == nil {
t.Fatal("want a timeout error")
}
Expand All @@ -153,8 +153,8 @@ func TestBrandDeleteWaitTimeoutKeepsReceiptHonest(t *testing.T) {
t.Fatalf("want a DELETE then at least one GET, got %v", *methods)
}
got := decodeStdout(t, out)
if got["id"] != "BGJR2BA" {
t.Errorf("stdout = %v, want id BGJR2BA", got)
if got["id"] != "BEXMPL6" {
t.Errorf("stdout = %v, want id BEXMPL6", got)
}
if got["deleted"] != false {
t.Errorf("stdout = %v, want deleted false — the timeout means completion was never confirmed, and exit 5 must not be paired with deleted:true", got)
Expand Down
2 changes: 1 addition & 1 deletion cmd/tendlc/brand_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var brandGetCmd = &cobra.Command{
Brands have two IDs: bandwidthId exists immediately, while brandId is assigned
by TCR and is null until registration completes. Either identifier works
here — pass whichever one you have.`,
Example: ` band tendlc brand get BGJR2BA --plain
Example: ` band tendlc brand get BEXMPL6 --plain
band tendlc brand get WET8JUY8H0 --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
4 changes: 2 additions & 2 deletions cmd/tendlc/brand_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ Unlike customer profiles, brands have no versioned snapshots and no
per-version fetch — this is the only history view for a brand.

Either the TCR brandId or the Bandwidth bandwidthId works as the positional.`,
Example: ` band tendlc brand history BGJR2BA --plain
band tendlc brand history BGJR2BA --all --plain`,
Example: ` band tendlc brand history BEXMPL6 --plain
band tendlc brand history BEXMPL6 --all --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if brandHistoryAll && cmd.Flags().Changed("offset") {
Expand Down
4 changes: 2 additions & 2 deletions cmd/tendlc/brand_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ endpoint returns 204 with no body — there is no ID or resource to poll, so
there is no --wait here. The brand's own status is the signal.

Requires --confirm.`,
Example: ` band tendlc brand reverify BGJR2BA --confirm --plain`,
Example: ` band tendlc brand reverify BEXMPL6 --confirm --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireConfirm(brandReverifyConfirm,
Expand Down Expand Up @@ -68,7 +68,7 @@ verification before the brand goes UNVERIFIED.

Re-sending an email is neither destructive nor billable, so unlike most
writes in this command set, this does not require --confirm.`,
Example: ` band tendlc brand resend-2fa BGJR2BA --plain`,
Example: ` band tendlc brand resend-2fa BEXMPL6 --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
svc, err := service(cmd)
Expand Down
24 changes: 12 additions & 12 deletions cmd/tendlc/brand_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func stubBrandIdentityServer(t *testing.T) (*httptest.Server, *[]string) {
// server, so this fails loudly if the confirm gate ever moves after the
// service/POST call.
func TestBrandReverifyWithoutConfirmMakesNoRequests(t *testing.T) {
_, _, err := runBrandCmd(t, nil, "brand", "reverify", "BGJR2BA")
_, _, err := runBrandCmd(t, nil, "brand", "reverify", "BEXMPL6")
if err == nil {
t.Fatal("want an error when --confirm is missing")
}
Expand All @@ -45,24 +45,24 @@ func TestBrandReverifyWithoutConfirmMakesNoRequests(t *testing.T) {
func TestBrandReverifyWithConfirmPostsToIdentityReverify(t *testing.T) {
srv, paths := stubBrandIdentityServer(t)

out, _, err := runBrandCmd(t, srv, "brand", "reverify", "BGJR2BA", "--confirm", "--plain")
out, _, err := runBrandCmd(t, srv, "brand", "reverify", "BEXMPL6", "--confirm", "--plain")
if err != nil {
t.Fatalf("brand reverify --confirm: %v", err)
}
if len(*paths) != 1 || !strings.HasSuffix((*paths)[0], "/brands/BGJR2BA/identity/reverify") {
if len(*paths) != 1 || !strings.HasSuffix((*paths)[0], "/brands/BEXMPL6/identity/reverify") {
t.Fatalf("paths = %v, want exactly one POST to .../identity/reverify", *paths)
}
got := decodeStdout(t, out)
if got["id"] != "BGJR2BA" {
t.Errorf("stdout = %v, want id BGJR2BA", got)
if got["id"] != "BEXMPL6" {
t.Errorf("stdout = %v, want id BEXMPL6", got)
}
if got["reverificationRequested"] != true {
t.Errorf("stdout = %v, want reverificationRequested true", got)
}
if got["status"] != "accepted" {
t.Errorf("stdout = %v, want status accepted", got)
}
if got["check"] != "band tendlc brand get BGJR2BA" {
if got["check"] != "band tendlc brand get BEXMPL6" {
t.Errorf("stdout = %v, want check pointing at brand get", got)
}
}
Expand All @@ -73,16 +73,16 @@ func TestBrandReverifyWithConfirmPostsToIdentityReverify(t *testing.T) {
func TestBrandResend2FANeedsNoConfirm(t *testing.T) {
srv, paths := stubBrandIdentityServer(t)

out, _, err := runBrandCmd(t, srv, "brand", "resend-2fa", "BGJR2BA", "--plain")
out, _, err := runBrandCmd(t, srv, "brand", "resend-2fa", "BEXMPL6", "--plain")
if err != nil {
t.Fatalf("brand resend-2fa: %v", err)
}
if len(*paths) != 1 || !strings.HasSuffix((*paths)[0], "/brands/BGJR2BA/identity/resend2faEmail") {
if len(*paths) != 1 || !strings.HasSuffix((*paths)[0], "/brands/BEXMPL6/identity/resend2faEmail") {
t.Fatalf("paths = %v, want exactly one POST to .../identity/resend2faEmail", *paths)
}
got := decodeStdout(t, out)
if got["id"] != "BGJR2BA" {
t.Errorf("stdout = %v, want id BGJR2BA", got)
if got["id"] != "BEXMPL6" {
t.Errorf("stdout = %v, want id BEXMPL6", got)
}
if got["emailResent"] != true {
t.Errorf("stdout = %v, want emailResent true", got)
Expand Down Expand Up @@ -119,8 +119,8 @@ func TestBrandIdentity403MapsToExitFour(t *testing.T) {
name string
args []string
}{
{"reverify", []string{"brand", "reverify", "BGJR2BA", "--confirm"}},
{"resend-2fa", []string{"brand", "resend-2fa", "BGJR2BA"}},
{"reverify", []string{"brand", "reverify", "BEXMPL6", "--confirm"}},
{"resend-2fa", []string{"brand", "resend-2fa", "BEXMPL6"}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
Expand Down
12 changes: 6 additions & 6 deletions cmd/tendlc/brand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func newBrandStub(t *testing.T, handler http.HandlerFunc) *httptest.Server {
// page. Good enough for tests that just need brand list to succeed.
func stubBrandList(t *testing.T) *httptest.Server {
return newBrandStub(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"bandwidthId":"WET8JUY8H0","brandId":"BGJR2BA"}],` +
_, _ = w.Write([]byte(`{"data":[{"bandwidthId":"WET8JUY8H0","brandId":"BEXMPL6"}],` +
`"page":{"pageNumber":0,"pageSize":50,"totalElements":1,"totalPages":1}}`))
})
}
Expand Down Expand Up @@ -106,7 +106,7 @@ func stubBrandGetCapturing(t *testing.T) (*httptest.Server, *[]string) {
var paths []string
srv := newBrandStub(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
_, _ = w.Write([]byte(`{"data":{"bandwidthId":"WET8JUY8H0","brandId":"BGJR2BA"}}`))
_, _ = w.Write([]byte(`{"data":{"bandwidthId":"WET8JUY8H0","brandId":"BEXMPL6"}}`))
})
return srv, &paths
}
Expand Down Expand Up @@ -329,12 +329,12 @@ func TestBrandListAllWalksEveryPage(t *testing.T) {

func TestBrandGetAcceptsEitherIdentifier(t *testing.T) {
srv, paths := stubBrandGetCapturing(t)
for _, id := range []string{"BGJR2BA", "WET8JUY8H0"} {
for _, id := range []string{"BEXMPL6", "WET8JUY8H0"} {
if _, _, err := runBrandCmd(t, srv, "brand", "get", id); err != nil {
t.Fatalf("brand get %s: %v", id, err)
}
}
if len(*paths) != 2 || !strings.HasSuffix((*paths)[0], "/brands/BGJR2BA") || !strings.HasSuffix((*paths)[1], "/brands/WET8JUY8H0") {
if len(*paths) != 2 || !strings.HasSuffix((*paths)[0], "/brands/BEXMPL6") || !strings.HasSuffix((*paths)[1], "/brands/WET8JUY8H0") {
t.Errorf("paths = %v; get must pass the ID through unchanged", *paths)
}
}
Expand All @@ -361,7 +361,7 @@ func TestBrandCommandsRejectStrayPositionals(t *testing.T) {
}

func TestBrandHistoryReturnsMessageLog(t *testing.T) {
out, _, err := runBrandCmd(t, stubBrandHistory(t), "brand", "history", "BGJR2BA")
out, _, err := runBrandCmd(t, stubBrandHistory(t), "brand", "history", "BEXMPL6")
if err != nil {
t.Fatalf("brand history: %v", err)
}
Expand All @@ -373,7 +373,7 @@ func TestBrandHistoryReturnsMessageLog(t *testing.T) {
// TestBrandHistoryAllWalksEveryPage is TestBrandListAllWalksEveryPage's twin
// for `brand history --all`.
func TestBrandHistoryAllWalksEveryPage(t *testing.T) {
out, errOut, err := runBrandCmd(t, stubBrandHistoryTwoPages(t), "brand", "history", "BGJR2BA",
out, errOut, err := runBrandCmd(t, stubBrandHistoryTwoPages(t), "brand", "history", "BEXMPL6",
"--all", "--limit", "1", "--plain")
if err != nil {
t.Fatalf("brand history --all: %v", err)
Expand Down
4 changes: 2 additions & 2 deletions cmd/tendlc/brand_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ responds — and is rejected outright if the brand has an active campaign or
an active Standard/Enhanced/Political vetting). Changing mobile-phone sets
identity status to UNVERIFIED. Changing business-contact-email on a
PUBLIC_PROFIT brand revokes Auth+ compliance.`,
Example: ` band tendlc brand update BGJR2BA --website "https://acme.example" --plain
band tendlc brand update BGJR2BA --company-name "Acme Corp 2" --confirm --plain`,
Example: ` band tendlc brand update BEXMPL6 --website "https://acme.example" --plain
band tendlc brand update BEXMPL6 --company-name "Acme Corp 2" --confirm --plain`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
changed := map[string]bool{}
Expand Down
Loading
Loading