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
7 changes: 1 addition & 6 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w
- -s -w -X github.com/xeaser/squad-opencode/internal/version.Version={{.Version}}{{ with index .Env "GITHUB_REPOSITORY" }} -X github.com/xeaser/squad-opencode/internal/version.Repo={{ . }}{{ end }}
goos:
- windows
- linux
Expand All @@ -34,8 +34,3 @@ archives:

checksum:
name_template: checksums.txt

release:
github:
owner: xeaser
name: squad-opencode
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Do not force-push to the default branch. Do not rewrite published tags.

4. The release workflow checks the tag is on `origin/main`, waits for `ci`, then GoReleaser publishes.

`go build` (and CI's compile job) report `dev`. GoReleaser injects the tag into `version.Version`, so `squad-oc version` on a release binary matches the tag. Do not edit `internal/version/version.go` to bump the version.

Do not tag a feature branch. Do not move or delete a published `v*` tag. After a squash-merge the feature-branch SHA is not on `main`; tag the squash commit.

## Signed commits
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Config-driven version, package, and release identity

> **Beads:** `squad-h7s.2`. Parent inbox `squad-h7s`. Broader CI overhaul stays `squad-h7s.4`.
>
> Approved 2026-08-18. Read this whole file before coding.

## Goal

Release identity (version, module, GitHub repo, binary name) comes from existing sources (`go.mod`, git tag via GoReleaser, goreleaser `project_name`), then is populated into the binary. Local `go build` does not lie about being a released version.

## Locked decisions

- **No new config format.** `go.mod` + git tag + `.goreleaser.yaml` `project_name` are enough.
- **`Version` is a `var` defaulting to `dev`.** GoReleaser injects the tag with `-ldflags -X`. Local and CI `go build` report `dev`.
- **`Module` is a `const` matching `go.mod`.** A test fails if they drift.
- **`Repo` defaults from `Module`:** `github.com/owner/name` → `owner/name`. Optional release `-X` of `GITHUB_REPOSITORY` when that env is set, so a fork’s published binary self-updates from the fork. Do not probe git remotes at runtime.
- **`Name` is a `const` `squad-oc` matching goreleaser `project_name`.** Self-update asset and binary names use `version.Name`.
- **Drop hardcoded `release.github.owner` / `name`.** GitHub Actions `GITHUB_REPOSITORY` is the source. Snapshot/local GoReleaser does not publish.
- **Go toolchain** already comes from `go.mod` (`go-version-file` in h7s.1). Do not retouch workflow Go pins.
- **CLI surface unchanged.** `squad-oc version` still prints one line; the value changes from a stale `0.4.0` to `dev` or the injected tag.
- **Docs:** CONTRIBUTING only (dev vs tagged release). No README/workshop change.

## Non-goals

- `.squad/` project config, MCP, marketplace, themes
- New identity/config file
- Runtime git-remote detection
- Rewriting GoReleaser archives
- actionlint, SHA-pin, OS test matrix (`squad-h7s.4`)
- A live `v*` tag in this change

## Architecture

```text
go.mod .goreleaser.yaml
module github.com/owner/name project_name: squad-oc
go 1.26.6 ldflags -X Version={{.Version}}
│ optional -X Repo=$GITHUB_REPOSITORY
▼ │
internal/version │
Module = module path │
Repo = owner/name (or -X) │
Name = squad-oc │
Version = "dev" or -X tag <───────────────┘

consumers: cli version / help, updatecheck, selfupdate (upgrade --self)
```

`GITHUB_REPOSITORY` is only applied when the env var is non-empty so an empty value cannot wipe the module-derived default.

## Files

| File | Change |
|------|--------|
| `internal/version/version.go` | `var Version`, `var Repo`, `const Module`, `const Name` |
| `internal/version/version_test.go` | Identity vs `go.mod` / goreleaser; default `dev`; ldflags smoke build |
| `.goreleaser.yaml` | ldflags `-X Version`; conditional `-X Repo`; drop hardcoded GitHub owner/name |
| `internal/selfupdate/selfupdate.go` | Asset/binary names use `version.Name` |
| `internal/selfupdate/selfupdate_test.go` | Use `version.Repo` instead of a literal |
| `CONTRIBUTING.md` | Local `dev` vs injected tag |

## Testing / verification

- `go test ./internal/version ./internal/selfupdate ./internal/updatecheck ./internal/cli`
- `go test ./...` and `go build -o squad-oc ./cmd/squad-oc` (`version` prints `dev`)
- Ldflags smoke: build with `-X ...Version=9.9.9` and `squad-oc version` prints `9.9.9`
- No live `v*` tag. Next real tag after merge is the live proof.

## Out of scope (already tracked)

| Topic | Bead |
|-------|------|
| Broader CI overhaul | `squad-h7s.4` |
| OpenCode v2 plugin spike | `squad-h7s.3` |
10 changes: 5 additions & 5 deletions internal/selfupdate/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var (
)

// AssetName is the goreleaser archive for goos/goarch at version.Version.
// Format: squad-oc_<version>_<os>_<arch>.zip (windows) or .tar.gz (else).
// Format: <Name>_<version>_<os>_<arch>.zip (windows) or .tar.gz (else).
func AssetName(goos, goarch string) string {
return assetName(version.Version, goos, goarch)
}
Expand All @@ -42,7 +42,7 @@ func assetName(ver, goos, goarch string) string {
if goos == "windows" {
ext = ".zip"
}
return fmt.Sprintf("squad-oc_%s_%s_%s%s", ver, goos, goarch, ext)
return fmt.Sprintf("%s_%s_%s_%s%s", version.Name, ver, goos, goarch, ext)
}

// ReplaceExecutable copies downloaded over current.
Expand Down Expand Up @@ -233,7 +233,7 @@ func extractZip(archivePath, destDir string) (string, error) {
rc.Close()
return dest, err
}
return "", fmt.Errorf("archive does not contain squad-oc")
return "", fmt.Errorf("archive does not contain %s", version.Name)
}

func extractTarGz(archivePath, destDir string) (string, error) {
Expand Down Expand Up @@ -268,12 +268,12 @@ func extractTarGz(archivePath, destDir string) (string, error) {
}
return dest, nil
}
return "", fmt.Errorf("archive does not contain squad-oc")
return "", fmt.Errorf("archive does not contain %s", version.Name)
}

func isReleaseBinary(name string) bool {
base := filepath.Base(filepath.ToSlash(name))
return base == "squad-oc" || base == "squad-oc.exe"
return base == version.Name || base == version.Name+".exe"
}

func writeNewFile(dest string, r io.Reader) error {
Expand Down
6 changes: 3 additions & 3 deletions internal/selfupdate/selfupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestReplaceExecutableRenameFailsLeavesNew(t *testing.T) {

func TestUpgradeSelfAlready(t *testing.T) {
srv := releaseServer(t, version.Version, nil)
msg, err := UpgradeSelf(srv.Client(), "xeaser/squad-opencode", version.Version)
msg, err := UpgradeSelf(srv.Client(), version.Repo, version.Version)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestUpgradeSelfRejectsArchiveWithoutBinary(t *testing.T) {
lookupExe = func() (string, error) { return dummy, nil }
t.Cleanup(func() { lookupExe = os.Executable })

_, err := UpgradeSelf(srv.Client(), "xeaser/squad-opencode", "0.2.1")
_, err := UpgradeSelf(srv.Client(), version.Repo, "0.2.1")
if err == nil {
t.Fatal("expected error for archive without squad-oc")
}
Expand All @@ -162,7 +162,7 @@ func runUpgradeSelf(t *testing.T, goos, goarch, binName string, archive, payload
lookupExe = func() (string, error) { return dummy, nil }
t.Cleanup(func() { lookupExe = os.Executable })

msg, err := UpgradeSelf(srv.Client(), "xeaser/squad-opencode", "0.2.1")
msg, err := UpgradeSelf(srv.Client(), version.Repo, "0.2.1")
if err != nil {
t.Fatal(err)
}
Expand Down
23 changes: 19 additions & 4 deletions internal/version/version.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package version

// Version is the squad-oc release string.
const Version = "0.4.0"
import "strings"

// Repo is the public GitHub repo used by update-check and upgrade --self.
const Repo = "xeaser/squad-opencode"
// Version is the squad-oc release string. Releases overwrite this via
// -ldflags -X. Local go build leaves the dev default.
var Version = "dev"

// Name is the binary and archive stem (goreleaser project_name).
const Name = "squad-oc"

// Module is the Go module path. Must match go.mod (enforced by test).
const Module = "github.com/xeaser/squad-opencode"

// Repo is owner/name used by update-check and upgrade --self.
// Default follows Module. Releases may overwrite via -ldflags -X with
// GITHUB_REPOSITORY so forks self-update from themselves.
var Repo = repoFromModule(Module)

func repoFromModule(module string) string {
return strings.TrimPrefix(module, "github.com/")
}
120 changes: 120 additions & 0 deletions internal/version/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package version

import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestVersionDefaultIsDev(t *testing.T) {
if Version != "dev" {
t.Fatalf("Version = %q, want %q", Version, "dev")
}
}

func TestIdentityMatchesSources(t *testing.T) {
mod := modulePath(t)
if Module != mod {
t.Fatalf("Module = %q, want go.mod %q", Module, mod)
}
wantRepo := strings.TrimPrefix(mod, "github.com/")
if Repo != wantRepo {
t.Fatalf("Repo = %q, want %q (from Module)", Repo, wantRepo)
}
name := goreleaserProjectName(t)
if Name != name {
t.Fatalf("Name = %q, want goreleaser project_name %q", Name, name)
}
}

func TestGoreleaserReleaseIdentity(t *testing.T) {
b, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml"))
if err != nil {
t.Fatal(err)
}
body := string(b)
wantX := "-X " + Module + "/internal/version.Version={{.Version}}"
if !strings.Contains(body, wantX) {
t.Fatalf("goreleaser ldflags missing %q", wantX)
}
if !strings.Contains(body, "GITHUB_REPOSITORY") || !strings.Contains(body, "/internal/version.Repo=") {
t.Fatal("goreleaser should -X Repo from GITHUB_REPOSITORY when set")
}
if strings.Contains(body, "owner: xeaser") || strings.Contains(body, "name: squad-opencode") {
t.Fatal("goreleaser must not hardcode GitHub owner/name; use GITHUB_REPOSITORY")
}
}

func TestVersionInjectedByLdflags(t *testing.T) {
root := repoRoot(t)
bin := filepath.Join(t.TempDir(), "squad-oc")
if runtime.GOOS == "windows" {
bin += ".exe"
}
ld := fmt.Sprintf("-X %s/internal/version.Version=9.9.9", Module)
cmd := exec.Command("go", "build", "-o", bin, "-ldflags", ld, "./cmd/squad-oc")
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
got, err := exec.Command(bin, "version").CombinedOutput()
if err != nil {
t.Fatalf("version: %v\n%s", err, got)
}
if strings.TrimSpace(string(got)) != "9.9.9" {
t.Fatalf("squad-oc version = %q, want 9.9.9", got)
}
}

func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// go test runs with cwd = this package directory.
return filepath.Clean(filepath.Join(dir, "..", ".."))
}

func modulePath(t *testing.T) string {
t.Helper()
f, err := os.Open(filepath.Join(repoRoot(t), "go.mod"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if rest, ok := strings.CutPrefix(line, "module "); ok {
return rest
}
}
if err := sc.Err(); err != nil {
t.Fatal(err)
}
t.Fatal("go.mod has no module line")
return ""
}

func goreleaserProjectName(t *testing.T) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml"))
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(line, "project_name:"); ok {
return strings.TrimSpace(rest)
}
}
t.Fatal(".goreleaser.yaml has no project_name")
return ""
}