From c602469bf9234f098d0a39693d82fb3e1ed8d0fe Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 9 Oct 2026 12:19:21 +0800 Subject: [PATCH] feat(release): rename future assets to jevwise Use `jevwise_` for future releases while preserving published v1.0.0, v1.1.0 and v1.2.0 assets without overwriting them. Prefer the exact primary updater asset and use `jev_` only when it is absent, preserving validation, atomic replacement, the installed basename, and no alias or deletion. Document manual migration for old clients and cover publisher and downloader compatibility paths. --- README.md | 13 +++-- docs/internal/cli-rename.md | 5 +- internal/cli/update.go | 6 +- internal/cli/update_test.go | 2 +- internal/update/download.go | 18 +++++- internal/update/download_test.go | 88 ++++++++++++++++++++++++++++- internal/update/integration_test.go | 4 +- internal/update/update.go | 8 +-- scripts/release_assets.py | 9 ++- scripts/test_release_assets.py | 20 ++++++- 10 files changed, 148 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 75140db..9333500 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/internal/cli-rename.md b/docs/internal/cli-rename.md index d40b855..7343210 100644 --- a/docs/internal/cli-rename.md +++ b/docs/internal/cli-rename.md @@ -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. @@ -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. diff --git a/internal/cli/update.go b/internal/cli/update.go index 998e3b7..57cefaf 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -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") @@ -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} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index e73f0cf..5deb46b 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -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") { diff --git a/internal/update/download.go b/internal/update/download.go index 35a74c4..2a4df24 100644 --- a/internal/update/download.go +++ b/internal/update/download.go @@ -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. @@ -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) @@ -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 diff --git a/internal/update/download_test.go b/internal/update/download_test.go index e503bd5..7e74968 100644 --- a/internal/update/download_test.go +++ b/internal/update/download_test.go @@ -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" } @@ -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") != "" { @@ -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"}, @@ -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) + } + }) + } +} diff --git a/internal/update/integration_test.go b/internal/update/integration_test.go index f25f653..9ad6b82 100644 --- a/internal/update/integration_test.go +++ b/internal/update/integration_test.go @@ -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) @@ -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) diff --git a/internal/update/update.go b/internal/update/update.go index 2412b1a..6913a00 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 ( @@ -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 { @@ -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. @@ -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) diff --git a/scripts/release_assets.py b/scripts/release_assets.py index a19197c..087dd37 100644 --- a/scripts/release_assets.py +++ b/scripts/release_assets.py @@ -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] @@ -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: diff --git a/scripts/test_release_assets.py b/scripts/test_release_assets.py index b071063..dac875c 100644 --- a/scripts/test_release_assets.py +++ b/scripts/test_release_assets.py @@ -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"]) @@ -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") @@ -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: