diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..800013e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..6269cf7 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -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 <.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 diff --git a/docs/release.md b/docs/release.md index 1fc6503..97df88f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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/.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--g`. +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,6 +42,9 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0" git push origin v0.2.0 ``` +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 @@ -92,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`