diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff759f..ecaf935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.3] + +### Added + +- `sourceant stop` shuts down the local stack without removing its data. +- Core and agent releases can be selected independently during setup. + +### Fixed + +- Installation finds a release when only prereleases are available. + +### Compatibility + +- `sourceant stop` requires agent `1.0.0-beta.3` or later with shutdown support. +- Core compatibility is determined by the installed agent. + ## [1.0.0-beta.2] - 2026-08-30 First release, versioned alongside the core it reads. diff --git a/README.md b/README.md index 1d6307d..6ecfaf9 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,23 @@ sourceant setup `setup` puts the agent and a core on this machine and writes down which one, so the agent knows what to start. -Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start. +`--runtime docker` pulls the published image. `--runtime python` builds a virtual environment and installs the core's published wheel. + +The core and agent versions are independent of the CLI version. By default, Docker uses the `latest` image, Python uses the latest core release, and the agent uses its latest release. Release lookup prefers a stable release and falls back to the newest published prerelease when no stable release exists. The CLI installer uses the same policy. + +Pin releases independently: + +```bash +sourceant setup --core-version 1.0.0-beta.2 --agent-version 1.0.0-beta.2 +``` + +`--core-version` selects a `v`-prefixed image tag for Docker or a release wheel for Python. Use `--image` for a custom container image or `--from` for a Python package specifier or checkout. Neither can be combined with `--core-version`. + +To pin the CLI installer itself: + +```bash +curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | SOURCEANT_VERSION=1.0.0-beta.2 sh +``` Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them. @@ -78,3 +94,13 @@ make build ## Licence MIT. + +## Versioning + +The CLI is versioned independently of the core, agent, and design package. A CLI release does not require matching releases of the components it installs. Use `--core-version` and `--agent-version` to select them separately, or `--image` and `--from` to choose the core directly. + +CLI `1.0.0-beta.3` requires agent `1.0.0-beta.3` for `sourceant stop`. The agent defines core compatibility. For a reproducible setup, select both explicitly: + +```bash +sourceant setup --agent-version 1.0.0-beta.3 --core-version 1.0.0-beta.2 +``` diff --git a/VERSION b/VERSION index 7e0b231..b0a2ffd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-beta.2 +1.0.0-beta.3 diff --git a/internal/command/setup.go b/internal/command/setup.go index e6b31ca..af09429 100644 --- a/internal/command/setup.go +++ b/internal/command/setup.go @@ -10,11 +10,13 @@ import ( func setupCommand() *cobra.Command { var ( - runtime string - image string - from string - noPull bool - noAgent bool + coreVersion string + agentVersion string + runtime string + image string + from string + noPull bool + noAgent bool ) command := &cobra.Command{ Use: "setup", @@ -35,7 +37,7 @@ func setupCommand() *cobra.Command { Image: image, From: from, Pull: !noPull, - Version: Version, + Version: coreVersion, Out: cmd.OutOrStdout(), }, install.Run) if err != nil { @@ -56,7 +58,7 @@ func setupCommand() *cobra.Command { _, _ = fmt.Fprintf(out, "Written to %s\n\n", path) if !noAgent { - agentPath, err := install.InstallAgent(Version, install.Get, out) + agentPath, err := install.InstallAgent(agentVersion, install.Get, out) if err != nil { return fmt.Errorf("the core is installed, but the agent is not: %w", err) } @@ -67,10 +69,14 @@ func setupCommand() *cobra.Command { return nil }, } + command.Flags().StringVar(&coreVersion, "core-version", "latest", "Core release to install, independently of the CLI") + command.Flags().StringVar(&agentVersion, "agent-version", "latest", "Agent release to install, independently of the CLI") command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python. Chosen for you when not named") - command.Flags().StringVar(&image, "image", install.DefaultImage, "The container to use, for the docker runtime") - command.Flags().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the wheel this version published") + command.Flags().StringVar(&image, "image", "", "The container to use, for the docker runtime") + command.Flags().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the selected core release wheel") command.Flags().BoolVar(&noPull, "no-pull", false, "Use an image already on this machine") command.Flags().BoolVar(&noAgent, "no-agent", false, "Leave the agent alone, install only the core") + command.MarkFlagsMutuallyExclusive("core-version", "image") + command.MarkFlagsMutuallyExclusive("core-version", "from") return command } diff --git a/internal/command/setup_test.go b/internal/command/setup_test.go new file mode 100644 index 0000000..130837a --- /dev/null +++ b/internal/command/setup_test.go @@ -0,0 +1,183 @@ +package command + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestSetupVersionsAreIndependent(t *testing.T) { + previous := Version + Version = "unrelated-cli-version" + t.Cleanup(func() { Version = previous }) + for _, tc := range []struct { + name string + args []string + want string + }{ + {"docker default", []string{"--runtime", "docker"}, "ghcr.io/sourceant/sourceant:latest"}, + {"docker pinned", []string{"--runtime", "docker", "--core-version", "1.0.0-beta.2"}, "ghcr.io/sourceant/sourceant:v1.0.0-beta.2"}, + {"docker custom", []string{"--runtime", "docker", "--image", "custom:local"}, "custom:local"}, + {"python default", []string{"--runtime", "python"}, "v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl"}, + {"python pinned", []string{"--runtime", "python", "--core-version", "v1.0.0-beta.2"}, "v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl"}, + {"python custom", []string{"--runtime", "python", "--from", "./local-core"}, "./local-core"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + t.Setenv("SOURCEANT_HOME", filepath.Join(home, "data")) + t.Setenv("CALLS", filepath.Join(home, "calls")) + bin := filepath.Join(home, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "docker": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CALLS\"\n", + "python3": "#!/bin/sh\nexit 0\n", + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(body), 0755); err != nil { + t.Fatal(err) + } + } + runtimeBin := filepath.Join(home, "runtime", "bin") + if err := os.MkdirAll(runtimeBin, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"pip", "sourceant"} { + if err := os.WriteFile(filepath.Join(runtimeBin, name), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CALLS\"\n"), 0755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/latest" { + t.Errorf("unexpected lookup: %s", r.URL) + http.NotFound(w, r) + return + } + data, err := os.ReadFile("testdata/releases/core.json") + if err != nil { + t.Error(err) + return + } + _, _ = w.Write(data) + })) + defer server.Close() + t.Setenv("SOURCEANT_CORE_API_BASE", server.URL) + var out, stderr bytes.Buffer + if code := Run(append([]string{"setup", "--no-agent"}, tc.args...), &out, &stderr); code != 0 { + t.Fatalf("%s", stderr.String()) + } + calls, err := os.ReadFile(filepath.Join(home, "calls")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(calls), tc.want) { + t.Fatalf("wanted %s in %s", tc.want, calls) + } + }) + } +} + +func TestSetupResolvesAgentIndependently(t *testing.T) { + previous := Version + Version = "unrelated-cli-version" + t.Cleanup(func() { Version = previous }) + for _, pinned := range []bool{false, true} { + t.Run(map[bool]string{false: "latest", true: "pinned"}[pinned], func(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + t.Setenv("SOURCEANT_HOME", filepath.Join(home, "data")) + if err := os.WriteFile(filepath.Join(home, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", home+string(os.PathListSeparator)+os.Getenv("PATH")) + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.RequestURI()) + if r.URL.RawQuery == "per_page=1" { + data, err := os.ReadFile("testdata/releases/agent.json") + if err != nil { + t.Error(err) + return + } + _, _ = w.Write(data) + return + } + http.NotFound(w, r) + })) + defer server.Close() + t.Setenv("SOURCEANT_API_BASE", server.URL) + t.Setenv("SOURCEANT_DOWNLOAD_BASE", server.URL) + args := []string{"setup", "--runtime", "docker"} + if pinned { + args = append(args, "--agent-version", "v1.0.0-beta.2") + } + var out, stderr bytes.Buffer + if Run(args, &out, &stderr) == 0 { + t.Fatal("download should fail") + } + if !strings.Contains(stderr.String(), "could not fetch the agent") { + t.Fatal(stderr.String()) + } + if !strings.Contains(paths[len(paths)-1], "/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-") { + t.Fatal(paths) + } + if pinned && len(paths) != 1 { + t.Fatal(paths) + } + if !pinned && (len(paths) != 3 || paths[0] != "/latest" || paths[1] != "/?per_page=1") { + t.Fatal(paths) + } + }) + } +} + +func TestInstallerResolvesPrerelease(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.RequestURI()) + if r.URL.RawQuery == "per_page=1" { + data, err := os.ReadFile("testdata/releases/cli.json") + if err != nil { + t.Error(err) + return + } + _, _ = w.Write(data) + return + } + http.NotFound(w, r) + })) + defer server.Close() + t.Setenv("SOURCEANT_VERSION", "latest") + t.Setenv("SOURCEANT_API_BASE", server.URL) + t.Setenv("SOURCEANT_DOWNLOAD_BASE", server.URL) + cmd := exec.Command("sh", "../../scripts/install.sh") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("download should fail") + } + if !strings.Contains(string(out), "could not download") { + t.Fatal(string(out)) + } + if len(paths) != 3 || paths[0] != "/latest" || paths[1] != "/?per_page=1" || !strings.Contains(paths[2], "/v1.0.0-beta.2/sourceant-1.0.0-beta.2-") { + t.Fatal(paths) + } +} + +func TestSetupRejectsConflictingCoreSelection(t *testing.T) { + for _, flag := range []string{"--image", "--from"} { + var out, stderr bytes.Buffer + if Run([]string{"setup", "--core-version", "1.0.0-beta.2", flag, "custom"}, &out, &stderr) == 0 { + t.Fatal("accepted conflicting selectors") + } + if !strings.Contains(stderr.String(), "none of the others can be") { + t.Fatal(stderr.String()) + } + } +} diff --git a/internal/command/testdata/releases/agent.json b/internal/command/testdata/releases/agent.json new file mode 100644 index 0000000..7dd32bc --- /dev/null +++ b/internal/command/testdata/releases/agent.json @@ -0,0 +1 @@ +[{"url":"https://api.github.com/repos/sourceant/agent/releases/380219177","assets_url":"https://api.github.com/repos/sourceant/agent/releases/380219177/assets","upload_url":"https://uploads.github.com/repos/sourceant/agent/releases/380219177/assets{?name,label}","html_url":"https://github.com/sourceant/agent/releases/tag/v1.0.0-beta.2","id":380219177,"author":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOUHdZq84Wqa8p","tag_name":"v1.0.0-beta.2","target_commitish":"main","name":"Release 1.0.0-beta.2","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-01T04:48:23Z","updated_at":"2026-09-01T04:49:12Z","published_at":"2026-09-01T04:49:11Z","assets":[{"url":"https://api.github.com/repos/sourceant/agent/releases/assets/538986109","id":538986109,"node_id":"RA_kwDOUHdZq84gIEZ9","name":"checksums.txt","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"text/plain","state":"uploaded","size":458,"digest":"sha256:ee0f014bd0439c6cc4163d4ffe7b2191923fb96e53216ba353c5990eb101e809","download_count":0,"created_at":"2026-09-01T04:49:11Z","updated_at":"2026-09-01T04:49:12Z","browser_download_url":"https://github.com/sourceant/agent/releases/download/v1.0.0-beta.2/checksums.txt"},{"url":"https://api.github.com/repos/sourceant/agent/releases/assets/538986113","id":538986113,"node_id":"RA_kwDOUHdZq84gIEaB","name":"sourceant-agent-1.0.0-beta.2-darwin-amd64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":3728414,"digest":"sha256:3588f909a890e5f72f60e1b6ce3d39ce57fd75554f36009123d087f73774fc5a","download_count":0,"created_at":"2026-09-01T04:49:11Z","updated_at":"2026-09-01T04:49:12Z","browser_download_url":"https://github.com/sourceant/agent/releases/download/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-darwin-amd64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/agent/releases/assets/538986114","id":538986114,"node_id":"RA_kwDOUHdZq84gIEaC","name":"sourceant-agent-1.0.0-beta.2-darwin-arm64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":3508015,"digest":"sha256:680ca8811a779f5ea385f3c05285c9143d2f42c0b051f31de707ca4de75456e6","download_count":0,"created_at":"2026-09-01T04:49:12Z","updated_at":"2026-09-01T04:49:12Z","browser_download_url":"https://github.com/sourceant/agent/releases/download/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-darwin-arm64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/agent/releases/assets/538986112","id":538986112,"node_id":"RA_kwDOUHdZq84gIEaA","name":"sourceant-agent-1.0.0-beta.2-linux-amd64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":3725036,"digest":"sha256:2744aaa3f1311e7ce05a2e7489a680c00e6165b3b61fc15ddd5300ae2f87f2e2","download_count":0,"created_at":"2026-09-01T04:49:11Z","updated_at":"2026-09-01T04:49:12Z","browser_download_url":"https://github.com/sourceant/agent/releases/download/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-linux-amd64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/agent/releases/assets/538986111","id":538986111,"node_id":"RA_kwDOUHdZq84gIEZ_","name":"sourceant-agent-1.0.0-beta.2-linux-arm64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":3403774,"digest":"sha256:0da608594367c0a3e160ebdc3c0a440d47a6945b919f8f2b5f7ff389cc146221","download_count":0,"created_at":"2026-09-01T04:49:11Z","updated_at":"2026-09-01T04:49:12Z","browser_download_url":"https://github.com/sourceant/agent/releases/download/v1.0.0-beta.2/sourceant-agent-1.0.0-beta.2-linux-arm64.tar.gz"}],"tarball_url":"https://api.github.com/repos/sourceant/agent/tarball/v1.0.0-beta.2","zipball_url":"https://api.github.com/repos/sourceant/agent/zipball/v1.0.0-beta.2","body":"## Release Notes\n\n\nFirst release, versioned alongside the core it supervises.\n\n### Added\n\n- Supervises the core: starts whatever was installed on a free port, waits for\n it to answer, and restarts it when it dies, backing off as failures repeat\n- Serves the local view from the binary itself, so it works with no network and\n cannot drift from the agent serving it: Overview, Graph, Knowledge, Reviews,\n Skills, Repositories and Settings\n- Asks for a review of a checkout and keeps the answer, so a review has an\n address that still opens an hour later\n- Proxies `/mcp` to the core, so a coding agent on this machine reaches the same\n index through the agent it already talks to\n- Reads and writes knowledge, skills, settings and repositories against the\n index, and browses the filesystem for the folder a person is adding\n\n## Installation\n\n1. Download the appropriate archive for your platform from the assets below\n2. Extract the binary:\n - **Linux/macOS:** `tar -xzf .tar.gz && chmod +x `\n - **Windows:** Extract the `.zip` file\n3. Move the binary to your PATH (e.g., `/usr/local/bin` on Linux/macOS)\n4. Verify the download using `checksums.txt`\n"}] \ No newline at end of file diff --git a/internal/command/testdata/releases/cli.json b/internal/command/testdata/releases/cli.json new file mode 100644 index 0000000..715201e --- /dev/null +++ b/internal/command/testdata/releases/cli.json @@ -0,0 +1 @@ +[{"url":"https://api.github.com/repos/sourceant/cli/releases/380219905","assets_url":"https://api.github.com/repos/sourceant/cli/releases/380219905/assets","upload_url":"https://uploads.github.com/repos/sourceant/cli/releases/380219905/assets{?name,label}","html_url":"https://github.com/sourceant/cli/releases/tag/v1.0.0-beta.2","id":380219905,"author":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"node_id":"RE_kwDOUHdYP84WqbIB","tag_name":"v1.0.0-beta.2","target_commitish":"main","name":"Release 1.0.0-beta.2","draft":false,"immutable":false,"prerelease":true,"created_at":"2026-09-01T04:50:36Z","updated_at":"2026-09-07T21:05:54Z","published_at":"2026-09-01T04:51:14Z","assets":[{"url":"https://api.github.com/repos/sourceant/cli/releases/assets/538987958","id":538987958,"node_id":"RA_kwDOUHdYP84gIE22","name":"checksums.txt","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"text/plain","state":"uploaded","size":434,"digest":"sha256:ba63f0000fa993a16c74f306e5f9db17356ccf0020c734e444cffade1c4b3b8a","download_count":0,"created_at":"2026-09-01T04:51:14Z","updated_at":"2026-09-01T04:51:14Z","browser_download_url":"https://github.com/sourceant/cli/releases/download/v1.0.0-beta.2/checksums.txt"},{"url":"https://api.github.com/repos/sourceant/cli/releases/assets/538987954","id":538987954,"node_id":"RA_kwDOUHdYP84gIE2y","name":"sourceant-1.0.0-beta.2-darwin-amd64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":2956812,"digest":"sha256:4b646d31c73b222dc7f7e01a5d32375d2f5191613f4d28d8949c3ef2e676a60a","download_count":0,"created_at":"2026-09-01T04:51:14Z","updated_at":"2026-09-01T04:51:14Z","browser_download_url":"https://github.com/sourceant/cli/releases/download/v1.0.0-beta.2/sourceant-1.0.0-beta.2-darwin-amd64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/cli/releases/assets/538987956","id":538987956,"node_id":"RA_kwDOUHdYP84gIE20","name":"sourceant-1.0.0-beta.2-darwin-arm64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":2718385,"digest":"sha256:146f9a9595a687453d06f5b55977f26b84eccb32d27146da3d1f447a075ccbae","download_count":0,"created_at":"2026-09-01T04:51:14Z","updated_at":"2026-09-01T04:51:15Z","browser_download_url":"https://github.com/sourceant/cli/releases/download/v1.0.0-beta.2/sourceant-1.0.0-beta.2-darwin-arm64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/cli/releases/assets/538987955","id":538987955,"node_id":"RA_kwDOUHdYP84gIE2z","name":"sourceant-1.0.0-beta.2-linux-amd64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":2962128,"digest":"sha256:72a0329defc0c6dc1fae7d85a5f233d40894bac3eb1e87b521b4229821ab4b10","download_count":0,"created_at":"2026-09-01T04:51:14Z","updated_at":"2026-09-01T04:51:15Z","browser_download_url":"https://github.com/sourceant/cli/releases/download/v1.0.0-beta.2/sourceant-1.0.0-beta.2-linux-amd64.tar.gz"},{"url":"https://api.github.com/repos/sourceant/cli/releases/assets/538987957","id":538987957,"node_id":"RA_kwDOUHdYP84gIE21","name":"sourceant-1.0.0-beta.2-linux-arm64.tar.gz","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/gzip","state":"uploaded","size":2643154,"digest":"sha256:fd09b020eb62edc50bf6ef60e26839ef40dc3aedfd92e561e155caf194b168d4","download_count":0,"created_at":"2026-09-01T04:51:14Z","updated_at":"2026-09-01T04:51:15Z","browser_download_url":"https://github.com/sourceant/cli/releases/download/v1.0.0-beta.2/sourceant-1.0.0-beta.2-linux-arm64.tar.gz"}],"tarball_url":"https://api.github.com/repos/sourceant/cli/tarball/v1.0.0-beta.2","zipball_url":"https://api.github.com/repos/sourceant/cli/zipball/v1.0.0-beta.2","body":"## Release Notes\r\n\r\n\r\nFirst release, versioned alongside the core it reads.\r\n\r\n### Added\r\n\r\n- `sourceant setup` puts the agent and a core on this machine and writes down\r\n which one, so the agent knows what to start. As a container today, or as a\r\n Python package once the core is published as one\r\n- An install script, so the command is the only thing anybody fetches by hand\r\n- `sourceant status` says whether the agent and the indexer are running\r\n- `sourceant repos` lists what is indexed here, and `sourceant graph` says what\r\n the indexer found in one of them\r\n- `sourceant ui` opens the graph in a browser, and says the agent is not running\r\n rather than opening a browser on an error page\r\n- `--json` on any command prints the agent's own answer, for anything that reads\r\n rather than looks\r\n- `SOURCEANT_AGENT_URL` names the agent to talk to, and `--agent` overrides it\r\n for one command\r\n\r\n## Installation\r\n\r\n1. Download the appropriate archive for your platform from the assets below\r\n2. Extract the binary:\r\n - **Linux/macOS:** `tar -xzf .tar.gz && chmod +x `\r\n - **Windows:** Extract the `.zip` file\r\n3. Move the binary to your PATH (e.g., `/usr/local/bin` on Linux/macOS)\r\n4. Verify the download using `checksums.txt`\r\n"}] \ No newline at end of file diff --git a/internal/command/testdata/releases/core.json b/internal/command/testdata/releases/core.json new file mode 100644 index 0000000..688bd3a --- /dev/null +++ b/internal/command/testdata/releases/core.json @@ -0,0 +1 @@ +{"url":"https://api.github.com/repos/sourceant/sourceant/releases/380211508","assets_url":"https://api.github.com/repos/sourceant/sourceant/releases/380211508/assets","upload_url":"https://uploads.github.com/repos/sourceant/sourceant/releases/380211508/assets{?name,label}","html_url":"https://github.com/sourceant/sourceant/releases/tag/v1.0.0-beta.2","id":380211508,"author":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"node_id":"RE_kwDONwNvGc4WqZE0","tag_name":"v1.0.0-beta.2","target_commitish":"main","name":"Release 1.0.0-beta.2","draft":false,"immutable":false,"prerelease":false,"created_at":"2026-09-01T04:24:33Z","updated_at":"2026-09-01T04:24:40Z","published_at":"2026-09-01T04:24:40Z","assets":[{"url":"https://api.github.com/repos/sourceant/sourceant/releases/assets/538953577","id":538953577,"node_id":"RA_kwDONwNvGc4gH8dp","name":"sourceant-1.0.0b2-py3-none-any.whl","label":"","uploader":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"content_type":"application/octet-stream","state":"uploaded","size":458739,"digest":"sha256:dafad7c2281a23650550f39403ef4848063b1ba6d615d9fb829b1672f6d13af9","download_count":1,"created_at":"2026-09-01T04:24:40Z","updated_at":"2026-09-01T04:24:40Z","browser_download_url":"https://github.com/sourceant/sourceant/releases/download/v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl"}],"tarball_url":"https://api.github.com/repos/sourceant/sourceant/tarball/v1.0.0-beta.2","zipball_url":"https://api.github.com/repos/sourceant/sourceant/zipball/v1.0.0-beta.2","body":"## Release Notes\n\n\n### Added\n\n- Reviewing a checkout on this machine, through the same reviewer a pull request\n goes through, with no forge and no pull request involved\n- A review is kept and answers to a name, so an agent can ask for one over MCP and\n hand somebody a link that still opens it an hour later\n- `review_working_tree` over MCP, and prompts a person can trigger rather than tools\n a model has to decide to call\n- What a review found can be kept between runs and recognised again when the code\n moves under it, so a finding dismissed once stays dismissed. Off unless asked for\n- Interfaces for what a deployment provides: which repositories a workspace covers,\n where its skills are kept, which model it bills, and who is asking. A hosted\n deployment answers from a database and a personal one from a disk\n- Knowledge can be read across every registered repository at once rather than one\n at a time\n\n### Changed\n\n- The MCP server is part of the core rather than beside it, and anything able to do\n something contributes the tools for it\n- A large change is read in parts sized to what a review can take in, rather than to\n what the model will accept. On a change of a hundred and thirteen files the whole\n diff yielded between nothing and ten findings and the parts yielded thirty four\n- The parts are read at the same time rather than one after another\n- The reviewer, the folders a workspace covers and the model it bills are resolved\n through the registry rather than by reaching for a class\n\n### Fixed\n\n- A working checkout is indexed as it is rather than as a commit, and reviews asked\n for it by commit. The code graph was never reachable from a review of a checkout\n- The claims a suggestion is checked against were optional in the schema a model\n answers, so it never sent them and nothing was ever checked\n- Names bound at the left margin were recorded by nothing, so a claim that a constant\n is undefined could not be contradicted by the file defining it\n- Skills are matched on letters rather than on A to Z, so a description written in\n any other language is no longer split at every accent\n- A page of findings is asked of the database rather than fetched whole and sliced\n- The local API is published on loopback rather than on every interface, and the MCP\n endpoint refuses a host it does not recognise\n\n\n---\n\n## Deployment\n\nInstall the CLI, and it brings down the rest:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | sh\nsourceant setup\nsourceant ui\n```\n\nSee the [README](README.md) for full setup instructions.\n\n\n## Documentation\n\nSee the [README](README.md) for usage instructions.\n\n## Full Changelog\n\nView the complete version history in [CHANGELOG.md](CHANGELOG.md).\n"} \ No newline at end of file diff --git a/internal/install/agent.go b/internal/install/agent.go index 53c19e9..e454157 100644 --- a/internal/install/agent.go +++ b/internal/install/agent.go @@ -4,6 +4,7 @@ import ( "archive/tar" "compress/gzip" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -72,27 +73,64 @@ func Get(url string) (io.ReadCloser, error) { } if response.StatusCode != http.StatusOK { response.Body.Close() - return nil, fmt.Errorf("%s answered %s", url, response.Status) + return nil, &httpError{url: url, status: response.StatusCode} } return response.Body, nil } -// LatestAgent asks which version to install when none was named. +type httpError struct { + url string + status int +} + +func (e *httpError) Error() string { + return fmt.Sprintf("%s answered HTTP %d", e.url, e.status) +} + func LatestAgent(get Fetcher) (string, error) { - body, err := get(apiBase() + "/latest") - if err != nil { - return "", err + return latestRelease(apiBase(), get) +} + +func LatestCore(get Fetcher) (string, error) { + base := os.Getenv("SOURCEANT_CORE_API_BASE") + if base == "" { + base = "https://api.github.com/repos/" + CoreRepo + "/releases" } - defer body.Close() - var release struct { + return latestRelease(base, get) +} + +func latestRelease(base string, get Fetcher) (string, error) { + type release struct { Tag string `json:"tag_name"` } - if err := json.NewDecoder(body).Decode(&release); err != nil { - return "", err + body, err := get(base + "/latest") + var selected release + if err != nil { + var status *httpError + if !errors.As(err, &status) || status.status != http.StatusNotFound { + return "", err + } + body, err = get(base + "?per_page=1") + if err != nil { + return "", err + } + defer func() { _ = body.Close() }() + var releases []release + if err := json.NewDecoder(body).Decode(&releases); err != nil { + return "", err + } + if len(releases) > 0 { + selected = releases[0] + } + } else { + defer func() { _ = body.Close() }() + if err := json.NewDecoder(body).Decode(&selected); err != nil { + return "", err + } } - tag := strings.TrimPrefix(release.Tag, "v") + tag := strings.TrimPrefix(selected.Tag, "v") if tag == "" { - return "", fmt.Errorf("%s names no latest release", AgentRepo) + return "", fmt.Errorf("%s names no published release", base) } return tag, nil } @@ -118,6 +156,8 @@ func InstallAgent(version string, get Fetcher, out io.Writer) (string, error) { } } + version = strings.TrimPrefix(version, "v") + say(out, "Fetching %s %s for %s.\n", AgentName, version, platform) body, err := get(AgentURL(version, platform)) if err != nil { diff --git a/internal/install/install.go b/internal/install/install.go index d32558d..ead793d 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -158,6 +158,9 @@ func installDocker(opts Options, run Runner) (Config, error) { image := opts.Image if image == "" { image = DefaultImage + if opts.Version != "" && opts.Version != "latest" { + image = "ghcr.io/sourceant/sourceant:v" + strings.TrimPrefix(opts.Version, "v") + } } if _, err := run("docker", "version", "--format", "{{.Server.Version}}"); err != nil { return Config{}, errors.New("docker is not running here. Start it, or install the python runtime instead") @@ -197,7 +200,15 @@ func installDocker(opts Options, run Runner) (Config, error) { func installPython(opts Options, run Runner) (Config, error) { from := opts.From if from == "" { - from = CoreWheel(opts.Version) + version := strings.TrimPrefix(opts.Version, "v") + if version == "" || version == "latest" { + var err error + version, err = LatestCore(Get) + if err != nil { + return Config{}, fmt.Errorf("could not tell which core to install: %w", err) + } + } + from = CoreWheel(version) } python, err := exec.LookPath("python3") if err != nil { diff --git a/scripts/install.sh b/scripts/install.sh index bd5ea72..0e38f1b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -39,10 +39,20 @@ resolve() { printf '%s' "${VERSION#v}" return fi - # The tag, read without jq so this works on a machine with nothing on it. - tag=$(curl -fsSL "$API_BASE/latest" | + status=$(curl -sSL -w '\n%{http_code}' "$API_BASE/latest") || + die "could not reach $API_BASE/latest" + code=$(printf '%s\n' "$status" | tail -1) + case "$code" in + 200) ;; + 404) + status=$(curl -fsSL "$API_BASE?per_page=1") || + die "could not fetch releases from $API_BASE" + ;; + *) die "release lookup returned HTTP $code from $API_BASE/latest" ;; + esac + tag=$(printf '%s\n' "$status" | sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p' | head -1) - [ -n "$tag" ] || die "could not tell which version is latest" + [ -n "$tag" ] || die "no published release found; set SOURCEANT_VERSION to a release version" printf '%s' "${tag#v}" }