feat: add cross-platform release packaging

This commit is contained in:
mirivlad 2026-06-06 10:49:56 +08:00
parent 48c0057d11
commit 8e59c3052e
14 changed files with 348 additions and 28 deletions

View File

@ -1,6 +1,7 @@
APP=sshkeeper
RELEASE_CHECK_DIR ?= /tmp/sshkeeper-release-check
.PHONY: build run test vet fmt clean install
.PHONY: build run test vet fmt clean install release-check
build:
go build -o bin/$(APP) .
@ -22,3 +23,15 @@ clean:
install:
go build -o $(HOME)/.local/bin/$(APP) .
release-check:
rm -rf $(RELEASE_CHECK_DIR)
mkdir -p $(RELEASE_CHECK_DIR)
go test ./...
go vet ./...
CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP) .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-linux-arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-darwin-arm64 .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o $(RELEASE_CHECK_DIR)/$(APP)-windows-amd64.exe .

View File

@ -1,7 +1,8 @@
# sshkeeper
`sshkeeper` is a Linux console manager for SSH profiles, secrets, and quick
OpenSSH launches. It does not replace OpenSSH; it keeps connection metadata in a
`sshkeeper` is a console manager for SSH profiles, secrets, and quick OpenSSH
launches. Primary target: Linux. macOS builds are available, and Windows builds
are experimental. It does not replace OpenSSH; it keeps connection metadata in a
local SQLite database, keeps passwords/passphrases in an encrypted vault, and
starts the system `ssh` client with the right options.
@ -42,10 +43,24 @@ Or use the build scripts:
```bash
./build.sh # Build binary to bin/
./release.sh # Build release tarballs to dist/
./release.sh # Build release archives to dist/
```
Requirements: Go 1.25+, Linux x86_64, system OpenSSH.
Requirements: Go 1.25+ and system OpenSSH.
Platform status:
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | Primary target | `linux/amd64` and `linux/arm64` release tarballs are available. |
| macOS | Supported, needs verification on target machines | `darwin/amd64` and `darwin/arm64` release tarballs are available. Requires system `ssh` client. Homebrew formula planned. |
| Windows | Experimental | Requires OpenSSH Client available as `ssh.exe` in `PATH`. Password/key-passphrase PTY flows are not validated on Windows. |
On Windows, install OpenSSH Client via Windows Optional Features or PowerShell:
```powershell
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
```
**Source repositories:**
- Main public: [github.com/mirivlad/sshkeeper](https://github.com/mirivlad/sshkeeper)
@ -55,8 +70,7 @@ Requirements: Go 1.25+, Linux x86_64, system OpenSSH.
```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
chmod +x sshkeeper-linux-amd64
sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
sshkeeper
```
@ -285,10 +299,13 @@ If `XDG_CONFIG_HOME` or `XDG_DATA_HOME` are set, sshkeeper stores data under
```bash
go test ./...
go build -o bin/sshkeeper .
make release-check
```
`bin/` is ignored by git.
For release packaging details, see [docs/release.md](docs/release.md).
## Project Layout
```text
@ -302,8 +319,9 @@ sshkeeper/
├── internal/vault/ # Encrypted vault
├── internal/tunnel/ # Tunnel state management
├── docs/guide.md # User guide
├── docs/release.md # Release packaging guide
├── build.sh # Build binary to bin/
├── release.sh # Build release tarballs to dist/
├── release.sh # Build release archives to dist/
└── main.go
```

View File

@ -21,7 +21,8 @@ var (
var rootCmd = &cobra.Command{
Use: "sshkeeper",
Short: "sshkeeper — SSH connection manager",
Long: `sshkeeper is a console SSH connection manager for Linux.
Long: `sshkeeper is a console SSH connection manager.
Linux is the primary target; macOS builds are available and Windows is experimental.
It manages server profiles, secrets, and provides a convenient way
to launch SSH sessions using the system OpenSSH client.`,
RunE: func(cmd *cobra.Command, args []string) error {

View File

@ -18,7 +18,7 @@
## Что такое sshkeeper
sshkeeper — это консольный менеджер SSH-подключений для Linux. Он хранит профили серверов, секреты (пароли, фразы от ключей) и запускает системный `ssh` с нужными опциями.
sshkeeper — это консольный менеджер SSH-подключений. Основная целевая платформа — Linux; сборки для macOS доступны, Windows-сборка пока экспериментальная. Он хранит профили серверов, секреты (пароли, фразы от ключей) и запускает системный `ssh` с нужными опциями.
**Чем sshkeeper НЕ является:**
- Это не Ansible — он не настраивает серверы и не пушит файлы
@ -63,14 +63,27 @@ go build -o ~/.local/bin/sshkeeper .
./release.sh # сборка релизных архивов в dist/
```
**Требования:** Go 1.25+, Linux x86_64, системный OpenSSH.
**Требования:** Go 1.25+ и системный OpenSSH.
Статус платформ:
| Платформа | Статус | Примечание |
|-----------|--------|------------|
| Linux | Основная целевая платформа | Архивы `linux/amd64` и `linux/arm64`. |
| macOS | Сборки доступны, требуется проверка на целевых машинах | Архивы `darwin/amd64` и `darwin/arm64`, нужен системный `ssh`. Homebrew formula запланирована. |
| Windows | Experimental | Нужен OpenSSH Client как `ssh.exe` в `PATH`; password/key-passphrase PTY-сценарии на Windows пока не подтверждены. |
На Windows OpenSSH Client можно установить через Windows Optional Features или PowerShell:
```powershell
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
```
### Из релиза (после публикации v0.2.0)
```bash
tar -xzf sshkeeper_v0.2.0_linux_amd64.tar.gz
chmod +x sshkeeper-linux-amd64
sudo install -m 0755 sshkeeper-linux-amd64 /usr/local/bin/sshkeeper
sudo install -m 0755 sshkeeper_v0.2.0_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
---
@ -192,7 +205,8 @@ sshkeeper — Quick Help
sshkeeper — Full Help
What is sshkeeper
sshkeeper is a Linux console SSH connection manager.
sshkeeper is a console SSH connection manager.
Linux is the primary target; macOS builds are available and Windows is experimental.
It stores server profiles, secrets, and launches the system ssh client.
Navigation
@ -782,6 +796,9 @@ go test ./...
# Сборка
go build -o bin/sshkeeper .
# Проверка релизной кросс-сборки
make release-check
# Или через скрипты
./build.sh # сборка в bin/
./release.sh # релизные архивы в dist/

114
docs/release.md Normal file
View File

@ -0,0 +1,114 @@
# Release Packaging
This document describes the manual release flow for sshkeeper.
## Create a Tag
Use a semantic version tag:
```bash
git status --short
git tag -a v0.2.0 -m "sshkeeper v0.2.0"
git push origin v0.2.0
```
The release script uses `git describe --tags --always --dirty` by default. You
can also pass the version explicitly:
```bash
./release.sh v0.2.0
```
or:
```bash
VERSION=v0.2.0 ./release.sh
```
For reproducible archives, the script uses `SOURCE_DATE_EPOCH`. By default it
uses the timestamp of the latest git commit. To force a specific timestamp:
```bash
SOURCE_DATE_EPOCH=1760000000 ./release.sh v0.2.0
```
## Run Release Checks
Before packaging, run:
```bash
make release-check
```
This runs:
- `go test ./...`
- `go vet ./...`
- native `go build`
- `linux/amd64` cross-build with `CGO_ENABLED=0`
- `linux/arm64` cross-build with `CGO_ENABLED=0`
- `darwin/amd64` cross-build with `CGO_ENABLED=0`
- `darwin/arm64` cross-build with `CGO_ENABLED=0`
- `windows/amd64` cross-build with `CGO_ENABLED=0`
## Build Artifacts
Run:
```bash
./release.sh v0.2.0
```
Expected files in `dist/`:
```text
sshkeeper_v0.2.0_linux_amd64.tar.gz
sshkeeper_v0.2.0_linux_arm64.tar.gz
sshkeeper_v0.2.0_darwin_amd64.tar.gz
sshkeeper_v0.2.0_darwin_arm64.tar.gz
sshkeeper_v0.2.0_windows_amd64.zip
checksums.txt
```
Each archive contains:
- `sshkeeper` or `sshkeeper.exe`
- `README.md`
- `LICENSE`
- `docs/guide.md`
## Verify Checksums
From the `dist/` directory:
```bash
sha256sum -c checksums.txt
```
Expected result: every archive reports `OK`.
## Publish in GitHub Release
Upload these files to the release:
- all five platform archives
- `checksums.txt`
Release notes should mention platform status:
- Linux is the primary target.
- macOS builds are available as tar.gz for amd64 and arm64 and require the
system `ssh` client.
- Windows build is experimental and requires OpenSSH Client available as
`ssh.exe` in `PATH`.
## Packaging TODO
Prepare these package channels after the first archive-based release:
- deb package
- Arch PKGBUILD / AUR
- rpm later
- Homebrew tap
- Scoop manifest
- Winget later

View File

@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"github.com/BurntSushi/toml"
)
@ -34,7 +35,7 @@ type UIConfig struct {
func defaultConfig() *Config {
return &Config{
SSH: SSHConfig{
Binary: "/usr/bin/ssh",
Binary: defaultSSHBinary(),
ConnectTimeoutSec: 10,
TestCommand: "echo SSHKEEPER_OK",
},
@ -47,6 +48,13 @@ func defaultConfig() *Config {
}
}
func defaultSSHBinary() string {
if runtime.GOOS == "windows" {
return "ssh.exe"
}
return "/usr/bin/ssh"
}
func Load() (*Config, error) {
cfg := defaultConfig()

View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"github.com/mirivlad/sshkeeper/internal/config"
@ -12,7 +13,34 @@ import (
type VaultFunc func(serverAlias string, secretType string) (string, error)
const windowsOpenSSHInstallHint = "Install OpenSSH Client via Windows Optional Features or PowerShell:\nAdd-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0"
func EnsureSSHBinary(binary string) error {
return validateSSHBinaryForOS(runtime.GOOS, binary, exec.LookPath)
}
func validateSSHBinaryForOS(goos string, binary string, lookPath func(string) (string, error)) error {
if goos == "windows" {
if _, err := lookPath("ssh.exe"); err != nil {
return fmt.Errorf("ssh.exe not found in PATH. Windows build is experimental and requires OpenSSH Client.\n%s", windowsOpenSSHInstallHint)
}
return nil
}
if strings.TrimSpace(binary) == "" {
binary = "ssh"
}
if _, err := lookPath(binary); err != nil {
return fmt.Errorf("ssh binary not found (%s): %w", binary, err)
}
return nil
}
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
args := BuildSSHArgsSimple(server)
if strings.TrimSpace(server.StartupCommand) != "" {
args = append(args, server.StartupCommand)
@ -49,6 +77,10 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error
}
func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
args := BuildSSHArgsSimple(server)
args = append(args, command)
@ -78,6 +110,10 @@ func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, co
}
func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return "", err
}
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
@ -116,6 +152,10 @@ func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFu
}
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return false, err.Error()
}
args := BuildSSHArgsSimple(server)
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
@ -179,6 +219,10 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool,
}
func ConnectWithArgs(cfg *config.Config, args []string, vaultFunc VaultFunc, server *model.Server) error {
if err := EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return err
}
switch server.AuthMethod {
case model.AuthPassword:
password, err := vaultFunc(server.Alias, "ssh_password")

View File

@ -104,6 +104,40 @@ func TestConnectRunsStartupCommand(t *testing.T) {
}
}
func TestValidateSSHBinaryForWindowsRequiresOpenSSHClient(t *testing.T) {
err := validateSSHBinaryForOS("windows", "ssh.exe", func(name string) (string, error) {
if name != "ssh.exe" {
t.Fatalf("expected lookup for ssh.exe, got %q", name)
}
return "", os.ErrNotExist
})
if err == nil {
t.Fatal("expected missing ssh.exe error")
}
if !strings.Contains(err.Error(), "OpenSSH Client") {
t.Fatalf("expected OpenSSH Client guidance, got %q", err)
}
if !strings.Contains(err.Error(), "Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0") {
t.Fatalf("expected PowerShell install command, got %q", err)
}
}
func TestValidateSSHBinaryForNonWindowsChecksConfiguredBinary(t *testing.T) {
var lookedUp string
err := validateSSHBinaryForOS("linux", "/usr/bin/ssh", func(name string) (string, error) {
lookedUp = name
return "/usr/bin/ssh", nil
})
if err != nil {
t.Fatalf("validate ssh binary: %v", err)
}
if lookedUp != "/usr/bin/ssh" {
t.Fatalf("expected lookup for configured binary, got %q", lookedUp)
}
}
func TestBuildSSHArgs_Simple(t *testing.T) {
server := &model.Server{Host: "example.org", Port: 22, User: "root"}
args := BuildSSHArgsSimple(server)

View File

@ -1,3 +1,5 @@
//go:build !windows
package ssh
import (

View File

@ -1,3 +1,5 @@
//go:build !windows
package ssh
import (

View File

@ -0,0 +1,15 @@
//go:build windows
package ssh
import "fmt"
const windowsPTYUnsupportedMessage = "password and key-passphrase auth are not supported on Windows experimental builds; use key or agent auth with OpenSSH Client"
func ConnectWithPassword(sshBinary string, args []string, password string) error {
return fmt.Errorf("%s", windowsPTYUnsupportedMessage)
}
func connectWithPasswordAndRead(sshBinary string, args []string, password string, timeoutSec int) (bool, string) {
return false, windowsPTYUnsupportedMessage
}

View File

@ -159,7 +159,8 @@ func (m *fullHelpModel) View() string {
rows [][2]string
}{
{"What is sshkeeper", [][2]string{
{"", "sshkeeper is a Linux console SSH connection manager."},
{"", "sshkeeper is a console SSH connection manager."},
{"", "Linux is the primary target; macOS builds are available and Windows is experimental."},
{"", "It stores server profiles, secrets, and launches the system ssh client."},
{"", ""},
}},

View File

@ -93,6 +93,10 @@ func Start(cfg *config.Config, server *model.Server, forwards []*model.Forward,
mu.Lock()
defer mu.Unlock()
if err := ssh.EnsureSSHBinary(cfg.SSH.Binary); err != nil {
return nil, err
}
// Filter enabled forwards
var active []*model.Forward
for _, f := range forwards {

View File

@ -4,26 +4,73 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.version=${VERSION}"
DIST_DIR="dist"
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
echo "==> Building release ${APP} ${VERSION}..."
echo "==> SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"
rm -rf "${DIST_DIR}"
mkdir -p "${DIST_DIR}"
# Linux amd64
echo "==> linux/amd64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" .
tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_amd64.tar.gz" -C "${DIST_DIR}" "${APP}"
rm -f "${DIST_DIR}/${APP}"
package_docs() {
local package_dir="$1"
# Linux arm64
echo "==> linux/arm64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "${LDFLAGS}" -o "${DIST_DIR}/${APP}" .
tar -czf "${DIST_DIR}/${APP}_${VERSION}_linux_arm64.tar.gz" -C "${DIST_DIR}" "${APP}"
rm -f "${DIST_DIR}/${APP}"
cp README.md LICENSE "${package_dir}/"
if [[ -f docs/guide.md ]]; then
mkdir -p "${package_dir}/docs"
cp docs/guide.md "${package_dir}/docs/"
fi
}
normalize_package() {
local package_dir="$1"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
}
build_tarball() {
local goos="$1"
local goarch="$2"
local package_dir="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}"
local archive="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}.tar.gz"
echo "==> ${goos}/${goarch}..."
rm -rf "${package_dir}"
mkdir -p "${package_dir}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}"
normalize_package "${package_dir}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}"
rm -rf "${package_dir}"
}
build_zip() {
local goos="$1"
local goarch="$2"
local package_dir="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}"
local archive="${DIST_DIR}/${APP}_${VERSION}_${goos}_${goarch}.zip"
echo "==> ${goos}/${goarch}..."
rm -rf "${package_dir}"
mkdir -p "${package_dir}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
package_docs "${package_dir}"
normalize_package "${package_dir}"
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}"
}
build_tarball linux amd64
build_tarball linux arm64
build_tarball darwin amd64
build_tarball darwin arm64
build_zip windows amd64
(cd "${DIST_DIR}" && sha256sum *.tar.gz *.zip > checksums.txt)
echo "==> Done."
ls -lh "${DIST_DIR}/"*.tar.gz
ls -lh "${DIST_DIR}/"*.tar.gz "${DIST_DIR}/"*.zip "${DIST_DIR}/checksums.txt"