From ac7e3197b91c280d387500f1015baeeba35ad527 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 00:51:43 -0500 Subject: [PATCH 1/4] tpl: render go-app at master Template-Source: git@github.com:nnutter/go-template Template-Ref: master Template-Commit: 377e5147e4c2bde83c475c7a63f4de801d286a74 Answers-Digest: sha256:39511cffb191d17f4d0f49ee84412b38175cb3857e0313d5cf7ab492262bbd1d Tpl-Version: 0.13.0 --- .github/workflows/ci.yaml | 37 +++++ .github/workflows/update-tap.yaml | 158 +++++++++++++++++++++ LICENSE | 21 +++ go.mod | 18 +++ lefthook.toml | 4 + mise.toml | 228 ++++++++++++++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/update-tap.yaml create mode 100644 LICENSE create mode 100644 go.mod create mode 100644 lefthook.toml create mode 100644 mise.toml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9af4765 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validation: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.5 + + - name: Run fixers + run: mise run fixers + + - name: Run tests + run: mise run ci-tests + + - name: Run govulncheck + run: mise run lint-govulncheck diff --git a/.github/workflows/update-tap.yaml b/.github/workflows/update-tap.yaml new file mode 100644 index 0000000..883ad11 --- /dev/null +++ b/.github/workflows/update-tap.yaml @@ -0,0 +1,158 @@ +name: Update Tap + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to publish to nnutter/homebrew-tap + required: true + type: string + +permissions: + contents: read + +concurrency: + group: update-tap + cancel-in-progress: false + +jobs: + update-formula: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + FORMULA_NAME: timber + TAP_OWNER: nnutter + TAP_REPOSITORY: homebrew-tap + steps: + - name: Resolve release tag + id: release + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + tag="${EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$tag" ]]; then + echo "A release tag is required." >&2 + exit 1 + fi + if [[ "$tag" == */* ]]; then + echo "The release tag must not contain a slash." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Generate a token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ env.TAP_OWNER }} + repositories: ${{ env.TAP_REPOSITORY }} + permission-contents: write + permission-pull-requests: write + + - name: Check out the tap + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPOSITORY }} + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Skip when the formula already uses this tag + id: needed + env: + FORMULA_NAME: ${{ env.FORMULA_NAME }} + TAG: ${{ steps.release.outputs.tag }} + run: | + if grep -Fq "/archive/refs/tags/${TAG}.tar.gz" "Formula/${FORMULA_NAME}.rb" || + grep -Fq "/tarball/${TAG}" "Formula/${FORMULA_NAME}.rb"; then + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "Formula ${FORMULA_NAME} already uses ${TAG}." + else + echo "needed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Configure git for the app + if: steps.needed.outputs.needed == 'true' + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + + - name: Create the formula branch + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: formula-updates/${{ env.FORMULA_NAME }}-${{ steps.release.outputs.tag }} + run: git switch --create "$BRANCH_NAME" + + - name: Update the formula + if: steps.needed.outputs.needed == 'true' + env: + FORMULA_NAME: ${{ env.FORMULA_NAME }} + HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: bin/update-formula "$FORMULA_NAME" "$TAG" + + - name: Push the formula branch + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: formula-updates/${{ env.FORMULA_NAME }}-${{ steps.release.outputs.tag }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + run: | + git push --force-with-lease \ + "https://x-access-token:${GH_TOKEN}@github.com/${TAP_OWNER}/${TAP_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH_NAME}" + + - name: Open a pull request + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: formula-updates/${{ env.FORMULA_NAME }}-${{ steps.release.outputs.tag }} + FORMULA_NAME: ${{ env.FORMULA_NAME }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + UPSTREAM_REPOSITORY: ${{ github.repository }} + run: | + tap="${TAP_OWNER}/${TAP_REPOSITORY}" + existing_url="$( + gh pr list \ + --repo "$tap" \ + --head "$BRANCH_NAME" \ + --state open \ + --json url \ + --jq '.[0].url // empty' + )" + if [[ -n "$existing_url" ]]; then + echo "Pull request already exists: $existing_url" + exit 0 + fi + + gh pr create \ + --repo "$tap" \ + --title "Updated ${FORMULA_NAME} to ${TAG}" \ + --head "$BRANCH_NAME" \ + --base "$(gh repo view "$tap" --json defaultBranchRef --jq .defaultBranchRef.name)" \ + --body "Update ${FORMULA_NAME} to ${TAG} from ${UPSTREAM_REPOSITORY}." + + - name: Enable auto-merge + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: formula-updates/${{ env.FORMULA_NAME }}-${{ steps.release.outputs.tag }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + run: | + gh pr merge \ + --repo "${TAP_OWNER}/${TAP_REPOSITORY}" \ + --merge \ + --auto \ + "$BRANCH_NAME" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b874fb2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nathaniel Nutter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..234c513 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/nnutter/timber + +go 1.27.0 + +tool ( + github.com/Antonboom/testifylint + github.com/alecthomas/go-check-sumtype/cmd/go-check-sumtype + github.com/evilmartians/lefthook/v2 + github.com/kisielk/errcheck + github.com/nnutter/constable/cmd/constable + github.com/securego/gosec/v2/cmd/gosec + github.com/zricethezav/gitleaks/v8 + go.uber.org/nilaway/cmd/nilaway + golang.org/x/tools/cmd/goimports + golang.org/x/vuln/cmd/govulncheck + honnef.co/go/tools/cmd/staticcheck + mvdan.cc/gofumpt +) diff --git a/lefthook.toml b/lefthook.toml new file mode 100644 index 0000000..14da116 --- /dev/null +++ b/lefthook.toml @@ -0,0 +1,4 @@ +[pre-commit.commands.format] +glob = ["**/*.go"] +run = 'mise run pre-commit {staged_files}' +stage_fixed = true diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..8517003 --- /dev/null +++ b/mise.toml @@ -0,0 +1,228 @@ +[tools] +go = "1.27.0" + +##################### +# Development Tasks # +##################### + +[tasks.all] +description = "Run fixers, tests, and linters" +run = [{ task = "fixers" }, { task = "tests" }, { task = "linters" }] + +[tasks.ci-tests] +alias = "test" +description = "Run tests (with the race detector)" +run = "go test -race -shuffle=on ./..." + +[tasks.coverage] +alias = "cover" +description = "Run tests and generate coverage report" +run = "go test -coverprofile=coverage.out -covermode=atomic ./... && go tool cover -func=coverage.out" + +[tasks.install-hooks] +description = "Install Lefthook git hooks" +run = "go tool lefthook install" + +[tasks.refresh-template] +description = "Refresh from template" +run = "git tpl update && git tpl merge" + +[tasks.pre-commit] +description = "Format staged files" +usage = 'arg "[staged_files]" var=#true' +run = [ + "go tool gitleaks git --redact --pre-commit --staged", + "mise run fixers ${usage_staged_files}", +] + +[tasks.tests] +alias = "test" +description = "Run tests (with the race detector)" +run = "go test -cover ./..." + +############# +# Fix Tasks # +############# + +[tasks.fixers] +alias = "fix" +usage = 'arg "[staged_files]" var=#true' +run = [ + "mise run fix-go-mod-tidy", + "mise run fix-gofumpt ${usage_staged_files}", + "mise run fix-goimports ${usage_staged_files}", + "mise run fix-go-fix ${usage_staged_files}", + "mise run fix-testifylint ${usage_staged_files}", +] + +[tasks.fix-gofumpt] +description = "Run code formatting tools" +usage = 'arg "[staged_files]" var=#true' +run = ''' +#!/usr/bin/env bash +set -eo pipefail +if [ -z "${usage_staged_files:-}" ]; then + go tool gofumpt -l -w . + exit 0 +fi +go_files="" +for f in ${usage_staged_files}; do + case "$f" in *.go) go_files="$go_files $f" ;; esac +done +[ -z "$go_files" ] && exit 0 +# shellcheck disable=SC2086 +go tool gofumpt -l -w $go_files +''' + +[tasks.fix-goimports] +description = "Run code formatting tools" +usage = 'arg "[staged_files]" var=#true' +run = ''' +#!/usr/bin/env bash +set -eo pipefail +if [ -z "${usage_staged_files:-}" ]; then + go tool goimports -l -w -local $(go list -m) . + exit 0 +fi +go_files="" +for f in ${usage_staged_files}; do + case "$f" in *.go) go_files="$go_files $f" ;; esac +done +[ -z "$go_files" ] && exit 0 +# shellcheck disable=SC2086 +go tool goimports -l -w -local $(go list -m) $go_files +''' + +[tasks.fix-go-fix] +description = "Apply go fix" +usage = 'arg "[staged_files]" var=#true' +run = ''' +#!/usr/bin/env bash +set -eo pipefail +if [ -z "${usage_staged_files:-}" ]; then + go fix ./... + exit 0 +fi +dirs="" +for f in ${usage_staged_files}; do + case "$f" in + *.go) + d=$(dirname "$f") + case "$d" in ./*|/*) ;; *) d="./$d" ;; esac + dirs="$dirs $d" + ;; + esac +done +[ -z "$dirs" ] && exit 0 +# shellcheck disable=SC2086 +pkgs=$(printf '%s\n' $dirs | sort -u) +# shellcheck disable=SC2086 +go fix $pkgs +''' + +[tasks.fix-go-mod-tidy] +description = "Tidy go.mod and go.sum" +run = "go mod tidy" + +[tasks.fix-testifylint] +description = "Run testifylint static analysis" +usage = 'arg "[staged_files]" var=#true' +run = ''' +#!/usr/bin/env bash +set -eo pipefail +if [ -z "${usage_staged_files:-}" ]; then + go tool testifylint -fix ./... + exit 0 +fi +dirs="" +for f in ${usage_staged_files}; do + case "$f" in + *.go) + d=$(dirname "$f") + case "$d" in ./*|/*) ;; *) d="./$d" ;; esac + dirs="$dirs $d" + ;; + esac +done +[ -z "$dirs" ] && exit 0 +# shellcheck disable=SC2086 +pkgs=$(printf '%s\n' $dirs | sort -u) +# shellcheck disable=SC2086 +go tool testifylint -fix $pkgs +''' + +############## +# Lint Tasks # +############## + +[tasks.linters] +alias = "static-analysis" +run = [ + { task = "lint-constable" }, + { task = "lint-errcheck" }, + { task = "lint-gitleaks" }, + { task = "lint-go-check-sumtype" }, + { task = "lint-go-vet" }, + { task = "lint-gosec" }, + { task = "lint-govulncheck" }, + { task = "lint-nilaway" }, + { task = "lint-staticcheck" }, + { task = "lint-testifylint" }, +] + +[tasks.lint-constable] +description = "Checks nonmutating directives" +run = "go tool constable ./..." + +[tasks.lint-errcheck] +description = "Check for unhandled errors" +run = "go tool errcheck ./..." + +[tasks.lint-gitleaks] +description = "Scan the Git history for secrets" +run = "go tool gitleaks dir --verbose --redact ." + +[tasks.lint-go-check-sumtype] +description = "Check sealed sum-type switches for exhaustiveness" +run = "go tool go-check-sumtype -default-signifies-exhaustive=false -include-shared-interfaces ./..." + +[tasks.lint-go-vet] +description = "Run go vet static analysis" +run = "go vet ./..." + +[tasks.lint-gosec] +description = "Run gosec static analysis" +run = "go tool gosec -severity high -quiet ./..." + +[tasks.lint-govulncheck] +description = "Check for vulnerabilities" +run = "go tool govulncheck ./..." + +[tasks.lint-nilaway] +description = "Run nilaway on github.com/nnutter packages" +run = "go tool nilaway -include-pkgs $(go list -m) ./..." + +[tasks.lint-staticcheck] +description = "Run staticcheck static analysis" +run = "go tool staticcheck ./..." + +[tasks.lint-testifylint] +description = "Run testifylint static analysis" +run = "go tool testifylint ./..." + +##################### +# Agent Hooks # +##################### + +[tasks.on-start] +description = "Pi mise-hooks: run fixers, tests, and linters before agent starts" +run = [{ task = "all" }] + +[tasks.on-stop] +description = "Pi mise-hooks: fix edited files, then test and lint" +usage = 'arg "[staged_files]" var=#true' +run = [ + "mise run fixers ${usage_staged_files}", + "mise run tests", + "mise run linters", +] From 5b45d2c4d20ecb74fb93878b6a6c1c334bdf29ec Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 00:51:20 -0500 Subject: [PATCH 2/4] ttyd is not available on macOS so I'll use Homebrew --- mise.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/mise.toml b/mise.toml index d24e411..d76480a 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,5 @@ [tools] go = "1.27.0" -ttyd = "1.7.7" ##################### # Development Tasks # From b5d947ea4b2c600c2258542e58bdafee04b7af4b Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 00:59:25 -0500 Subject: [PATCH 3/4] Upgrade to Go 1.27.1 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3b7b55a..19e14e2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nnutter/timber -go 1.27.0 +go 1.27.1 require ( charm.land/fang/v2 v2.0.1 From 1e86163342c9ad154ca31247a1ce017590d49009 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 01:05:08 -0500 Subject: [PATCH 4/4] Unnecessary guards and also zsh is not required --- mise.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/mise.toml b/mise.toml index ca50034..b4c919d 100644 --- a/mise.toml +++ b/mise.toml @@ -14,8 +14,6 @@ description = "Require shell test dependencies and run the full suite" run = ''' #!/usr/bin/env bash set -euo pipefail -command -v zsh -command -v python3 python3 -c 'import os, pty; master, slave = pty.openpty(); os.close(master); os.close(slave)' go test -race -shuffle=on -cover ./... '''