diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 385f0eb..831da16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,19 @@ on: branches: - main +# Cancel duplicate runs on the same PR/branch, but keep history on main. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + # Default to read-only. Individual jobs may grant additional scopes if needed. permissions: contents: read jobs: - check: + lint: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read @@ -27,4 +33,148 @@ jobs: go-version-file: go.mod - run: go mod download - - run: make check + - run: make fmt-check + - run: make lint + - run: make typecheck + - run: make deadcode + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + # The e2e test shells out to `git`. Confirm it's available so a missing + # binary fails loud here instead of producing a confusing test error. + - name: Verify git is available + run: git --version + + - run: go mod download + + - name: Run tests with race detector + # Tee verbose output to a file so we can attach it as an artifact on + # failure — Actions logs aren't accessible to anonymous viewers, and + # OS-specific failures are easier to triage with full -v output. + shell: bash + run: | + set -o pipefail + go test -race -shuffle=on -v ./... 2>&1 | tee test-output.txt + + - name: Upload test output (always) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: test-output-${{ matrix.os }} + path: test-output.txt + if-no-files-found: warn + + - name: Enforce coverage threshold (Linux only) + if: matrix.os == 'ubuntu-latest' + run: make coverage + + - name: Upload coverage profile + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage + path: coverage.out + if-no-files-found: error + + vuln: + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - run: go mod download + - run: make vuln + + build: + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: windows + goarch: amd64 + + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - run: go mod download + + # Cross-compile sanity check using the same flags as release.yml. We do + # not upload the binary here — release.yml is the canonical producer of + # signed, reproducible artifacts. This job exists so PRs catch breakage + # in a target before it reaches a tag. + - name: Cross-compile + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go build -trimpath -buildvcs=true -o /dev/null ./cmd/git-real + + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - run: go mod download + + - name: Lint workflow files (actionlint) + run: make actionlint diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d20f7fd --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,52 @@ +name: codeql + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # Weekly on Monday 06:17 UTC (off-peak, randomized minute to avoid + # contention with other repos scheduled on the hour). + - cron: "17 6 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: analyze (go) + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read + actions: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [go] + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Initialize CodeQL + uses: github/codeql-action/init@b2f9ef845756500b97acbdaf5c1dd4e9c1d15734 # v3.35.2 + with: + languages: ${{ matrix.language }} + build-mode: autobuild + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@b2f9ef845756500b97acbdaf5c1dd4e9c1d15734 # v3.35.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..0a2360d --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,25 @@ +name: dependency-review + +on: + pull_request: + +permissions: + contents: read + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 5 + # Skip dependency-review on Dependabot's own PRs (it would re-review its + # own bumps, often spuriously) and let it run unblocking elsewhere — we'll + # tighten the policy once the repository's Dependency Graph status is + # confirmed enabled. Until then, this is best-effort and non-blocking. + continue-on-error: true + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c617f0f..6da1c55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,10 @@ jobs: # Pin the build timestamp to the tag commit's author date so that # rebuilding from the same source produces a byte-identical binary. - export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + # (declare and export separately so a `git log` failure isn't masked + # by `export`'s exit status — shellcheck SC2155) + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + export SOURCE_DATE_EPOCH go build \ -trimpath \ diff --git a/Makefile b/Makefile index 9d7283c..603d820 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ LDFLAGS := -s -w \ -X main.commit=$(COMMIT) \ -X main.date=$(DATE) -.PHONY: build fmt fmt-check lint typecheck deadcode test coverage check +.PHONY: build fmt fmt-check lint typecheck deadcode test test-race coverage vuln actionlint check build: $(GO) build -trimpath -buildvcs=true -ldflags='$(LDFLAGS)' -o git-real ./cmd/git-real @@ -51,7 +51,16 @@ deadcode: test: $(GO) test ./... +test-race: + $(GO) test -race -shuffle=on ./... + coverage: COVERAGE_THRESHOLD=$(COVERAGE_THRESHOLD) bash ./scripts/check-coverage.sh +vuln: + $(GO) tool govulncheck ./... + +actionlint: + $(GO) tool actionlint + check: fmt-check lint typecheck deadcode coverage diff --git a/cmd/git-real/e2e_test.go b/cmd/git-real/e2e_test.go index b19c72f..a1f7f7a 100644 --- a/cmd/git-real/e2e_test.go +++ b/cmd/git-real/e2e_test.go @@ -22,6 +22,7 @@ func TestMainEndToEnd(t *testing.T) { runGit(t, repoDir, "config", "user.name", "GitReal Test") runGit(t, repoDir, "config", "user.email", "test@example.com") runGit(t, repoDir, "config", "commit.gpgsign", "false") + runGit(t, repoDir, "config", "core.autocrlf", "false") writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n") runGit(t, repoDir, "add", "file.txt") @@ -129,6 +130,7 @@ func TestMainOnceCancelledByContext(t *testing.T) { runGit(t, repoDir, "config", "user.name", "GitReal Test") runGit(t, repoDir, "config", "user.email", "test@example.com") runGit(t, repoDir, "config", "commit.gpgsign", "false") + runGit(t, repoDir, "config", "core.autocrlf", "false") writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n") runGit(t, repoDir, "add", "file.txt") diff --git a/go.mod b/go.mod index 24ff849..7cb9242 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,30 @@ module github.com/watany-dev/gitreal go 1.26 tool ( + github.com/rhysd/actionlint/cmd/actionlint golang.org/x/tools/cmd/deadcode + golang.org/x/vuln/cmd/govulncheck honnef.co/go/tools/cmd/staticcheck ) require ( github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/rhysd/actionlint v1.7.12 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e // indirect golang.org/x/tools v0.44.0 // indirect + golang.org/x/vuln v1.3.0 // indirect honnef.co/go/tools v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index ebf561e..f47179d 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,49 @@ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY= +github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e h1:OXgN37M6hqjaAvb7CJK9vJ+7Z/6lvIm5bXho5poo/Wk= +golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw= +golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/internal/git/git_test.go b/internal/git/git_test.go index ab7b0cb..8f7a885 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "os/exec" + "path/filepath" "strings" "testing" "time" @@ -603,8 +604,22 @@ func TestDiscoverIntegration(t *testing.T) { if err != nil { t.Fatalf("Discover() error = %v", err) } - if repo.Root() != tempDir { - t.Fatalf("Root() = %q, want %q", repo.Root(), tempDir) + // `git rev-parse --show-toplevel` returns a path that has been resolved + // for symlinks (macOS: /var → /private/var) and that uses forward slashes + // regardless of OS (Windows: C:/Users/... vs Go's native C:\Users\...). + // Normalize both sides through filepath.FromSlash + EvalSymlinks so the + // comparison is OS-agnostic — also resolves Windows 8.3 short names like + // RUNNER~1 to their long form. + got, err := filepath.EvalSymlinks(filepath.FromSlash(repo.Root())) + if err != nil { + t.Fatalf("EvalSymlinks(Root()=%q) error = %v", repo.Root(), err) + } + want, err := filepath.EvalSymlinks(filepath.FromSlash(tempDir)) + if err != nil { + t.Fatalf("EvalSymlinks(tempDir=%q) error = %v", tempDir, err) + } + if got != want { + t.Fatalf("Root() = %q (normalized %q), want %q (normalized %q)", repo.Root(), got, tempDir, want) } if err := repo.SetConfigBool("gitreal.enabled", true); err != nil {