diff --git a/.github/workflows/ci-go-checks.yml b/.github/workflows/ci-go-checks.yml deleted file mode 100644 index e0038be..0000000 --- a/.github/workflows/ci-go-checks.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: CI Go Checks - -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - verify: - name: verify (fmt + lint + test + coverage) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Read Go version from .tool-versions - id: go-version - shell: bash - run: | - set -euo pipefail - version=$(awk '$1 == "golang" { print $2; exit }' .tool-versions) - if [ -z "${version}" ]; then - echo "Failed to determine Go version from .tool-versions" >&2 - exit 1 - fi - echo "version=${version}" >> "$GITHUB_OUTPUT" - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ steps.go-version.outputs.version }} - cache: true - cache-dependency-path: | - **/go.sum - - - name: Run verifier (coverage gate) - if: ${{ hashFiles('**/*.go') != '' }} - run: go run ./cmd/verify - - - name: Run integration tests (required) - run: go test ./integration/... -v - - - name: Run CLI e2e-lite tests (required) - run: go test ./e2e/cli/... -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..684b5bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,157 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + verify: + # Concurrency is scoped to this job so superseded PR pushes cancel their + # own verify runs WITHOUT cancelling an in-flight `release` job that may + # already have created a tag/GitHub Release. + concurrency: + group: verify-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + if: ${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]') }} + name: verify (fmt + lint + test + coverage) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read Go version from .tool-versions + id: go-version + shell: bash + run: | + set -euo pipefail + version=$(awk '$1 == "golang" { print $2; exit }' .tool-versions) + if [ -z "${version}" ]; then + echo "Failed to determine Go version from .tool-versions" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go-version.outputs.version }} + cache: true + cache-dependency-path: | + **/go.sum + + - name: Run verifier (fmt + lint + test + coverage gate) + run: go run ./cmd/verify + + # Tag, release notes, GitHub Release, binaries, and Homebrew tap update + # all happen here on push to `main` after `verify` passes. Conventional + # Commits drive the version. No manual `scripts/release.sh` step. + release: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') }} + name: release + needs: [verify] + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: release-${{ github.repository }}-main + cancel-in-progress: false + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Read Go version from .tool-versions + id: go-version + shell: bash + run: | + set -euo pipefail + version=$(awk '$1 == "golang" { print $2; exit }' .tool-versions) + if [ -z "${version}" ]; then + echo "Failed to determine Go version from .tool-versions" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go-version.outputs.version }} + cache: true + cache-dependency-path: | + **/go.sum + + # Decides next version from Conventional Commits, creates tag + GitHub + # Release with notes. Outputs `new_release_published` and + # `new_release_version` for downstream steps. + - name: Run semantic-release + id: release + uses: cycjimmy/semantic-release-action@v4 + with: + extra_plugins: | + conventional-changelog-conventionalcommits@7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GIT_AUTHOR_NAME: glitch418x + GIT_AUTHOR_EMAIL: 189487110+glitch418x@users.noreply.github.com + GIT_COMMITTER_NAME: glitch418x + GIT_COMMITTER_EMAIL: 189487110+glitch418x@users.noreply.github.com + + # semantic-release creates the tag via the GitHub Release API; pull all + # tags so we can detect one at HEAD (whether just-created or already + # present from a previous partial-failure run). + - name: Fetch tags + run: git fetch --tags --force + + # Gate GoReleaser on "is there a tag at HEAD?" rather than + # `steps.release.outputs.new_release_published`. Re-running the job + # after a partial failure (semantic-release succeeded, GoReleaser + # failed mid-flight, e.g. transient `TAP_GITHUB_TOKEN` outage) still + # publishes binaries to the existing GitHub Release because the tag + # is now there. Without this, the second run would short-circuit and + # leave the release without binaries. + - name: Detect tag at HEAD + id: tag + shell: bash + run: | + set -euo pipefail + if tag=$(git describe --exact-match --tags HEAD 2>/dev/null); then + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "present=true" >> "$GITHUB_OUTPUT" + echo "found tag at HEAD: ${tag}" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "no tag at HEAD — skipping GoReleaser" + fi + + - name: Run GoReleaser + if: steps.tag.outputs.present == 'true' + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ steps.tag.outputs.tag }} + + - name: Attest build provenance + if: steps.tag.outputs.present == 'true' + uses: actions/attest-build-provenance@v2 + with: + subject-path: "dist/healthd_*.tar.gz" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a1634fa..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - build: - name: Build ${{ matrix.goos }}-${{ matrix.goarch }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - goos: darwin - goarch: arm64 - - goos: darwin - goarch: amd64 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Read Go version from .tool-versions - id: goversion - run: | - VERSION=$(awk '$1=="golang" {print $2}' .tool-versions) - if [ -z "$VERSION" ]; then - echo "Could not read Go version from .tool-versions" >&2 - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ steps.goversion.outputs.version }} - - - name: Build archive - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: | - VERSION="${GITHUB_REF_NAME}" - ARTIFACT="healthd_${VERSION}_${GOOS}_${GOARCH}.tar.gz" - BIN_DIR="dist/${GOOS}_${GOARCH}" - - mkdir -p "$BIN_DIR" - CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$BIN_DIR/healthd" . - tar -C "$BIN_DIR" -czf "dist/$ARTIFACT" healthd - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: healthd-${{ matrix.goos }}-${{ matrix.goarch }} - path: dist/healthd_*.tar.gz - if-no-files-found: error - - release: - name: Publish release - runs-on: ubuntu-latest - needs: build - - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: dist - pattern: healthd-* - merge-multiple: true - - - name: Generate checksums - run: | - cd dist - sha256sum healthd_*.tar.gz > checksums.txt - - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - files: | - dist/healthd_*.tar.gz - dist/checksums.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4febd3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..cd47d3a --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,75 @@ +version: 2 + +project_name: healthd + +before: + hooks: + - go mod tidy + +builds: + - id: healthd + main: . + binary: healthd + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + - -X github.com/uinaf/healthd/cmd.Version={{ .Version }} + - -X github.com/uinaf/healthd/cmd.Commit={{ .Commit }} + - -X github.com/uinaf/healthd/cmd.BuildDate={{ .Date }} + goos: + - darwin + goarch: + - arm64 + - amd64 + +archives: + # Match the existing artifact layout: healthd_v0.X.Y_darwin_arm64.tar.gz + # containing only the `healthd` binary. The leading `v` keeps the brew + # formula URL pattern stable across releases. + - id: healthd + name_template: "{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + # Match existing artifact layout: a tarball containing only the `healthd` + # binary. `none*` disables the README/LICENSE auto-include. + files: + - none* + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot-{{ .ShortCommit }}" + +changelog: + # semantic-release owns release notes; goreleaser ships binaries only. + disable: true + +release: + github: + owner: uinaf + name: healthd + # semantic-release creates the release first; goreleaser uploads artifacts to it. + mode: append + prerelease: auto + +brews: + - name: healthd + repository: + owner: uinaf + name: homebrew-tap + branch: main + token: "{{ .Env.TAP_GITHUB_TOKEN }}" + directory: Formula + commit_author: + name: glitch418x + email: 189487110+glitch418x@users.noreply.github.com + commit_msg_template: "healthd: bump to {{ .Tag }}" + homepage: "https://github.com/uinaf/healthd" + description: "Pluggable local host health-check daemon" + license: "MIT" + test: | + assert_match "healthd", shell_output("#{bin}/healthd --help") diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..2b733ce --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,26 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { "type": "docs", "release": false }, + { "type": "chore", "release": false }, + { "type": "test", "release": false }, + { "type": "refactor", "release": "patch" }, + { "type": "perf", "release": "patch" }, + { "type": "build", "release": false }, + { "type": "ci", "release": false } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { "preset": "conventionalcommits" } + ], + "@semantic-release/github" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 56ae75f..637b28d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,11 @@ go run ./cmd/verify # full gate (fmt + lint + test + 80% cov go build ./... # build go test ./... # quick: all tests, no gate go build -o ~/.local/bin/healthd . # install to ~/.local/bin -scripts/release.sh vX.Y.Z # full release + brew update ``` +## Releases +Automated on push to `main`. CI verifies, `semantic-release` decides the version from Conventional Commits and tags + drafts the GitHub Release, then GoReleaser builds darwin/arm64 + darwin/amd64 tarballs and updates the formula in `uinaf/homebrew-tap`. No manual script — write `feat:` / `fix:` / `feat!:` commits and the bump happens. Use `[skip ci]` in the message to skip a release for a given push. + ## Env - `HEALTHD_CONFIG` — override config path (also via `--config`); useful for per-worktree isolation diff --git a/cmd/root.go b/cmd/root.go index 11b9984..2c25b0e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,12 +1,28 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "runtime/debug" + + "github.com/spf13/cobra" +) + +// Build metadata. Overridden via -ldflags="-X .../cmd.Version=..." by GoReleaser. +// When unset (e.g. `go run`, `go build` without ldflags), we fall back to the +// embedded VCS info from the Go build, then to "dev". +var ( + Version = "" + Commit = "" + BuildDate = "" +) func NewRootCommand() *cobra.Command { root := &cobra.Command{ - Use: "healthd", - Short: "Host health-check daemon", + Use: "healthd", + Short: "Host health-check daemon", + Version: versionString(), } + root.SetVersionTemplate("{{.Version}}\n") root.AddCommand(newCheckCommand()) root.AddCommand(newInitCommand()) @@ -16,3 +32,37 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newValidateCommand()) return root } + +func versionString() string { + v, c, d := Version, Commit, BuildDate + if v == "" || c == "" { + if info, ok := debug.ReadBuildInfo(); ok { + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + if c == "" { + c = s.Value + } + case "vcs.time": + if d == "" { + d = s.Value + } + } + } + } + } + if v == "" { + v = "dev" + } + if c == "" { + return v + } + short := c + if len(short) > 7 { + short = short[:7] + } + if d == "" { + return fmt.Sprintf("%s (%s)", v, short) + } + return fmt.Sprintf("%s (%s, %s)", v, short, d) +} diff --git a/go.mod b/go.mod index 7a798ee..113334d 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,15 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.10.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/go.sum b/go.sum index aab4da4..f2c4af9 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 74112ce..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -VERSION="${1:?Usage: scripts/release.sh v0.X.0}" -[[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid version: $VERSION (expected vX.Y.Z)"; exit 1; } - -DIR="$(mktemp -d)" -trap 'rm -rf "$DIR"' EXIT - -echo "==> Building binaries..." -GOOS=darwin GOARCH=arm64 go build -o "$DIR/healthd_arm64" . -GOOS=darwin GOARCH=amd64 go build -o "$DIR/healthd_amd64" . - -echo "==> Creating tarballs..." -for arch in arm64 amd64; do - cp "$DIR/healthd_${arch}" "$DIR/healthd" - tar czf "$DIR/healthd_${VERSION}_darwin_${arch}.tar.gz" -C "$DIR" healthd - rm "$DIR/healthd" -done - -echo "==> Computing checksums..." -(cd "$DIR" && shasum -a 256 healthd_${VERSION}_darwin_*.tar.gz > checksums.txt) -cat "$DIR/checksums.txt" - -echo "==> Tagging ${VERSION}..." -git tag -a "$VERSION" -m "Release ${VERSION}" -git push origin "$VERSION" - -echo "==> Creating GitHub release..." -gh release create "$VERSION" \ - "$DIR/healthd_${VERSION}_darwin_arm64.tar.gz" \ - "$DIR/healthd_${VERSION}_darwin_amd64.tar.gz" \ - "$DIR/checksums.txt" \ - --title "$VERSION" \ - --generate-notes - -echo "==> Updating homebrew formula..." -ARM_SHA=$(grep arm64 "$DIR/checksums.txt" | awk '{print $1}') -AMD_SHA=$(grep amd64 "$DIR/checksums.txt" | awk '{print $1}') -VER_NUM="${VERSION#v}" - -FORMULA_PATH="${HOMEBREW_TAP:-$HOME/projects/homebrew-tap}/Formula/healthd.rb" -sed -i '' \ - -e "s/version \".*\"/version \"${VER_NUM}\"/" \ - -e "s|download/v[^/]*/healthd_v[^\"]*_darwin_arm64|download/${VERSION}/healthd_${VERSION}_darwin_arm64|" \ - -e "s|download/v[^/]*/healthd_v[^\"]*_darwin_amd64|download/${VERSION}/healthd_${VERSION}_darwin_amd64|" \ - "$FORMULA_PATH" - -# Update sha256 values using awk (match tarball URL line, update next sha256 line) -awk -v arm="$ARM_SHA" -v amd="$AMD_SHA" ' - /arm64\.tar\.gz/ { found_arm=1 } - found_arm && /sha256/ { sub(/"[a-f0-9]+"/, "\"" arm "\""); found_arm=0 } - /amd64\.tar\.gz/ { found_amd=1 } - found_amd && /sha256/ { sub(/"[a-f0-9]+"/, "\"" amd "\""); found_amd=0 } - { print } -' "$FORMULA_PATH" > "${FORMULA_PATH}.tmp" && mv "${FORMULA_PATH}.tmp" "$FORMULA_PATH" - -cd "$(dirname "$FORMULA_PATH")/.." -git add Formula/healthd.rb -git commit -m "healthd: bump to ${VERSION}" -git push - -echo "==> Done! Run 'brew upgrade healthd' to verify."