Compare commits

...

2 Commits

Author SHA1 Message Date
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
6 changed files with 277 additions and 7 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

@ -1,10 +1,40 @@
# 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 a local run produces byte-identical archives.
### 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 +42,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
@ -89,7 +125,7 @@ Expected result: every archive reports `OK`.
## 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`

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)}