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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ The release-assets workflow attaches raw CLI binaries and `SHA256SUMS` to an exi

| Platform | Architectures | Asset pattern |
| --- | --- | --- |
| Linux | amd64, arm64 | `jev_vX.Y.Z_linux_ARCH` |
| macOS | amd64, arm64 | `jev_vX.Y.Z_darwin_ARCH` |
| Windows | amd64, arm64 | `jev_vX.Y.Z_windows_ARCH.exe` |

- Download the exact OS/architecture asset. Unix users must make a downloaded binary executable; Windows users run the `.exe` directly.
| Linux | amd64, arm64 | `jevwise_vX.Y.Z_linux_ARCH` |
| macOS | amd64, arm64 | `jevwise_vX.Y.Z_darwin_ARCH` |
| Windows | amd64, arm64 | `jevwise_vX.Y.Z_windows_ARCH.exe` |

- Future releases use the patterns above; existing v1.0.0, v1.1.0 and v1.2.0 keep their legacy `jev_...` names and are not rewritten.
- Download the exact OS/architecture asset. Install it as `jevwise` (`jevwise.exe` on Windows); Unix users must make it executable. Verify `jevwise version` before use.
- For the first renamed release, install manually: older `jev` clients cannot find `jevwise_...` assets. Confirm binary ownership before removing an old copy; never remove another project's `jev`. Regenerate shell completions with `jevwise completion SHELL` and review saved scripts or agent commands.
- The new updater prefers `jevwise_...`, using `jev_...` only when the new exact platform name is absent from release metadata, never after a verification/download failure. It replaces its current executable path without renaming that path or installing a legacy alias.
- `SHA256SUMS` hashes the exact raw binary bytes. Verify the hash before use. Checksums detect corruption, not a compromised publisher; trust remains the repository and GitHub/TLS.
- The internal update downloader verifies exact platform asset names, metadata sizes and SHA-256 before returning bytes. Binary/manifest limits are 64 MiB/64 KiB; downloads use fixed release URLs and bounded HTTPS redirects to allowlisted GitHub hosts, without credentials or cookies.
- Release/Make builds embed a passive update stamp that survives stripping and path trimming. Version guards inspect Go module/platform metadata, recheck the candidate checksum, and reject equal/older or mismatched on-disk versions without executing binaries. Stamps are self-declared metadata, not signatures.
Expand Down
5 changes: 4 additions & 1 deletion docs/internal/cli-rename.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Plan and scope

- [x] 25 - Rename command/help/version output, default Make and native-CI filenames, actionable MCP hints and public command examples. Test name/help/completion/version and Make output. Keep release tooling's native-version check compatible with existing tags. Parent: `cebb336`.
- [ ] 26 - Rename future release assets to `jevwise_...`; teach the updater to prefer the new exact name and use legacy names only when the new name is absent. Preserve size/checksum/redirect/version guards. Add offline publisher/downloader regression tests and migration notes. Depends on 25.
- [x] 26 - Rename future release assets to `jevwise_...`; teach the updater to prefer the new exact name and use legacy names only when the new name is absent. Preserve size/checksum/redirect/version guards. Add offline publisher/downloader regression tests and migration notes. Parent: `b683738` (25).
- Both scopes approved by the user. One PR at a time; feature branches stacked linearly on `virtual-main`, worktrees under `.agent/worktrees`; soft 300 / hard 450 aggregate changed lines including docs/tests.
- Keep SDK import paths, `cmd` entry-point path, Jev provider/model/hidden skill flag, `TS_JEV_*` / `TYPESAFE_*`, `ts-jev` config path, runtime state and update stamps unchanged. No `jev` alias, user-binary deletion, release/tag/visibility changes or Windows dependency upgrade.

Expand All @@ -15,3 +15,6 @@
- Require all five exact-head CI successes before an approved merge, then verify merged tree/main/virtual-main and rerun checks. Preserve private spec and user artifacts; remove only merged task-owned worktrees/branches/temp files.

- 25 local QA passed: root fresh normal/race/tidy/vet/build/format/diff, 11 Python fixtures and six Make builds; independent full tests plus focused race checks repeated three times. Command-only smoke is credential-free. CI/publication/merge pending.

- 26 local QA passed: root fresh normal/race/tidy/vet/build/format/diff, 11 offline Python fixtures, six Make builds and credential-free native smoke; independent full tests, updater race repeated five times and final updater/CLI race repeated three times. Documentation structure, links and shell syntax passed.
- Publisher integration also built six real binaries and a matching manifest from clean 25 source using synthetic version metadata, without tags, releases or uploads. Legacy-only manifests and primary failure/no-downgrade paths are covered; CI/publication/merge pending.
6 changes: 3 additions & 3 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func updateFailure(installed bool, message string, cause error) error {

func newUpdate(ops updateOps, current string) *cobra.Command {
var check bool
cmd := &cobra.Command{Use: "update", Short: "Install or check the latest stable Jev release",
cmd := &cobra.Command{Use: "update", Short: "Install or check the latest stable Jevwise release",
Args: func(_ *cobra.Command, args []string) error {
if len(args) != 0 {
return errors.New("update does not accept positional arguments")
Expand Down Expand Up @@ -72,9 +72,9 @@ func newUpdate(ops updateOps, current string) *cobra.Command {
var output string
installed := false
if errors.Is(err, update.ErrNoRelease) {
output = "No stable Jev release is published yet.\n"
output = "No stable Jevwise release is published yet.\n"
} else if err != nil {
return &decisionError{"cannot check Jev releases", err}
return &decisionError{"cannot check Jevwise releases", err}
} else {
if _, err := update.Compare(release.Tag, release.Tag); err != nil {
return &decisionError{"invalid latest release version", err}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func TestUpdateNoReleaseAndSafeErrors(t *testing.T) {
cmd := updateRoot(func(context.Context) (update.Release, error) { return update.Release{}, lookupError }, "dev")
out, _, err := execute(cmd, "update", "--check")
if errors.Is(lookupError, update.ErrNoRelease) {
if err != nil || out != "No stable Jev release is published yet.\n" {
if err != nil || out != "No stable Jevwise release is published yet.\n" {
t.Fatal("missing release not informational", err)
}
} else if !errors.Is(err, cause) || out != "" || strings.Contains(err.Error(), "private-lookup-content") {
Expand Down
18 changes: 15 additions & 3 deletions internal/update/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type Binary struct {

type downloadError struct{ cause error }

func (e *downloadError) Error() string { return "cannot download Jev release" }
func (e *downloadError) Error() string { return "cannot download Jevwise release" }
func (e *downloadError) Unwrap() error { return e.cause }

// Download verifies one exact platform artifact against the release's SHA256SUMS.
Expand All @@ -49,10 +49,22 @@ func (c *Client) Download(ctx context.Context, release Release, goos, goarch str
if (goos != "linux" && goos != "darwin" && goos != "windows") || (goarch != "amd64" && goarch != "arm64") {
return fail(errors.New("unsupported release platform"))
}
name := "jev_" + release.Tag + "_" + goos + "_" + goarch
name := "jevwise_" + release.Tag + "_" + goos + "_" + goarch
if goos == "windows" {
name += ".exe"
}
// Metadata presence selects the new name, even when that asset is invalid.
// Legacy names are only a compatibility path for releases without it.
primaryPresent := false
for _, asset := range release.Assets {
if asset.Name == name {
primaryPresent = true
break
}
}
if !primaryPresent {
name = "jev_" + strings.TrimPrefix(name, "jevwise_")
}
sizes := make(map[string]int64, 2)
for _, asset := range release.Assets {
limit := int64(binaryLimit)
Expand Down Expand Up @@ -112,7 +124,7 @@ func (c *Client) readAsset(ctx context.Context, original string, size int64) ([]
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "jev-update")
req.Header.Set("User-Agent", "jevwise-update")
res, err := client.Do(req)
if err != nil {
return nil, err
Expand Down
88 changes: 86 additions & 2 deletions internal/update/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

func downloadFixture(goos, arch string) (Release, map[string]string) {
name := "jev_v1.2.3_" + goos + "_" + arch
name := "jevwise_v1.2.3_" + goos + "_" + arch
if goos == "windows" {
name += ".exe"
}
Expand All @@ -35,7 +35,7 @@ func TestDownloadPlatforms(t *testing.T) {
caller := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { t.Fatal("caller redirect policy used"); return nil }}
caller.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
if r.URL.String() != "https://github.com/bitbrew-dev/jevwise/releases/download/v1.2.3/"+name || content[name] == "" || r.Method != "GET" || r.Header.Get("User-Agent") == "" {
if r.URL.String() != "https://github.com/bitbrew-dev/jevwise/releases/download/v1.2.3/"+name || content[name] == "" || r.Method != "GET" || r.Header.Get("User-Agent") != "jevwise-update" {
t.Fatalf("wrong asset request: %v", r)
}
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
Expand Down Expand Up @@ -73,6 +73,8 @@ func TestDownloadPreflight(t *testing.T) {
}{
{"tag", func(r *Release) { r.Tag = "v01.2.3" }, "linux", "amd64"},
{"missing", func(r *Release) { r.Assets = r.Assets[:1] }, "linux", "amd64"},
{"wrong-prefix", func(r *Release) { r.Assets[0].Name = strings.Replace(r.Assets[0].Name, "jevwise_", "other_", 1) }, "linux", "amd64"},
{"wrong-platform", func(r *Release) { r.Assets[0].Name = strings.Replace(r.Assets[0].Name, "_linux_", "_darwin_", 1) }, "linux", "amd64"},
{"duplicate", func(r *Release) { r.Assets = append(r.Assets, r.Assets[0]) }, "linux", "amd64"},
{"duplicate-manifest", func(r *Release) { r.Assets = append(r.Assets, r.Assets[1]) }, "linux", "amd64"},
{"zero", func(r *Release) { r.Assets[0].Size = 0 }, "linux", "amd64"},
Expand Down Expand Up @@ -255,3 +257,85 @@ func TestDownloadCancellationAndTransport(t *testing.T) {
t.Fatal("nil client accepted")
}
}

func TestDownloadAssetNameCompatibilityAndNoDowngrade(t *testing.T) {
tests := []struct{ mode, os, arch string }{{"primary", "linux", "amd64"}}
for _, os := range []string{"linux", "darwin", "windows"} {
for _, arch := range []string{"amd64", "arm64"} {
tests = append(tests, struct{ mode, os, arch string }{"legacy", os, arch})
}
}
for _, mode := range []string{"zero", "negative", "oversize", "duplicate", "404", "checksum", "manifest", "missing-checksum"} {
tests = append(tests, struct{ mode, os, arch string }{mode, "linux", "amd64"})
}
for _, tc := range tests {
t.Run(tc.mode+"/"+tc.os+"/"+tc.arch, func(t *testing.T) {
release, content := downloadFixture(tc.os, tc.arch)
primary := release.Assets[0].Name
legacy := strings.Replace(primary, "jevwise_", "jev_", 1)
content[legacy] = "legacy binary"
legacyRow := fmt.Sprintf("%x %s\n", sha256.Sum256([]byte(content[legacy])), legacy)
content["SHA256SUMS"] += legacyRow
release.Assets = append(release.Assets, Asset{Name: legacy, Size: int64(len(content[legacy]))})
preflight := false
switch tc.mode {
case "legacy":
release.Assets = release.Assets[1:]
content["SHA256SUMS"] = legacyRow
case "zero", "negative", "oversize", "duplicate":
preflight = true
switch tc.mode {
case "zero":
release.Assets[0].Size = 0
case "negative":
release.Assets[0].Size = -1
case "oversize":
release.Assets[0].Size = binaryLimit + 1
case "duplicate":
release.Assets = append(release.Assets, release.Assets[0])
}
case "checksum":
content[primary] = "corrupt binary!"
case "manifest":
content["SHA256SUMS"] = "z" + content["SHA256SUMS"][1:]
case "missing-checksum":
content["SHA256SUMS"] = legacyRow
}
for i := range release.Assets {
if release.Assets[i].Name == "SHA256SUMS" {
release.Assets[i].Size = int64(len(content["SHA256SUMS"]))
}
}
var names []string
client := NewClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
names = append(names, name)
if preflight || (name == legacy && tc.mode != "legacy") {
t.Fatal("invalid primary triggered HTTP or legacy downgrade")
}
if content[name] == "" || r.Header.Get("User-Agent") != "jevwise-update" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
t.Fatal("unexpected asset request or credentials")
}
status := http.StatusOK
if tc.mode == "404" && name == primary {
status = http.StatusNotFound
}
return assetResponse(status, strings.NewReader(content[name]), nil), nil
})})
binary, err := client.Download(context.Background(), release, tc.os, tc.arch)
if tc.mode != "primary" && tc.mode != "legacy" {
if err == nil || binary != nil || (preflight && len(names) != 0) {
t.Fatal("primary failure accepted or retried", err)
}
return
}
chosen := primary
if tc.mode == "legacy" {
chosen = legacy
}
if err != nil || binary == nil || string(binary.data) != content[chosen] || len(names) != 2 || names[0] != "SHA256SUMS" || names[1] != chosen {
t.Fatal("wrong primary/legacy selection", names, err)
}
})
}
}
4 changes: 2 additions & 2 deletions internal/update/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestReplaceRefusesSameMetadataInodeExchange(t *testing.T) {

func TestLatestDownloadReplaceIntegration(t *testing.T) {
original, candidate := replacementFixture(t)
name := "jev_" + candidate.tag + "_" + runtime.GOOS + "_" + runtime.GOARCH
name := "jevwise_" + candidate.tag + "_" + runtime.GOOS + "_" + runtime.GOARCH
for _, malformed := range []bool{false, true} {
t.Run(fmt.Sprintf("malformed=%v", malformed), func(t *testing.T) {
directory, path := replacementTarget(t, original)
Expand All @@ -67,7 +67,7 @@ func TestLatestDownloadReplaceIntegration(t *testing.T) {
var bodies []*trackedBody
client := NewClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
index := len(bodies)
if index >= len(expected) || r.URL.String() != expected[index] {
if index >= len(expected) || r.URL.String() != expected[index] || r.Header.Get("User-Agent") != "jevwise-update" {
t.Fatalf("unexpected integration request %s", r.URL)
}
var reader io.Reader = bytes.NewReader(candidate.data)
Expand Down
8 changes: 4 additions & 4 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package update checks public Jev releases without SDK credentials.
// Package update checks public Jevwise releases without SDK credentials.
package update

import (
Expand All @@ -17,7 +17,7 @@ const latestURL = "https://api.github.com/repos/bitbrew-dev/jevwise/releases/lat
const metadataLimit = 1 << 20

// ErrNoRelease means GitHub has no published stable release.
var ErrNoRelease = errors.New("no published stable Jev release")
var ErrNoRelease = errors.New("no published stable Jevwise release")

// Asset describes an artifact without trusting its metadata download URL.
type Asset struct {
Expand All @@ -33,7 +33,7 @@ type Release struct {

type safeError struct{ cause error }

func (e *safeError) Error() string { return "cannot check Jev release" }
func (e *safeError) Error() string { return "cannot check Jevwise release" }
func (e *safeError) Unwrap() error { return e.cause }

// Client checks one fixed public repository using isolated HTTP settings.
Expand Down Expand Up @@ -76,7 +76,7 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2026-03-10")
req.Header.Set("User-Agent", "jev-update")
req.Header.Set("User-Agent", "jevwise-update")
res, err := c.httpClient.Do(req)
if err != nil {
return fail(err)
Expand Down
9 changes: 7 additions & 2 deletions scripts/release_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ def tag_valid(tag):
return tag


def command_name(tag):
return "jev" if tag_valid(tag) in LEGACY_TAGS else "jevwise"


def asset_names(tag):
return [f"jev_{tag_valid(tag)}_{system}_{arch}" + (".exe" if system == "windows" else "")
command = command_name(tag)
return [f"{command}_{tag}_{system}_{arch}" + (".exe" if system == "windows" else "")
for system, arch in PLATFORMS]


Expand Down Expand Up @@ -134,7 +139,7 @@ def build(metadata, source, directory):
run(["go", "build", "-mod=readonly", "-trimpath", "-ldflags", flags, "-o", str(directory / name), "./cmd"], cwd=source, env=env)
host = (run(["go", "env", "GOHOSTOS"]), run(["go", "env", "GOHOSTARCH"]))
native = directory / names[PLATFORMS.index(host)]
command = "jev" if metadata["tag"] in LEGACY_TAGS else "jevwise"
command = command_name(metadata["tag"])
if not run([str(native), "version"]).startswith(command + " " + metadata["tag"] + "\ncommit: " + metadata["commit"] + "\n"):
raise ValueError("linked release metadata mismatch")
with (directory / "SHA256SUMS").open("w", encoding="ascii", newline="\n") as manifest:
Expand Down
20 changes: 18 additions & 2 deletions scripts/test_release_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ def test_canonical_version_and_asset_contract(self):
release.tag_valid(tag)
names = release.asset_names(TAG)
self.assertEqual(len(set(names)), 6)
self.assertEqual(names[-2:], ["jev_v1.2.3_windows_amd64.exe", "jev_v1.2.3_windows_arm64.exe"])
self.assertEqual(names, [f"jevwise_{TAG}_{system}_{arch}" + (".exe" if system == "windows" else "")
for system, arch in release.PLATFORMS])
for tag in sorted(release.LEGACY_TAGS):
self.assertEqual(release.asset_names(tag), [f"jev_{tag}_{system}_{arch}" + (".exe" if system == "windows" else "")
for system, arch in release.PLATFORMS])
self.assertEqual(names[-2:], ["jevwise_v1.2.3_windows_amd64.exe", "jevwise_v1.2.3_windows_arm64.exe"])

@patch.dict(os.environ, {"GH_TOKEN": "fixture-token"})
@patch.object(release, "run", side_effect=["", "", COMMIT, "", "1791331200"])
Expand Down Expand Up @@ -190,7 +195,15 @@ def build_run(arguments, cwd=None, env=None):
self.assertEqual(sum(call.args[0][:2] == ["go", "build"] for call in run.call_args_list), 6)
manifest = (directory / "SHA256SUMS").read_text()
self.assertEqual(manifest, release.checksums(directory, release.asset_names(TAG)))
self.assertEqual(set(path.name for path in directory.iterdir()), set(release.asset_names(TAG)) | {"SHA256SUMS"})
self.assertEqual(manifest.splitlines(), sorted(manifest.splitlines(), key=lambda line: line.split(" ")[1]))
def wrong_identity(arguments, cwd=None, env=None):
if arguments[0].endswith("darwin_arm64"):
return f"jev {TAG}\ncommit: {COMMIT}\n"
return build_run(arguments, cwd, env)
with patch.object(release, "run", side_effect=wrong_identity), self.assertRaises(ValueError):
release.build(META, Path(temp), Path(temp) / "wrong-name")
self.assertFalse((Path(temp) / "wrong-name" / "SHA256SUMS").exists())
with patch.dict(os.environ, {"GH_TOKEN": "secret"}), patch.object(release, "run", side_effect=[COMMIT, ""]), self.assertRaises(ValueError):
release.build(META, Path(temp), Path(temp) / "blocked")

Expand All @@ -209,7 +222,10 @@ def build_run(arguments, cwd=None, env=None):
return ""
return f"jev {tag}\ncommit: {COMMIT}\n"
with patch.object(release, "run", side_effect=build_run):
release.build(dict(META, tag=tag), Path(temp), Path(temp) / "assets")
directory = Path(temp) / "assets"
release.build(dict(META, tag=tag), Path(temp), directory)
self.assertEqual(set(path.name for path in directory.iterdir()), set(release.asset_names(tag)) | {"SHA256SUMS"})
self.assertEqual((directory / "SHA256SUMS").read_text(), release.checksums(directory, release.asset_names(tag)))

def test_publication_preflight_identity_pagination_and_manifest_last(self):
with tempfile.TemporaryDirectory() as temp:
Expand Down
Loading