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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.6.0"
".": "2.7.0"
}
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# Changelog

## [2.7.0](https://github.com/aaronflorey/bin/compare/v2.6.0...v2.7.0) (2026-09-30)


### Features

* add macos app signing ([cfd883f](https://github.com/aaronflorey/bin/commit/cfd883f59922946429c0c469f4f5c565b463e071))
* add read-only artifact inspection ([3240497](https://github.com/aaronflorey/bin/commit/3240497609397b4fe17562d888cad7c13df91214))
* add repository search and release browsing ([8ee779d](https://github.com/aaronflorey/bin/commit/8ee779d96dcff0ce1a419f35b9138b20708814a7))
* **assets:** expose platform-compatible installable assets ([1471130](https://github.com/aaronflorey/bin/commit/147113080a1b0b66dcfa0aba98ba2b71e8d7ec9c))
* cache github responses ([1c0a3cd](https://github.com/aaronflorey/bin/commit/1c0a3cd47112b76747fa2882cc9b3965506972b6))
* carry bundled completion artifacts ([501e28a](https://github.com/aaronflorey/bin/commit/501e28a02b97a942b5945a9735c5f66fb3775420))
* carry bundled completion artifacts ([4248d9b](https://github.com/aaronflorey/bin/commit/4248d9b2fd045da94e924fe35134db94c8fdbebc))
* **config:** default use_gh_for_github_token to true ([5da1914](https://github.com/aaronflorey/bin/commit/5da1914ab21c9ee3ad36c5b1c0929b89e2515216))
* harden binary payload validation and selection ([4499d08](https://github.com/aaronflorey/bin/commit/4499d087a579b86923afeb08ccadc3a8d59bc845))
* **install:** select compatible products and release lanes ([8322028](https://github.com/aaronflorey/bin/commit/8322028815fc2baa27a5dd462167dd2bada46ac6))
* integrate managed completion lifecycle ([6e12db6](https://github.com/aaronflorey/bin/commit/6e12db615ad2e52f91eb51c12ce531ec2082b84e))
* **logging:** expose GitHub auth/rate-limit state and archive contents in errors ([1a513e1](https://github.com/aaronflorey/bin/commit/1a513e19d4044907ca3aad77ac33089342d94101))
* persist stable selection intent ([3e5145e](https://github.com/aaronflorey/bin/commit/3e5145e856305068bd8b3bfadba20f2e28fd470d))
* prefer bundled managed completions ([2ea9ebb](https://github.com/aaronflorey/bin/commit/2ea9ebbf3e43ae7ae1746465d5abb83022fe31d3))
* prefer bundled managed completions ([d1e0ccc](https://github.com/aaronflorey/bin/commit/d1e0ccc5167b4451d1d927a39970929f4209f01a))
* sync managed binary completions ([2d898a1](https://github.com/aaronflorey/bin/commit/2d898a1930843d5f0c750ce60fd5aa9934940cf9))


### Bug Fixes

* address release binary compatibility audit findings ([e9e7725](https://github.com/aaronflorey/bin/commit/e9e77252b0b5f0f6bfa5e963e5f7d27400405a1e))
* **assets:** log gzip inner name during decompression ([4c78b14](https://github.com/aaronflorey/bin/commit/4c78b14f4b854c8f177f176ea83c9ecf424f5d36))
* **assets:** preserve archive install selection ([1173539](https://github.com/aaronflorey/bin/commit/1173539109cb9371383dc98a276e53b6a148119a))
* bind checksums to exact artifacts ([1d7ef29](https://github.com/aaronflorey/bin/commit/1d7ef2931c50e2b937b15d5f2fae904525ff948e))
* bound artifact processing inventory ([41d8419](https://github.com/aaronflorey/bin/commit/41d84191375653ee48ab6d8649befc941d794089))
* commit binary installs recoverably ([ad8d74b](https://github.com/aaronflorey/bin/commit/ad8d74b6dba21949fb57482c4db8bebed7388473))
* **config:** recreate cached default path if missing ([16a0c67](https://github.com/aaronflorey/bin/commit/16a0c67d3a6574de4001ad14e04eaddee47619fd))
* contain remote artifact names ([11327d2](https://github.com/aaronflorey/bin/commit/11327d26162ce05d249da83c33c90cf66f5429f7))
* improve archive member resolution ([1ae6e0d](https://github.com/aaronflorey/bin/commit/1ae6e0d31f24bb678d0a730e5dc6fed4718e7b40))
* **install:** preserve requested release lane for existing binaries ([2ecd586](https://github.com/aaronflorey/bin/commit/2ecd5862b52e95ced5be2fb069c26b98f5838e2c))
* preserve Go import path separators ([c3750de](https://github.com/aaronflorey/bin/commit/c3750dea9bf2809b82bcfa52e3933ac662b8e1b3))
* process generic URLs through artifact resolver ([a8d392b](https://github.com/aaronflorey/bin/commit/a8d392b0e9260a85b042c9a1d7db05712a5cd0b0))
* replace binaries atomically ([2ab8115](https://github.com/aaronflorey/bin/commit/2ab8115e385930c9a69cd77c8814309016dd69da))
* resolve archive members by identity ([91a8629](https://github.com/aaronflorey/bin/commit/91a8629745cff221ad8ea8d40d38760cfdae38d0))
* resolve managed Windows targets ([e4cb2c5](https://github.com/aaronflorey/bin/commit/e4cb2c556c267b51a31c1161fcfb8fa16e1d2745))
* resolve release products before packaging ([1fbd6e6](https://github.com/aaronflorey/bin/commit/1fbd6e67f059534950fa3686e618f7cd77c1db2d))
* select dmg bundles by managed identity ([5eb2fef](https://github.com/aaronflorey/bin/commit/5eb2fef4cfa04b020a6be00b5a7b85d7a35042e1))
* smoketests failing ([5341066](https://github.com/aaronflorey/bin/commit/534106675b7860a0b6bf8bbc770840a8ade39a88))
* verify artifact integrity across installs ([c0b307a](https://github.com/aaronflorey/bin/commit/c0b307a10a3bf5b71d542aeeb7ed490396160309))
* warn when bundled completions are skipped ([770f256](https://github.com/aaronflorey/bin/commit/770f2567764cb225b481f7998832d7a138caf5da))
* warn when bundled completions are skipped ([1954251](https://github.com/aaronflorey/bin/commit/1954251fa1470789bdd6a231641160badd963303))

## [2.6.0](https://github.com/aaronflorey/bin/compare/v2.5.4...v2.6.0) (2026-07-09)


Expand Down
8 changes: 7 additions & 1 deletion cmd/completion_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestCompletionNativeHelperProcess(_ *testing.T) {
if os.Getenv("BIN_COMPLETION_TEST_SECRET") != "" {
completionNativeHelperFailure("generator inherited secret environment variable")
}
if os.Getenv("HOME") != workingDirectory || os.Getenv("TMPDIR") != workingDirectory {
if !sameTestFilePath(os.Getenv("HOME"), workingDirectory) || !sameTestFilePath(os.Getenv("TMPDIR"), workingDirectory) {
completionNativeHelperFailure("generator environment does not use its temporary working directory")
}
var input [1]byte
Expand Down Expand Up @@ -93,6 +93,12 @@ func completionNativeHelperFailure(format string, args ...any) {
os.Exit(1)
}

func sameTestFilePath(left, right string) bool {
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)
}

func TestGenerateNativeCompletionUsesDirectBoundedExecution(t *testing.T) {
requirePOSIXShellFixture(t)

Expand Down
9 changes: 5 additions & 4 deletions cmd/completions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,15 @@ func TestCompletionSyncRejectsOwnershipConflictAndAllowsExplicitRenamedCommand(t
})

t.Run("renamed command requires explicit argv", func(t *testing.T) {
setupCompletionSyncBinary(t, "alias", "upstream", `printf '%s|%s' "$1" "$2"`, installModeBinary)
if _, err := runCompletionSync(t, "alias", "fish"); err == nil {
const command = "bin-test-renamed-command"
setupCompletionSyncBinary(t, command, "upstream", `printf '%s|%s' "$1" "$2"`, installModeBinary)
if _, err := runCompletionSync(t, command, "fish"); err == nil {
t.Fatal("sync generated a default completion for renamed command")
}
if _, err := runCompletionSync(t, "alias", "fish", "--", "custom", "fish"); err != nil {
if _, err := runCompletionSync(t, command, "fish", "--", "custom", "fish"); err != nil {
t.Fatalf("sync explicit argv for renamed command: %v", err)
}
assertCompletionContent(t, "fish", "alias", "custom|fish")
assertCompletionContent(t, "fish", command, "custom|fish")
})
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/ensure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func TestEnsureAppliesPersistedSelectionIntent(t *testing.T) {
t.Fatalf("ensure bundled completion request = (%q, %q)", opts.BundledCompletionShell, opts.BundledCompletionCommand)
}
return &providers.File{
Data: strings.NewReader("#!/bin/sh\nexit 0\n"),
Data: strings.NewReader(testRunnablePayloadString(t)),
Name: "missing-tool",
Version: "1.0.0",
BundledCompletion: []byte("bundled completion"),
Expand Down
2 changes: 1 addition & 1 deletion cmd/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestInstallCompletionFlag(t *testing.T) {

installProviderFactory = func(string, string) (providers.Provider, error) {
return testFetchProvider{file: &providers.File{
Data: strings.NewReader("#!/bin/sh\nexit 0\n"),
Data: strings.NewReader(testRunnablePayloadString(t)),
Name: "tool",
Version: "1.0.0",
}}, nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ func absExpandedPath(path string) (string, error) {
// must remain literal.
func expandTrackedBinaryPath(path string) string {
dir, name := filepath.Split(path)
return os.ExpandEnv(dir) + name
return filepath.Join(os.ExpandEnv(dir), name)
}

func ensureReleaseAge(providerID, version string, publishedAt *time.Time, minAgeDays int) error {
Expand Down
14 changes: 7 additions & 7 deletions cmd/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func TestInstallBinaryPreservesArchiveMemberResolutionOutcomes(t *testing.T) {
t.Fatal("interactive install unexpectedly requested non-interactive fetch")
}
return &providers.File{
Data: strings.NewReader("#!/bin/sh\nexit 0\n"),
Data: strings.NewReader(testRunnablePayloadString(t)),
Name: "tool",
Version: "1.0.0",
PackagePath: "two/tool",
Expand Down Expand Up @@ -177,7 +177,7 @@ func TestInstallBinaryPersistsResolvedSelectionIntent(t *testing.T) {
intent := &config.SelectionDescriptor{LogicalProduct: "tool-cli", Target: &config.SelectionTarget{OS: "linux", Architecture: "amd64", ABI: "musl"}, ArchiveMember: "bin/tool"}
installProviderFactory = func(string, string) (providers.Provider, error) {
return fetchBinaryTestProvider{id: "test", fetchFn: func(*providers.FetchOpts) (*providers.File, error) {
return &providers.File{Data: strings.NewReader("#!/bin/sh\nexit 0\n"), Name: "tool", Version: "2.0.0", ReleaseTagPrefix: "nightly-", SourceAsset: "tool-cli-v2.0.0-linux-amd64-musl.tar.gz", PackagePath: "tool-v2/bin/tool", SelectionIntent: intent}, nil
return &providers.File{Data: strings.NewReader(testRunnablePayloadString(t)), Name: "tool", Version: "2.0.0", ReleaseTagPrefix: "nightly-", SourceAsset: "tool-cli-v2.0.0-linux-amd64-musl.tar.gz", PackagePath: "tool-v2/bin/tool", SelectionIntent: intent}, nil
}}, nil
}

Expand Down Expand Up @@ -496,7 +496,7 @@ func TestExistingConfigBinaryMatchesExpandedPath(t *testing.T) {
func TestSaveToDiskValidatesExpectedSHA(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "tool")
payload := "#!/bin/sh\necho hello\n"
payload := testRunnablePayloadString(t)
sum := sha256.Sum256([]byte(payload))

_, err := saveToDisk(&providers.File{
Expand Down Expand Up @@ -564,7 +564,7 @@ func TestSaveToDiskOverwritePublishesRunnableBytesAndCleansStage(t *testing.T) {
if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil {
t.Fatal(err)
}
payload := "#!/bin/sh\necho updated\n"
payload := testRunnablePayloadString(t)

hash, err := saveToDisk(&providers.File{Data: strings.NewReader(payload), Name: "tool"}, target, true)
if err != nil {
Expand Down Expand Up @@ -598,7 +598,7 @@ func TestSaveToDiskConcurrentNonForcePublishesOneCandidate(t *testing.T) {
}
dir := t.TempDir()
target := filepath.Join(dir, "tool")
payloads := []string{"#!/bin/sh\necho first\n", "#!/bin/sh\necho second\n"}
payloads := []string{testRunnablePayloadString(t), testRunnablePayloadString(t)}
start := make(chan struct{})
staged := make(chan struct{}, len(payloads))
release := make(chan struct{})
Expand Down Expand Up @@ -1029,7 +1029,7 @@ func TestInstallBinaryKeepsProviderEnvironmentSyntaxLiteral(t *testing.T) {
return fetchBinaryTestProvider{
id: "test",
fetchFn: func(*providers.FetchOpts) (*providers.File, error) {
return &providers.File{Name: "$REMOTE_NAME", Version: "1.2.3", Data: strings.NewReader("#!/bin/sh\nexit 0\n")}, nil
return &providers.File{Name: "$REMOTE_NAME", Version: "1.2.3", Data: strings.NewReader(testRunnablePayloadString(t))}, nil
},
}, nil
}
Expand Down Expand Up @@ -1065,7 +1065,7 @@ func TestInstallBinaryExpandsDestinationWithoutPathResolution(t *testing.T) {
return fetchBinaryTestProvider{
id: "test",
fetchFn: func(*providers.FetchOpts) (*providers.File, error) {
return &providers.File{Name: "tool", Version: "1.2.3", Data: strings.NewReader("#!/bin/sh\nexit 0\n")}, nil
return &providers.File{Name: "tool", Version: "1.2.3", Data: strings.NewReader(testRunnablePayloadString(t))}, nil
},
}, nil
}
Expand Down
16 changes: 13 additions & 3 deletions cmd/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
Expand Down Expand Up @@ -243,7 +244,11 @@ func TestRemoveWarnsForUnmanagedBinary(t *testing.T) {
setupTestConfig(t)

toolDir := t.TempDir()
toolPath := filepath.Join(toolDir, "external-tool")
toolName := "external-tool"
if runtime.GOOS == "windows" {
toolName += ".exe"
}
toolPath := filepath.Join(toolDir, toolName)
if err := os.WriteFile(toolPath, []byte("binary"), 0o755); err != nil {
t.Fatalf("failed creating executable: %v", err)
}
Expand Down Expand Up @@ -467,10 +472,15 @@ func TestRemoveMissingExplicitPathCleansConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
argument, err = filepath.Rel(cwd, path)
if err != nil {
if err := os.Chdir(directory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(cwd); err != nil {
t.Errorf("restore cwd: %v", err)
}
})
argument = filepath.Base(path)
}
root := newRemoveCmd()
root.newProvider = func(string, string) (providers.Provider, error) { return removeTestProvider{}, nil }
Expand Down
2 changes: 1 addition & 1 deletion cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type runTestProvider struct {
err error
}

const runnableRunScript = "#!/bin/sh\nexit 0\n"
var runnableRunScript = string(sharedRunnableTestPayload)

func (p *runTestProvider) Fetch(opts *providers.FetchOpts) (*providers.File, error) {
p.fetchCount++
Expand Down
34 changes: 34 additions & 0 deletions cmd/runnable_fixture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cmd

import (
"os"
"runtime"
"testing"
)

var sharedRunnableTestPayload = loadRunnableTestPayload()

func loadRunnableTestPayload() []byte {
if runtime.GOOS == "windows" {
path, err := os.Executable()
if err != nil {
panic(err)
}
payload, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return payload
}
return []byte("#!/bin/sh\nexit 0\n")
}

func testRunnablePayload(t testing.TB) []byte {
t.Helper()
return append([]byte(nil), sharedRunnableTestPayload...)
}

func testRunnablePayloadString(t testing.TB) string {
t.Helper()
return string(testRunnablePayload(t))
}
39 changes: 25 additions & 14 deletions cmd/system_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,14 @@ func resolveDMGSourceExecutable(mountPoint, bundlePath string) (string, error) {
if !pathWithin(canonicalBundle, canonicalExecutable) {
return "", fmt.Errorf("app bundle %q executable escapes bundle", filepath.Base(bundlePath))
}
if err := assets.ValidateRunnablePayload(executable, filepath.Base(executable)); err != nil {
if err := assets.ValidateRunnablePayload(canonicalExecutable, filepath.Base(canonicalExecutable)); err != nil {
return "", fmt.Errorf("app bundle %q contains an invalid executable: %w", filepath.Base(bundlePath), err)
}
return executable, nil
relativeExecutable, err := filepath.Rel(canonicalBundle, canonicalExecutable)
if err != nil {
return "", fmt.Errorf("resolve app executable path: %w", err)
}
return filepath.Join(bundlePath, relativeExecutable), nil
}

func offerToSignUnsignedApp(appPath string, nonInteractive bool) error {
Expand Down Expand Up @@ -364,12 +368,13 @@ func eligibleDMGAppBundles(root string) ([]dmgAppBundleCandidate, error) {
return nil, fmt.Errorf("read dmg mount root: %w", err)
}

byIdentity := make(map[string]dmgAppBundleCandidate)
candidates := make([]dmgAppBundleCandidate, 0, len(entries))
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".app") {
continue
}
candidatePath, err := filepath.EvalSymlinks(filepath.Join(canonicalRoot, entry.Name()))
sourcePath := filepath.Join(root, entry.Name())
candidatePath, err := filepath.EvalSymlinks(sourcePath)
if err != nil {
return nil, fmt.Errorf("resolve dmg app bundle %q: %w", entry.Name(), err)
}
Expand All @@ -380,23 +385,29 @@ func eligibleDMGAppBundles(root string) ([]dmgAppBundleCandidate, error) {
if err != nil {
return nil, err
}
candidate := dmgAppBundleCandidate{path: candidatePath, name: entry.Name(), identity: identity}
if existing, ok := byIdentity[identity]; ok {
names := []string{existing.name, candidate.name}
sort.Strings(names)
return nil, fmt.Errorf("dmg contained colliding app bundle identities (%s)", strings.Join(names, ", "))
}
byIdentity[identity] = candidate
candidates = append(candidates, dmgAppBundleCandidate{path: sourcePath, name: entry.Name(), identity: identity})
}

candidates := make([]dmgAppBundleCandidate, 0, len(byIdentity))
for _, candidate := range byIdentity {
candidates = append(candidates, candidate)
if err := validateUniqueDMGAppBundleIdentities(candidates); err != nil {
return nil, err
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].name < candidates[j].name })
return candidates, nil
}

func validateUniqueDMGAppBundleIdentities(candidates []dmgAppBundleCandidate) error {
byIdentity := make(map[string]string, len(candidates))
for _, candidate := range candidates {
if existing, ok := byIdentity[candidate.identity]; ok {
names := []string{existing, candidate.name}
sort.Strings(names)
return fmt.Errorf("dmg contained colliding app bundle identities (%s)", strings.Join(names, ", "))
}
byIdentity[candidate.identity] = candidate.name
}
return nil
}

func firstDMGAppIdentity(requestedIdentity, storedIdentity, legacyName string) (string, string, error) {
for _, selection := range []struct {
value string
Expand Down
Loading
Loading