From 899cbf663ff3f57cf0759c04b69636cac2f7cc9d Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 29 Mar 2026 22:10:40 -0700 Subject: [PATCH] Replace Justfile automation with Mage --- .github/pull_request_template.md | 4 +- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 4 +- AGENTS.md | 10 +- CODEOWNERS | 2 +- CONTRIBUTING.md | 32 +- Justfile | 152 -------- README.md | 20 +- magefiles/go.mod | 35 ++ magefiles/go.sum | 68 ++++ magefiles/magefile.go | 621 +++++++++++++++++++++++++++++++ 11 files changed, 778 insertions(+), 186 deletions(-) delete mode 100644 Justfile create mode 100644 magefiles/go.mod create mode 100644 magefiles/go.sum create mode 100644 magefiles/magefile.go diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c65c880..8272f32 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,8 +5,8 @@ ## Validation -- [ ] `just check` -- [ ] `just ci` when practical +- [ ] `mage check` +- [ ] `mage ci` when practical - [ ] docs updated if behavior or examples changed - [ ] governance, CI, or release impact explained if sensitive maintainer surfaces changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ce054..818ea82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,16 +21,16 @@ jobs: with: go-version-file: go.mod cache: true - - uses: taiki-e/install-action@just - name: Install repo tooling shell: bash run: | + go install github.com/magefile/mage@v1.17.0 go install mvdan.cc/gofumpt@v0.9.2 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run smoke gate shell: bash - run: just check + run: mage check check-macos: name: check (macos-latest) @@ -41,16 +41,16 @@ jobs: with: go-version-file: go.mod cache: true - - uses: taiki-e/install-action@just - name: Install repo tooling shell: bash run: | + go install github.com/magefile/mage@v1.17.0 go install mvdan.cc/gofumpt@v0.9.2 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run macOS compatibility gate shell: bash - run: just check + run: mage check check-windows: name: check (windows-latest) @@ -61,7 +61,6 @@ jobs: with: go-version-file: go.mod cache: true - - uses: taiki-e/install-action@just - name: Normalize checkout line endings shell: bash run: | @@ -70,12 +69,13 @@ jobs: - name: Install repo tooling shell: bash run: | + go install github.com/magefile/mage@v1.17.0 go install mvdan.cc/gofumpt@v0.9.2 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run Windows compatibility gate shell: bash - run: just check + run: mage check full-gate: name: full gate (ubuntu-latest) @@ -90,14 +90,14 @@ jobs: with: go-version-file: go.mod cache: true - - uses: taiki-e/install-action@just - name: Install repo tooling shell: bash run: | + go install github.com/magefile/mage@v1.17.0 go install mvdan.cc/gofumpt@v0.9.2 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 go install github.com/goreleaser/goreleaser/v2@v2.14.3 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run canonical CI gate shell: bash - run: just ci + run: mage ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1ac1b4..af83ac9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,17 +23,17 @@ jobs: with: go-version-file: go.mod cache: true - - uses: taiki-e/install-action@just - name: Install repo tooling shell: bash run: | + go install github.com/magefile/mage@v1.17.0 go install mvdan.cc/gofumpt@v0.9.2 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 go install github.com/goreleaser/goreleaser/v2@v2.14.3 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run canonical CI gate shell: bash - run: just ci + run: mage ci - name: Release with GoReleaser uses: goreleaser/goreleaser-action@v7 with: diff --git a/AGENTS.md b/AGENTS.md index 12fbbf8..b3b14fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,11 +118,11 @@ When integrating consumer concepts, keep `autent` generic: ## 6) Testing and Automation -- Use `just` recipes as the source of truth for local automation -- During implementation, run `just check` after meaningful increments when practical -- When Go code, `Justfile`, or workflow files change, finish with `just ci` before handoff if the environment supports it -- Keep `just ci` aligned with the required GitHub Actions gate -- Prefer package-scoped loops with `just test-pkg ` for fast iteration +- Use `mage` targets as the source of truth for local automation +- During implementation, run `mage check` after meaningful increments when practical +- When Go code, `magefiles/`, or workflow files change, finish with `mage ci` before handoff if the environment supports it +- Keep `mage ci` aligned with the required GitHub Actions gate +- Prefer package-scoped loops with `mage test-pkg ` for fast iteration - Keep at least 70% coverage for packages with substantive executable logic - Do not add meaningless tests just to force doc-only or marker packages over the coverage threshold diff --git a/CODEOWNERS b/CODEOWNERS index aa92536..d24d791 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,7 +6,7 @@ /AGENTS.md @evanschultz /SECURITY.md @evanschultz /CODE_OF_CONDUCT.md @evanschultz -/Justfile @evanschultz +/magefiles/ @evanschultz /.goreleaser.yml @evanschultz /docs/ @evanschultz /cmd/ @evanschultz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0e6b7b..6770274 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,15 +29,23 @@ User-facing docs live in: ## Local Setup -Required local tools are driven by the `Justfile`. +Required local tools are driven by `magefiles/`. The main local gates are: ```bash -just check -just ci +mage check +mage ci ``` -`just ci` includes release configuration validation. +Install Mage locally before using the repo targets: + +```bash +go install github.com/magefile/mage@v1.17.0 +``` + +Running `mage` from the repository root will auto-discover `magefiles/` and keep the repository root as the working directory. + +`mage ci` includes release configuration validation. Install GoReleaser locally if you do not already have it. On macOS with Homebrew, for example: @@ -48,9 +56,9 @@ brew install goreleaser For faster loops, prefer package-scoped tests: ```bash -just test-pkg ./app -just test-pkg ./domain -just test-pkg ./sqlite +mage test-pkg ./app +mage test-pkg ./domain +mage test-pkg ./sqlite ``` ## Coding Expectations @@ -77,9 +85,9 @@ At minimum, consider whether the change affects: Before asking for review: -- run `just check` -- run `just ci` when practical -- keep GitHub Actions and `Justfile` behavior aligned +- run `mage check` +- run `mage ci` when practical +- keep GitHub Actions and `magefiles/` behavior aligned The release workflow exists to validate and publish tagged releases. Do not add unrelated packaging or deployment behavior into the core library CI path. @@ -109,8 +117,8 @@ Contributor flow: 1. create a branch from `main` 2. make the change -3. run `just check` -4. run `just ci` when practical +3. run `mage check` +4. run `mage ci` when practical 5. open a pull request with `gh pr create` Example: diff --git a/Justfile b/Justfile deleted file mode 100644 index a688210..0000000 --- a/Justfile +++ /dev/null @@ -1,152 +0,0 @@ -set shell := ["bash", "-eu", "-o", "pipefail", "-c"] -set windows-shell := ["C:/Program Files/Git/bin/bash.exe", "-eu", "-o", "pipefail", "-c"] - -[private] -verify-bootstrap: - @test -f AGENTS.md - @test -f README.md - @test -f Justfile - @test -f .goreleaser.yml - @test -f .github/workflows/ci.yml - @test -f .github/workflows/release.yml - -fmt: - @if [ ! -f go.mod ]; then \ - echo "skip fmt: go.mod not initialized yet"; \ - elif ! command -v gofumpt >/dev/null 2>&1; then \ - echo "fmt failed: gofumpt is not installed"; \ - echo "install: brew install gofumpt"; \ - exit 1; \ - elif ! find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print -quit | grep -q .; then \ - echo "skip fmt: no Go files found"; \ - else \ - find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print0 | xargs -0 gofumpt -w; \ - fi - -[private] -fmt-check: - @if [ ! -f go.mod ]; then \ - echo "skip fmt-check: go.mod not initialized yet"; \ - elif ! command -v gofumpt >/dev/null 2>&1; then \ - echo "fmt-check failed: gofumpt is not installed"; \ - echo "install: brew install gofumpt"; \ - exit 1; \ - elif ! find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print -quit | grep -q .; then \ - echo "skip fmt-check: no Go files found"; \ - else \ - out="$(find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print0 | xargs -0 gofumpt -l)"; \ - if [ -n "$out" ]; then \ - echo "gofumpt required for:"; \ - echo "$out"; \ - exit 1; \ - fi; \ - fi - -test: - @if [ ! -f go.mod ]; then \ - echo "skip test: go.mod not initialized yet"; \ - elif ! find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print -quit | grep -q .; then \ - echo "skip test: no Go files found"; \ - else \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go test ./...; \ - fi - -lint: - @if [ ! -f go.mod ]; then \ - echo "skip lint: go.mod not initialized yet"; \ - elif ! command -v golangci-lint >/dev/null 2>&1; then \ - echo "lint failed: golangci-lint is not installed"; \ - echo "install: brew install golangci-lint"; \ - exit 1; \ - elif ! find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print -quit | grep -q .; then \ - echo "skip lint: no Go files found"; \ - elif ! git rev-parse --verify HEAD >/dev/null 2>&1; then \ - echo "skip lint: git HEAD is missing"; \ - else \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" golangci-lint run ./...; \ - fi - -test-pkg pkg: - @if [ ! -f go.mod ]; then \ - echo "skip test-pkg: go.mod not initialized yet"; \ - else \ - pkg="{{pkg}}"; \ - if [ -d "$pkg" ]; then \ - if ls "$pkg"/*.go >/dev/null 2>&1; then \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go test "$pkg"; \ - else \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go test "$pkg/..."; \ - fi; \ - else \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go test "$pkg"; \ - fi; \ - fi - -build: - @if [ ! -f go.mod ]; then \ - echo "skip build: go.mod not initialized yet"; \ - elif [ ! -d ./cmd/autent-example ]; then \ - echo "skip build: ./cmd/autent-example not present"; \ - else \ - tmp_bin="$(mktemp -t autent-example.XXXXXX)"; \ - trap 'rm -f "$tmp_bin"' EXIT; \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go build -o "$tmp_bin" ./cmd/autent-example; \ - rm -f "$tmp_bin"; \ - trap - EXIT; \ - fi - -run: - @if [ ! -f go.mod ]; then \ - echo "run failed: go.mod not initialized yet"; \ - exit 1; \ - elif [ ! -d ./cmd/autent-example ]; then \ - echo "run failed: expected ./cmd/autent-example to exist"; \ - exit 1; \ - else \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go run ./cmd/autent-example; \ - fi - -[private] -coverage: - @if [ ! -f go.mod ]; then \ - echo "skip coverage: go.mod not initialized yet"; \ - elif ! find . -type f -name '*.go' -not -path './.git/*' -not -path './.tmp/*' -not -path './.cache/*' -print -quit | grep -q .; then \ - echo "skip coverage: no Go files found"; \ - else \ - tmp="$(mktemp)"; \ - GOFLAGS="${GOFLAGS:+$GOFLAGS }-buildvcs=false" go test ./... -cover | tee "$tmp"; \ - awk 'BEGIN {bad=0} \ - /^ok[[:space:]]/ && /coverage:/ { \ - covLine=$0; \ - sub(/^.*coverage:[[:space:]]*/, "", covLine); \ - sub(/%.*/, "", covLine); \ - cov=covLine+0; \ - if (cov < 70) { \ - print "coverage below 70%:", $2, covLine "%"; \ - bad=1; \ - } \ - } \ - END {exit bad}' "$tmp"; \ - rm -f "$tmp"; \ - fi - -[private] -release-check: - @if [ ! -f .goreleaser.yml ]; then \ - echo "release-check failed: .goreleaser.yml not found"; \ - exit 1; \ - elif ! command -v goreleaser >/dev/null 2>&1; then \ - echo "release-check failed: goreleaser is not installed"; \ - echo "install: brew install goreleaser"; \ - exit 1; \ - else \ - trap 'rm -rf dist' EXIT; \ - goreleaser check; \ - goreleaser build --snapshot --clean --single-target; \ - rm -rf dist; \ - trap - EXIT; \ - fi - -check: verify-bootstrap fmt-check lint test build - -ci: verify-bootstrap fmt-check lint test coverage build release-check diff --git a/README.md b/README.md index 1f20979..5ff6734 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ For subagent workflows, the recommended pattern is to issue a short-lived delega `autent` uses three different confidence levels when talking about platform support: - build confidence: GoReleaser cross-builds the example CLI release artifacts for macOS, Linux, and Windows -- CI confidence: `just check` runs in GitHub Actions on macOS, Linux, and Windows runners, while the heavier `just ci` gate remains Ubuntu-only +- CI confidence: `mage check` runs in GitHub Actions on macOS, Linux, and Windows runners, while the heavier `mage ci` gate remains Ubuntu-only - human runtime confidence: the documented example CLI flow has been exercised by hand on macOS That means the project currently has: - cross-built example CLI artifacts for the major desktop and server targets -- automated `just check` coverage on GitHub-hosted macOS, Linux, and Windows runners +- automated `mage check` coverage on GitHub-hosted macOS, Linux, and Windows runners - human-verified runtime behavior for the documented CLI flow on macOS Be precise when describing support. @@ -152,9 +152,21 @@ The Go module release surface is the SemVer tag itself; GoReleaser additionally ## Local Commands +Install Mage locally first: + +```bash +go install github.com/magefile/mage@v1.17.0 +``` + +Repository automation lives in `magefiles/`, and running `mage` from the repo root will auto-discover it. + +Then use the repository automation targets: + ```bash -just check -just ci +mage -l +mage check +mage ci +mage test-pkg ./app ``` ## SQLite Integration Modes diff --git a/magefiles/go.mod b/magefiles/go.mod new file mode 100644 index 0000000..a10bdb7 --- /dev/null +++ b/magefiles/go.mod @@ -0,0 +1,35 @@ +module github.com/evanmschultz/autent/magefiles + +go 1.26.1 + +require github.com/evanmschultz/laslig v0.1.1 + +require ( + charm.land/glamour/v2 v2.0.0 // indirect + charm.land/lipgloss/v2 v2.0.2 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/magefiles/go.sum b/magefiles/go.sum new file mode 100644 index 0000000..0c6ad36 --- /dev/null +++ b/magefiles/go.sum @@ -0,0 +1,68 @@ +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg= +github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20260323091123-df7b1bcffcca h1:ySF+Ei4e+tXO6q2XmFxYTSk29HfnnJw1PK4As+TJpkA= +github.com/charmbracelet/x/exp/golden v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o= +github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/evanmschultz/laslig v0.1.1 h1:vGV9hue1xeoiRWKnV3E4c+pywx3Z1jSBUKoErct23Kw= +github.com/evanmschultz/laslig v0.1.1/go.mod h1:7N2h1tPxjc6whmaRtl6n8JAKTd63bgBxVq/ilbWF754= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= diff --git a/magefiles/magefile.go b/magefiles/magefile.go new file mode 100644 index 0000000..224274a --- /dev/null +++ b/magefiles/magefile.go @@ -0,0 +1,621 @@ +// Package main defines Mage targets for autent repository automation. +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/evanmschultz/laslig" +) + +// Aliases preserves stable task names that previously used hyphenated just recipes. +var Aliases = map[string]any{ + "test-pkg": TestPkg, +} + +// taskPrinter renders human-facing automation status output. +var taskPrinter = laslig.New(os.Stdout, laslig.Policy{ + Format: laslig.FormatAuto, + Style: laslig.StyleAuto, +}) + +// Fmt formats Go source files with gofumpt when the repository is initialized. +func Fmt() error { + if err := renderSection("fmt"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("fmt", "go.mod not initialized yet") + } + if err := requireTool("gofumpt", "fmt failed: gofumpt is not installed", "brew install gofumpt"); err != nil { + return err + } + + goFiles, err := collectGoFiles(".") + if err != nil { + return err + } + if len(goFiles) == 0 { + return renderSkip("fmt", "no Go files found") + } + + if err := runCommand(nil, "gofumpt", append([]string{"-w"}, goFiles...)...); err != nil { + return err + } + return renderSuccess("fmt", "formatted Go sources") +} + +// Test runs the full Go test suite when Go sources are present. +func Test() error { + if err := renderSection("test"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("test", "go.mod not initialized yet") + } + + goFiles, err := collectGoFiles(".") + if err != nil { + return err + } + if len(goFiles) == 0 { + return renderSkip("test", "no Go files found") + } + + if err := runCommand(goCommandEnv(), "go", "test", "./..."); err != nil { + return err + } + return renderSuccess("test", "all packages passed") +} + +// Lint runs golangci-lint when the repository has Go sources and a valid git HEAD. +func Lint() error { + if err := renderSection("lint"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("lint", "go.mod not initialized yet") + } + if err := requireTool("golangci-lint", "lint failed: golangci-lint is not installed", "brew install golangci-lint"); err != nil { + return err + } + + goFiles, err := collectGoFiles(".") + if err != nil { + return err + } + if len(goFiles) == 0 { + return renderSkip("lint", "no Go files found") + } + if !gitHeadExists() { + return renderSkip("lint", "git HEAD is missing") + } + targets, err := listLintTargets() + if err != nil { + return err + } + if len(targets) == 0 { + return renderSkip("lint", "no Go packages found") + } + + if err := runCommand(nil, "golangci-lint", append([]string{"run"}, targets...)...); err != nil { + return err + } + return renderSuccess("lint", "lint passed") +} + +// TestPkg runs package-scoped Go tests, matching the previous just recipe behavior. +func TestPkg(pkg string) error { + if err := renderSection("test-pkg"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("test-pkg", "go.mod not initialized yet") + } + + target := pkg + if dirExists(pkg) { + hasDirectGoFiles, err := directoryHasDirectGoFiles(pkg) + if err != nil { + return err + } + if !hasDirectGoFiles { + target = filepath.ToSlash(filepath.Join(pkg, "...")) + } + } + + if err := runCommand(goCommandEnv(), "go", "test", target); err != nil { + return err + } + return renderSuccess("test-pkg", fmt.Sprintf("package tests passed for %s", pkg)) +} + +// Build verifies that the example program still compiles when it is present. +func Build() error { + if err := renderSection("build"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("build", "go.mod not initialized yet") + } + if !dirExists(filepath.Join("cmd", "autent-example")) { + return renderSkip("build", "./cmd/autent-example not present") + } + + tmpFile, err := os.CreateTemp("", "autent-example.*") + if err != nil { + return fmt.Errorf("create temporary build output: %w", err) + } + tmpPath := tmpFile.Name() + if closeErr := tmpFile.Close(); closeErr != nil { + return fmt.Errorf("close temporary build output: %w", closeErr) + } + defer os.Remove(tmpPath) + + if err := runCommand(goCommandEnv(), "go", "build", "-o", tmpPath, "./cmd/autent-example"); err != nil { + return err + } + return renderSuccess("build", "example build succeeded") +} + +// Run executes the example program for manual testing. +func Run() error { + if err := renderSection("run"); err != nil { + return err + } + if !fileExists("go.mod") { + return errors.New("run failed: go.mod not initialized yet") + } + if !dirExists(filepath.Join("cmd", "autent-example")) { + return errors.New("run failed: expected ./cmd/autent-example to exist") + } + + return runCommand(goCommandEnv(), "go", "run", "./cmd/autent-example") +} + +// Check runs the fast cross-platform contributor gate. +func Check() error { + if err := renderSection("check"); err != nil { + return err + } + if err := verifyBootstrap(); err != nil { + return err + } + if err := fmtCheck(); err != nil { + return err + } + if err := Lint(); err != nil { + return err + } + if err := Test(); err != nil { + return err + } + if err := Build(); err != nil { + return err + } + return renderSuccess("check", "canonical smoke gate passed") +} + +// Ci runs the full repository gate, including coverage and release validation. +func Ci() error { + if err := renderSection("ci"); err != nil { + return err + } + if err := verifyBootstrap(); err != nil { + return err + } + if err := fmtCheck(); err != nil { + return err + } + if err := Lint(); err != nil { + return err + } + if err := Test(); err != nil { + return err + } + if err := coverage(); err != nil { + return err + } + if err := Build(); err != nil { + return err + } + if err := releaseCheck(); err != nil { + return err + } + return renderSuccess("ci", "full gate passed") +} + +// verifyBootstrap validates the repository files required by automation and CI. +func verifyBootstrap() error { + if err := renderSection("verify-bootstrap"); err != nil { + return err + } + + for _, path := range bootstrapPaths() { + if !fileExists(path) { + return fmt.Errorf("verify-bootstrap failed: %s not found", path) + } + } + return renderSuccess("verify-bootstrap", "required automation files are present") +} + +// fmtCheck verifies that gofumpt would not rewrite any tracked Go sources. +func fmtCheck() error { + if err := renderSection("fmt-check"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("fmt-check", "go.mod not initialized yet") + } + if err := requireTool("gofumpt", "fmt-check failed: gofumpt is not installed", "brew install gofumpt"); err != nil { + return err + } + + goFiles, err := collectGoFiles(".") + if err != nil { + return err + } + if len(goFiles) == 0 { + return renderSkip("fmt-check", "no Go files found") + } + + output, err := runCommandOutput(nil, "gofumpt", append([]string{"-l"}, goFiles...)...) + if err != nil { + return err + } + out := strings.TrimSpace(output) + if out == "" { + return renderSuccess("fmt-check", "formatting already clean") + } + return fmt.Errorf("gofumpt required for:\n%s", out) +} + +// coverage enforces the repository's package coverage floor. +func coverage() error { + if err := renderSection("coverage"); err != nil { + return err + } + if !fileExists("go.mod") { + return renderSkip("coverage", "go.mod not initialized yet") + } + + goFiles, err := collectGoFiles(".") + if err != nil { + return err + } + if len(goFiles) == 0 { + return renderSkip("coverage", "no Go files found") + } + + output, err := runCommandTee(goCommandEnv(), "go", "test", "./...", "-cover") + if err != nil { + return err + } + + belowFloor := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + if !strings.HasPrefix(line, "ok") || !strings.Contains(line, "coverage:") { + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + idx := strings.Index(line, "coverage:") + if idx == -1 { + continue + } + covText := strings.TrimSpace(strings.TrimPrefix(line[idx:], "coverage:")) + percentIdx := strings.Index(covText, "%") + if percentIdx == -1 { + continue + } + + valueText := strings.TrimSpace(covText[:percentIdx]) + cov, parseErr := strconv.ParseFloat(valueText, 64) + if parseErr != nil { + return fmt.Errorf("parse coverage %q: %w", valueText, parseErr) + } + if cov < 70 { + belowFloor = append(belowFloor, fmt.Sprintf("%s %s%%", fields[1], valueText)) + } + } + + if len(belowFloor) > 0 { + return fmt.Errorf("coverage below 70%%:\n%s", strings.Join(belowFloor, "\n")) + } + return renderSuccess("coverage", "coverage floor satisfied") +} + +// releaseCheck validates GoReleaser configuration and snapshot buildability. +func releaseCheck() error { + if err := renderSection("release-check"); err != nil { + return err + } + if !fileExists(".goreleaser.yml") { + return errors.New("release-check failed: .goreleaser.yml not found") + } + if err := requireTool("goreleaser", "release-check failed: goreleaser is not installed", "brew install goreleaser"); err != nil { + return err + } + + defer os.RemoveAll("dist") + if err := runCommand(nil, "goreleaser", "check"); err != nil { + return err + } + if err := runCommand(nil, "goreleaser", "build", "--snapshot", "--clean", "--single-target"); err != nil { + return err + } + return renderSuccess("release-check", "release configuration validated") +} + +// bootstrapPaths returns the repository files required by the automation bootstrap. +func bootstrapPaths() []string { + return []string{ + "AGENTS.md", + "README.md", + filepath.Join("magefiles", "go.mod"), + filepath.Join("magefiles", "magefile.go"), + ".goreleaser.yml", + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "release.yml"), + } +} + +// renderSection prints a visual section header for one target. +func renderSection(title string) error { + return taskPrinter.Section(title) +} + +// renderSkip prints a warning-level skip message for a target. +func renderSkip(target string, reason string) error { + return taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeWarningLevel, + Label: "skip", + Text: target, + Detail: reason, + }) +} + +// renderSuccess prints a success summary for a target. +func renderSuccess(target string, detail string) error { + return taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeSuccessLevel, + Label: target, + Text: "ok", + Detail: detail, + }) +} + +// requireTool verifies that a binary exists on PATH before a target uses it. +func requireTool(name string, message string, installHint string) error { + if _, err := exec.LookPath(name); err == nil { + return nil + } + return errors.New(message + "\ninstall: " + installHint) +} + +// fileExists reports whether a file or directory exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// dirExists reports whether a directory exists. +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// collectGoFiles gathers Go sources while skipping local scratch and VCS directories. +func collectGoFiles(root string) ([]string, error) { + files := make([]string, 0) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if shouldSkipDir(path, entry.Name()) { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(entry.Name(), ".go") { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("collect Go files: %w", err) + } + + sort.Strings(files) + return files, nil +} + +// shouldSkipDir reports whether a directory should be excluded from repository scans. +func shouldSkipDir(path string, name string) bool { + if path == "." { + return false + } + switch name { + case ".git", ".tmp", ".cache", ".resources": + return true + default: + return false + } +} + +// directoryHasDirectGoFiles reports whether a directory contains Go files directly within it. +func directoryHasDirectGoFiles(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + return false, fmt.Errorf("read directory %s: %w", path, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(entry.Name(), ".go") { + return true, nil + } + } + return false, nil +} + +// gitHeadExists reports whether the repository currently has a resolvable HEAD commit. +func gitHeadExists() bool { + cmd := exec.Command("git", "rev-parse", "--verify", "HEAD") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() == nil +} + +// goCommandEnv returns the environment for Go-family tools that should suppress buildvcs metadata. +func goCommandEnv() []string { + return append(os.Environ(), "GOFLAGS="+mergedGOFLAGS()) +} + +// listLintTargets returns repo-relative package directories for the root module only. +func listLintTargets() ([]string, error) { + cmd := exec.Command("go", "list", "-f", "{{.Dir}}", "./...") + cmd.Env = goCommandEnv() + + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = os.Stderr + if err := taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Label: "run", + Text: commandString("go", "list", "-f", "{{.Dir}}", "./..."), + }); err != nil { + return nil, err + } + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%s: %w", commandString("go", "list", "-f", "{{.Dir}}", "./..."), err) + } + + output := strings.TrimSpace(stdout.String()) + if output == "" { + return nil, nil + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + + targets := make([]string, 0) + seen := make(map[string]struct{}) + for _, dir := range strings.Fields(output) { + rel, err := filepath.Rel(cwd, dir) + if err != nil { + return nil, fmt.Errorf("make %s relative to %s: %w", dir, cwd, err) + } + rel = filepath.ToSlash(rel) + + target := "." + if rel != "." { + target = "./" + rel + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + targets = append(targets, target) + } + + return targets, nil +} + +// mergedGOFLAGS appends the repository's default buildvcs override to any existing GOFLAGS. +func mergedGOFLAGS() string { + if current := strings.TrimSpace(os.Getenv("GOFLAGS")); current != "" { + return current + " -buildvcs=false" + } + return "-buildvcs=false" +} + +// runCommand executes a command while streaming output to the terminal. +func runCommand(env []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + if env != nil { + cmd.Env = env + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Label: "run", + Text: commandString(name, args...), + }); err != nil { + return err + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", commandString(name, args...), err) + } + return nil +} + +// runCommandOutput executes a command and returns its combined output. +func runCommandOutput(env []string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + if env != nil { + cmd.Env = env + } + if err := taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Label: "run", + Text: commandString(name, args...), + }); err != nil { + return "", err + } + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s: %w\n%s", commandString(name, args...), err, strings.TrimSpace(string(output))) + } + return string(output), nil +} + +// runCommandTee executes a command, streams output, and also captures it for later parsing. +func runCommandTee(env []string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + if env != nil { + cmd.Env = env + } + + var buffer bytes.Buffer + stdout := io.MultiWriter(os.Stdout, &buffer) + stderr := io.MultiWriter(os.Stderr, &buffer) + cmd.Stdout = stdout + cmd.Stderr = stderr + + if err := taskPrinter.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Label: "run", + Text: commandString(name, args...), + }); err != nil { + return "", err + } + if err := cmd.Run(); err != nil { + return buffer.String(), fmt.Errorf("%s: %w", commandString(name, args...), err) + } + return buffer.String(), nil +} + +// commandString formats a command line for human-facing status output. +func commandString(name string, args ...string) string { + parts := append([]string{name}, args...) + return strings.Join(parts, " ") +}