Compare commits

...

6 Commits

Author SHA1 Message Date
mirivlad a19b3deb24 docs: add v0.3.2 release notes and reproducibility results
Exercise the release workflow end to end. v0.3.1 was tagged and published by
hand before release.yml existed, so the tag-triggered path has never actually
run; this tag is the first to go through it.

Add docs/releases/v0.3.2.md, which release.yml picks up as the release body
instead of falling back to generated notes. The notes cover the whole v0.2.0
range rather than just this tag, since v0.3.0 through v0.3.2 landed in quick
succession and the F1 to Ctrl+H change is the one thing an upgrader must know.

Also record the measured reproducibility result: with modes, locale and
timezone pinned, ubuntu-latest and a workstation on a different umask, locale
and timezone now produce identical checksums for all five archives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:13:50 +08:00
mirivlad 59b57a4970 fix: make the windows zip independent of locale and timezone
With file modes normalized, the four tarballs reproduced byte for byte across
hosts but the Windows zip still did not. Two host properties were leaking into
it:

- Entry order. The archive is fed by `find | sort`, and sort honours the
  locale. A ru_RU.UTF-8 host emits docs/ before LICENSE; a C locale emits the
  reverse. Same files, different archive.
- Timestamps. zip records DOS local time with no zone attached, so building at
  UTC+08 embedded 19:06 where ubuntu-latest embedded 11:06 for the same commit.

Pin LC_ALL=C and TZ=UTC for the packaging subshell. Building the same commit
under ru_RU.UTF-8/Asia-Shanghai and under C/UTC now yields one hash.

The tarballs never had either problem: tar sorts internally by byte value and
stores Unix epochs, so neither locale nor zone reaches the output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:07 +08:00
mirivlad 878f7b4472 fix: normalize file modes when packaging a release
release.sh normalized entry order, ownership and mtimes, but not permissions,
so the archives inherited the builder's umask. A host with umask 002 packaged
664/775 while ubuntu-latest packaged 644/755, and the two archives hashed
differently even though every file inside was byte-identical:

  CI     -rw-r--r--  README.md   local  -rw-rw-r--  README.md
  CI     -rwxr-xr-x  sshkeeper   local  -rwxrwxr-x  sshkeeper

Force 755 on directories and the program, 644 on everything else. Building the
same commit under umask 002 and umask 022 now yields identical checksums.

Also correct the reproducibility claim in the release docs. What is reproducible
is the binary, given the same commit and Go version; the archive hash still
depends on the host tar and gzip, so the documented verification step now
compares the extracted binary instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:06:12 +08:00
mirivlad 0a02f0fc60 ci: add test, release and nightly workflows
The repository had no automation at all: every release was packaged and
published by hand, and nothing ran tests on a pull request.

- ci.yml runs gofmt, go vet and go test on Linux and macOS, and cross-builds
  all five release targets. macOS is a stated release target but was never
  actually exercised, only cross-compiled.
- release.yml publishes on a v* tag. It gates on `make release-check` so a red
  suite cannot ship, and builds through release.sh rather than duplicating the
  packaging rules, so CI archives stay byte-identical to local ones. A
  hand-written docs/releases/<tag>.md becomes the release body when present,
  otherwise notes are generated from history.
- nightly.yml rebuilds the tip of main on every push and replaces a rolling
  `nightly` prerelease. Prerelease is deliberate: it keeps GitHub's `Latest`
  badge on the newest real release rather than on an untested build.

The rolling tag is why version discovery was pinned to v* in the previous
commit; nightly.yml depends on that filter already being in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:59:40 +08:00
mirivlad 19ffc4ba5e build: pin version discovery to v* tags
Nightly builds will move a rolling `nightly` tag across main. A plain
`git describe --tags` returns whichever tag is nearest, so once that tag exists
every build — including a real release build — would report its version as
"nightly" and lose the release lineage entirely.

Restrict discovery to `v*` so the rolling tag is invisible to versioning:

  with a nightly tag ahead of v0.3.1
    git describe --tags                → nightly
    git describe --tags --match 'v*'   → v0.3.1-1-gf940087

Land this before the nightly workflow exists, so no build is ever stamped from
the rolling tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:56:45 +08:00
mirivlad cc83802244 fix: register the forward add local-port flag
`sshkeeper forward add` could never succeed. RunE read --local-port and init()
marked it required, but the flag was never registered on forwardAddCmd. Cobra
ignores MarkFlagRequired for an unknown flag, and GetInt returns 0 for one, so
every invocation failed validation with "invalid local port 0: must be
1-65535". There was no argument combination that worked.

Register the flag so both the read and the required annotation bind to a real
option. README and the guide already documented --local-port, so the intent was
there from the start; only the registration was missing.

The existing tests missed this because they build a throwaway cobra.Command,
register the flags on it themselves, and pass it to forwardAddCmd.RunE — the
real command's flag set was never exercised. Add a test that parses argv into
forwardAddCmd's own flags, plus one pinning the required annotation. Both fail
against the unfixed command with "unknown flag: --local-port".

Present since c2edaa4, so v0.2.0 and v0.3.0 both ship it broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:46:08 +08:00
9 changed files with 551 additions and 10 deletions

71
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Formatting is platform independent, so check it once rather than twice.
- name: gofmt
if: matrix.os == 'ubuntu-latest'
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: go vet
run: go vet ./...
- name: go test
run: go test ./... -count=1
cross-build:
name: cross-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Mirrors the release targets, so a platform-specific break surfaces on
# the pull request rather than at tag time.
- name: build all release targets
run: |
set -euo pipefail
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
goos="${target%/*}"
goarch="${target#*/}"
echo "==> ${goos}/${goarch}"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath -o /tmp/sshkeeper-ci-build .
done

94
.github/workflows/nightly.yml vendored Normal file
View File

@ -0,0 +1,94 @@
name: Nightly
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# Two pushes in quick succession must not race for the rolling tag. Let the
# newer commit win rather than publishing a nightly built from older code.
concurrency:
group: nightly
cancel-in-progress: true
jobs:
nightly:
name: publish nightly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Cheaper than the full release-check, but still refuses to publish a
# broken build.
- name: test
run: |
go vet ./...
go test ./... -count=1
# Version discovery is pinned to v* tags (see build.sh), so the rolling
# nightly tag below cannot hijack this value.
- name: resolve version
id: version
run: echo "value=$(git describe --tags --match 'v*' --always)" >> "$GITHUB_OUTPUT"
- name: build artifacts
env:
VERSION: ${{ steps.version.outputs.value }}
run: ./release.sh "$VERSION"
# Move the rolling tag before touching the release: a GitHub release must
# point at a tag, and this one always tracks the tip of main.
- name: move nightly tag
run: |
set -euo pipefail
git tag -f nightly
git push -f origin nightly
# Replace rather than update: assets are immutable once uploaded, so the
# old release has to go before the new archives can take its name.
- name: replace nightly release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
# Heredoc, not an inline string: the notes are markdown and must not
# inherit this file's YAML indentation.
cat > /tmp/nightly-notes.md <<EOF
Automated build from the tip of \`main\`, rebuilt on every push.
**This is not a stable release.** It is untagged, unannounced and may be
broken. The \`Latest\` badge stays on the newest \`v*\` release, which is
what you want for normal use.
| | |
|---|---|
| Version | \`${VERSION}\` |
| Commit | ${GITHUB_SHA} |
| Built | $(date -u '+%Y-%m-%d %H:%M UTC') |
Verify downloads against \`checksums.txt\`.
EOF
gh release delete nightly --yes || echo "no previous nightly release"
gh release create nightly \
--prerelease \
--title "sshkeeper nightly (${VERSION})" \
--notes-file /tmp/nightly-notes.md \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

65
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: publish ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
# Full history and tags: release.sh derives SOURCE_DATE_EPOCH from the
# tagged commit, and version discovery needs the v* tags to be present.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Gate the release on the same checks used locally. A red suite must not
# be able to publish.
- name: release checks
run: make release-check
# Build through release.sh rather than reimplementing packaging here, so
# CI and a local ./release.sh produce byte-identical archives.
- name: build artifacts
env:
VERSION: ${{ github.ref_name }}
run: ./release.sh "$VERSION"
- name: publish
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
# A hand-written docs/releases/<tag>.md wins; otherwise fall back to
# GitHub's generated changelog.
notes="docs/releases/${VERSION}.md"
if [ -f "$notes" ]; then
echo "Using hand-written notes from $notes"
set -- --notes-file "$notes"
else
echo "No $notes, generating notes from commit history"
set -- --generate-notes
fi
gh release create "$VERSION" \
--title "sshkeeper $VERSION" \
--verify-tag \
"$@" \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

View File

@ -4,7 +4,10 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
# --match 'v*' keeps the rolling `nightly` tag from hijacking the version: a
# plain `git describe --tags` picks whichever tag is nearest, so a nightly build
# would otherwise stamp binaries "nightly" instead of v<last release>-N-g<sha>.
VERSION=$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..."

View File

@ -199,6 +199,7 @@ func init() {
forwardAddCmd.Flags().String("name", "", "Forward name")
forwardAddCmd.Flags().String("description", "", "Forward description")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port")
forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")

View File

@ -7,8 +7,22 @@ import (
"github.com/mirivlad/sshkeeper/internal/db"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// restoreFlags returns every flag touched during the test back to its default.
// The cobra commands are package-level singletons, so parsing argv into one
// leaks state into whatever test runs next.
func restoreFlags(t *testing.T, cmd *cobra.Command) {
t.Helper()
t.Cleanup(func() {
cmd.Flags().Visit(func(f *pflag.Flag) {
_ = f.Value.Set(f.DefValue)
f.Changed = false
})
})
}
func TestForwardEditUpdatesEnabledFlag(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
@ -110,3 +124,70 @@ func TestForwardAddStoresNameAndDescription(t *testing.T) {
t.Fatalf("unexpected forward metadata: %#v", forwards[0])
}
}
// TestForwardAddParsesItsOwnFlags drives the real forwardAddCmd flag set the
// way the CLI does, instead of handing RunE a command built by the test.
//
// Regression: RunE read --local-port and init() marked it required, but the
// flag was never registered on forwardAddCmd. Cobra silently ignores
// MarkFlagRequired for an unknown flag and GetInt returns 0 for one, so every
// real invocation died on "invalid local port 0" while the sibling tests --
// which registered the flag on a throwaway command themselves -- kept passing.
func TestForwardAddParsesItsOwnFlags(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer testDB.Close()
previousDB := appDB
appDB = testDB
t.Cleanup(func() { appDB = previousDB })
server := &model.Server{Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := appDB.CreateServer(server); err != nil {
t.Fatalf("create server: %v", err)
}
restoreFlags(t, forwardAddCmd)
if err := forwardAddCmd.Flags().Parse([]string{
"--name", "Local PostgreSQL",
"--type", "local",
"--local-port", "15432",
"--remote-addr", "db01.internal.example.com",
"--remote-port", "5432",
}); err != nil {
t.Fatalf("parse forward add flags: %v", err)
}
if err := forwardAddCmd.RunE(forwardAddCmd, []string{"web"}); err != nil {
t.Fatalf("add forward: %v", err)
}
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
t.Fatalf("get forwards: %v", err)
}
if len(forwards) != 1 {
t.Fatalf("expected one forward, got %d", len(forwards))
}
got := forwards[0]
if got.LocalPort != 15432 {
t.Fatalf("local port not carried through: got %d, want 15432", got.LocalPort)
}
if got.Type != model.ForwardLocal || got.RemoteAddr != "db01.internal.example.com" || got.RemotePort != 5432 {
t.Fatalf("unexpected forward: %#v", got)
}
}
// TestForwardAddRequiresLocalPort pins the flag's registration and its required
// annotation, which is what MarkFlagRequired silently failed to attach.
func TestForwardAddRequiresLocalPort(t *testing.T) {
flag := forwardAddCmd.Flags().Lookup("local-port")
if flag == nil {
t.Fatal("forward add does not register --local-port")
}
if _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("--local-port is registered but not marked required")
}
}

View File

@ -1,10 +1,41 @@
# Release Packaging
This document describes the manual release flow for sshkeeper.
Releases are published by GitHub Actions. Pushing a `v*` tag is the whole
release procedure; the rest of this document describes what that automation
runs, and how to reproduce it by hand when needed.
## Workflows
| Workflow | Trigger | Result |
|----------|---------|--------|
| `ci.yml` | push to `main`, every pull request | `gofmt`, `go vet`, `go test` on Linux and macOS, plus a cross-build of all five release targets |
| `release.yml` | push of a `v*` tag | runs `make release-check`, then `release.sh`, then publishes the GitHub release |
| `nightly.yml` | push to `main` | rebuilds the tip of `main` and replaces the `nightly` prerelease |
`release.yml` builds through `release.sh` rather than reimplementing packaging,
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
what that guarantees.
### Release notes
`release.yml` looks for `docs/releases/<tag>.md`. If that file exists it becomes
the release body; otherwise GitHub generates notes from commit history. Write
the file before pushing the tag when a release deserves a real description.
### The nightly prerelease
`nightly.yml` force-moves a rolling `nightly` tag to the tip of `main` and
republishes a prerelease from it. It is marked prerelease deliberately, so
GitHub's `Latest` badge stays on the newest `v*` release.
Because that tag moves, version discovery in `build.sh` and `release.sh` is
pinned with `--match 'v*'`. Without the filter `git describe` would select
`nightly` and stamp binaries with it instead of `v<last release>-<n>-g<sha>`.
Keep the filter if you touch those scripts.
## Create a Tag
Use a semantic version tag:
Use a semantic version tag. Pushing it is what triggers `release.yml`:
```bash
git status --short
@ -12,8 +43,14 @@ 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:
The remaining sections describe the manual equivalent, which is still the way
to test packaging locally or to recover if Actions is unavailable.
The release script uses `git describe --tags --match 'v*' --always --dirty` by
default. The `--match 'v*'` filter matters: nightly builds move a `nightly` tag
across `main`, and without the filter `git describe` would pick that tag and
stamp binaries `nightly` instead of `v<last release>-<n>-g<sha>`. You can also
pass the version explicitly:
```bash
./release.sh v0.2.0
@ -87,9 +124,40 @@ sha256sum -c checksums.txt
Expected result: every archive reports `OK`.
## Reproducibility
Rebuilding the same commit with the same Go version reproduces the **binaries**
byte for byte. `release.sh` pins everything that would otherwise vary:
- `-trimpath` and `CGO_ENABLED=0` keep build paths and the host toolchain out
of the binary;
- `SOURCE_DATE_EPOCH` (the commit timestamp) sets every archive mtime;
- `tar --sort=name --owner=0 --group=0 --numeric-owner` fixes entry order and
ownership, and `gzip -n` drops the compression timestamp;
- `normalize_package` forces 755 on directories and the program and 644 on
everything else, so the builder's umask cannot leak into the archive.
- the Windows zip is packaged under `LC_ALL=C` and `TZ=UTC`, because `sort`
orders entries by locale and zip stores DOS local time with no zone.
With those in place the archives themselves reproduce across hosts: a build on
`ubuntu-latest` (umask 022, C locale, UTC) and one on a workstation (umask 002,
`ru_RU.UTF-8`, UTC+08) produce identical checksums for all five archives.
The strongest check is still the binary, since it does not depend on the host's
`tar` and `gzip` at all:
```bash
tar -xzf sshkeeper_<version>_linux_amd64.tar.gz
sha256sum sshkeeper_<version>_linux_amd64/sshkeeper
```
Comparing whole-archive hashes works too, as long as both builds used the same
Go version.
## Publish in GitHub Release
Upload these files to the release:
`release.yml` does this automatically on tag push. To publish by hand, upload:
- all five platform archives
- `checksums.txt`

140
docs/releases/v0.3.2.md Normal file
View File

@ -0,0 +1,140 @@
Release automation and reproducible packaging. sshkeeper itself behaves exactly
as in v0.3.1 — no functional changes to the TUI or the CLI.
This is also the first release published by GitHub Actions rather than by hand.
## In this release
**Archives are now reproducible.** Rebuilding a tag on a different machine used
to produce different checksums even when every packaged file was byte-identical,
because three host properties leaked into the archives:
| Leak | Effect |
|------|--------|
| File modes followed the builder's umask | umask 002 packaged `664`/`775`, umask 022 packaged `644`/`755` |
| `sort` orders entries by locale | a `ru_RU.UTF-8` host emitted `docs/` before `LICENSE`, a C locale the reverse |
| zip stores DOS local time with no zone | the same commit embedded `19:06` at UTC+08 and `11:06` at UTC |
All three are pinned now. A build on `ubuntu-latest` and one on a workstation
with a different umask, locale and timezone produce identical checksums for all
five archives. The binaries were always reproducible; only the packaging varied.
**CI.** The repository previously had no automation. It now runs `gofmt`,
`go vet` and `go test` on Linux *and* macOS for every push and pull request,
plus a cross-build of all five release targets. macOS is a stated release
target that until now was only ever cross-compiled, never tested.
**Releases are automated.** Pushing a `v*` tag runs the release checks, builds
through the same `release.sh` used locally, and publishes. Nightly builds from
`main` are published as a separate `nightly` prerelease, so the `Latest` badge
always points at a real release.
---
# Everything since v0.2.0
## Breaking change: full help moved off F1
**`Ctrl+H` opens full help. `F1` no longer has any binding.** `?` still opens
contextual quick help outside text editors. This landed in v0.3.0.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing do not collide. A
terminal configured to send BS for Backspace cannot tell them apart; switch it
to DEL (in xterm, `backarrowKey: false`).
Nothing else requires action when upgrading. Vaults, server profiles and stored
port forwards are unchanged, and no migration runs.
## The TUI was rebuilt around one shell (v0.3.0)
In v0.2.0 only the server dashboard had a real layout. Other screens rendered
free-form strings or the default Bubbles list frame, so they had no shared
width budget, no borders, and footers that floated wherever the content ended.
Every full-screen state now shares one contract: a header with breadcrumb and
truthful vault status, a separator, framed content panels, and a contextual
footer anchored to the last terminal row.
- Actions, search, tag input, confirmations and both help screens render inside
the shell.
- The port forward manager and editor use framed, width-budgeted layouts. Column
widths derive from the panel's inner width, so no row consumes the terminal's
last column.
- Tag, command template, template picker/mode/results and tunnel managers use
framed lists with a `>` selection marker, so selection never depends on colour
alone.
- Server and template editors use a framed form panel with the title moved into
the breadcrumb. Required markers, validation and dirty-state confirmation are
unchanged.
**Responsive layouts.** The supported floor is `60x16`. Wide (100+ columns)
shows two panels, medium (7099) stacks them, narrow (6069) keeps a single
compact panel. Below the floor only the minimum-size message renders. Long
ASCII, Cyrillic, CJK, combining and emoji content truncates by display cells
rather than byte count.
**Safety.** Destructive actions confirm with Cancel selected first and name the
exact target and its consequence. Status and help context stay truthful to
actual vault and connection state. Form validation prevents silent loss of
edits.
## `forward add` was completely broken (fixed in v0.3.1)
`sshkeeper forward add` could never succeed in v0.2.0 or v0.3.0. The command
read `--local-port` and marked it required, but the flag was never registered,
so cobra rejected it as unknown and the value fell back to 0:
```
$ sshkeeper forward add web --type local --local-port 15432 \
--remote-addr 127.0.0.1 --remote-port 5432
unknown flag: --local-port
```
No combination of arguments worked. Both documented forms work now:
```
$ sshkeeper forward add web --name "Local PostgreSQL" --type local \
--local-port 15432 --remote-addr db01.internal.example.com --remote-port 5432
✓ Forward added [1]
$ sshkeeper forward add web --name "SOCKS proxy" --type dynamic --local-port 1080
✓ Forward added [2]
```
Omitting the flag now reports `required flag(s) "local-port" not set` instead of
a misleading port-range error. The TUI (`Ctrl+W`) was never affected.
The command's tests had constructed their own throwaway cobra command and
registered the flags by hand, so the real command's registration was never
exercised and the suite passed against a broken command. Coverage now parses
argv into the actual command. An audit of every other command found no further
flag that is read but never registered.
## Also fixed since v0.2.0
- Port forward fields accept digits correctly (`10bcc07`).
- Platform and repository status are stated accurately in the docs: Linux and
macOS are primary release targets, Windows is experimental.
## Release-by-release
| Version | Contents |
|---------|----------|
| v0.3.0 | Unified TUI shell, responsive layouts, `F1``Ctrl+H` |
| v0.3.1 | `forward add` fix |
| v0.3.2 | Reproducible packaging, CI, automated releases |
## Install
```bash
tar -xzf sshkeeper_v0.3.2_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.3.2_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
Verify downloads against `checksums.txt`. Linux and macOS are the primary
release targets; the Windows build is experimental and needs OpenSSH Client
available as `ssh.exe` on `PATH`.
To verify a build yourself, check out the tag and run `./release.sh v0.3.2`
the checksums should match this release exactly, given the same Go version.

View File

@ -4,7 +4,8 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
VERSION=${VERSION:-${1:-$(git describe --tags --match 'v*' --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)}
@ -27,6 +28,16 @@ package_docs() {
normalize_package() {
local package_dir="$1"
local binary="$2"
# Permissions must not depend on the builder's umask. Without this, a host
# with umask 002 packages 664/775 while one with umask 022 packages
# 644/755, and the archives differ even though every file inside is
# byte-identical.
find "${package_dir}" -type d -exec chmod 755 {} +
find "${package_dir}" -type f -exec chmod 644 {} +
chmod 755 "${package_dir}/${binary}"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
}
@ -42,7 +53,7 @@ build_tarball() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}"
normalize_package "${package_dir}"
normalize_package "${package_dir}" "${APP}"
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}"
}
@ -59,8 +70,15 @@ build_zip() {
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}")" -@)
normalize_package "${package_dir}" "${APP}.exe"
# LC_ALL=C: `sort` is locale-sensitive, and it decides the entry order here.
# A ru_RU.UTF-8 host orders docs/ before LICENSE where a C locale does the
# reverse, producing a different archive from identical files.
#
# TZ=UTC: zip records DOS local time with no zone, so the same build in
# +08:00 and in UTC would embed different timestamps. tar needs neither —
# it sorts internally and stores Unix epochs.
(cd "${DIST_DIR}" && export LC_ALL=C TZ=UTC && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}"
}