Compare commits
6 Commits
codex/tui-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a19b3deb24 | |
|
|
59b57a4970 | |
|
|
878f7b4472 | |
|
|
0a02f0fc60 | |
|
|
19ffc4ba5e | |
|
|
cc83802244 |
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
5
build.sh
5
build.sh
|
|
@ -4,7 +4,10 @@ set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
APP=sshkeeper
|
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}"
|
LDFLAGS="-s -w -X main.version=${VERSION}"
|
||||||
|
|
||||||
echo "==> Building ${APP} ${VERSION}..."
|
echo "==> Building ${APP} ${VERSION}..."
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ func init() {
|
||||||
forwardAddCmd.Flags().String("name", "", "Forward name")
|
forwardAddCmd.Flags().String("name", "", "Forward name")
|
||||||
forwardAddCmd.Flags().String("description", "", "Forward description")
|
forwardAddCmd.Flags().String("description", "", "Forward description")
|
||||||
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
|
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
|
||||||
|
forwardAddCmd.Flags().Int("local-port", 0, "Listen port")
|
||||||
forwardAddCmd.MarkFlagRequired("local-port")
|
forwardAddCmd.MarkFlagRequired("local-port")
|
||||||
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
|
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
|
||||||
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
|
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,22 @@ import (
|
||||||
"github.com/mirivlad/sshkeeper/internal/db"
|
"github.com/mirivlad/sshkeeper/internal/db"
|
||||||
"github.com/mirivlad/sshkeeper/internal/model"
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
"github.com/spf13/cobra"
|
"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) {
|
func TestForwardEditUpdatesEnabledFlag(t *testing.T) {
|
||||||
testDB, err := db.Open(t.TempDir())
|
testDB, err := db.Open(t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -110,3 +124,70 @@ func TestForwardAddStoresNameAndDescription(t *testing.T) {
|
||||||
t.Fatalf("unexpected forward metadata: %#v", forwards[0])
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,41 @@
|
||||||
# Release Packaging
|
# 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
|
## Create a Tag
|
||||||
|
|
||||||
Use a semantic version tag:
|
Use a semantic version tag. Pushing it is what triggers `release.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short
|
git status --short
|
||||||
|
|
@ -12,8 +43,14 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0"
|
||||||
git push origin v0.2.0
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The release script uses `git describe --tags --always --dirty` by default. You
|
The remaining sections describe the manual equivalent, which is still the way
|
||||||
can also pass the version explicitly:
|
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
|
```bash
|
||||||
./release.sh v0.2.0
|
./release.sh v0.2.0
|
||||||
|
|
@ -87,9 +124,40 @@ sha256sum -c checksums.txt
|
||||||
|
|
||||||
Expected result: every archive reports `OK`.
|
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
|
## 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
|
- all five platform archives
|
||||||
- `checksums.txt`
|
- `checksums.txt`
|
||||||
|
|
|
||||||
|
|
@ -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 (70–99) stacks them, narrow (60–69) 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.
|
||||||
26
release.sh
26
release.sh
|
|
@ -4,7 +4,8 @@ set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
APP=sshkeeper
|
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}"
|
LDFLAGS="-s -w -X main.version=${VERSION}"
|
||||||
DIST_DIR="dist"
|
DIST_DIR="dist"
|
||||||
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
|
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
|
||||||
|
|
@ -27,6 +28,16 @@ package_docs() {
|
||||||
|
|
||||||
normalize_package() {
|
normalize_package() {
|
||||||
local package_dir="$1"
|
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}" {} +
|
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}" .
|
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
|
||||||
package_docs "${package_dir}"
|
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}"
|
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}"
|
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" .
|
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
|
||||||
package_docs "${package_dir}"
|
package_docs "${package_dir}"
|
||||||
normalize_package "${package_dir}"
|
normalize_package "${package_dir}" "${APP}.exe"
|
||||||
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
|
# 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}"
|
rm -rf "${package_dir}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue