diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index dca0259..35b95ea 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -1,4 +1,4 @@ -name: Build and Push +name: CI on: push: @@ -7,13 +7,109 @@ on: branches: [main] env: + NTFY_URL: http://ntfy.observability.svc.cluster.local:80 REGISTRY: gitea-http.gitea.svc.cluster.local:3000/daviestechlabs REGISTRY_HOST: gitea-http.gitea.svc.cluster.local:3000 IMAGE_NAME: ntfy-discord jobs: - build: + lint: + name: Lint runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run go vet + run: go vet ./... + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run golangci-lint + run: golangci-lint run ./... + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify dependencies + run: go mod verify + + - name: Build + run: go build -v ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + release: + name: Release + runs-on: ubuntu-latest + needs: [lint, test] + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version bump + id: version + run: | + # Get latest tag or default to v0.0.0 + LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + VERSION=${LATEST#v} + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Check commit message for keywords + MSG="${{ gitea.event.head_commit.message }}" + if echo "$MSG" | grep -qiE "^major:|BREAKING CHANGE"; then + MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 + BUMP="major" + elif echo "$MSG" | grep -qiE "^(minor:|feat:)"; then + MINOR=$((MINOR + 1)); PATCH=0 + BUMP="minor" + else + PATCH=$((PATCH + 1)) + BUMP="patch" + fi + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "bump=$BUMP" >> $GITHUB_OUTPUT + echo "Bumping $LATEST → $NEW_VERSION ($BUMP)" + + - name: Create and push tag + run: | + git config user.name "gitea-actions[bot]" + git config user.email "actions@git.daviestechlabs.io" + git tag -a ${{ steps.version.outputs.version }} -m "Release ${{ steps.version.outputs.version }}" + git push origin ${{ steps.version.outputs.version }} + + docker: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: [lint, test, release] + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' steps: - name: Checkout uses: actions/checkout@v4 @@ -34,7 +130,6 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Configure Docker for insecure registry - if: github.event_name != 'pull_request' run: | sudo mkdir -p /etc/docker echo '{"insecure-registries": ["${{ env.REGISTRY_HOST }}"]}' | sudo tee /etc/docker/daemon.json @@ -42,7 +137,6 @@ jobs: sleep 2 - name: Login to Gitea Registry - if: github.event_name != 'pull_request' run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin @@ -52,35 +146,50 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}},value=${{ needs.release.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.version }} type=raw,value=latest,enable={{is_default_branch}} - name: Build and push uses: docker/build-push-action@v5 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - test: + notify: + name: Notify runs-on: ubuntu-latest + needs: [lint, test, release, docker] + if: always() steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Notify on success + if: needs.lint.result == 'success' && needs.test.result == 'success' + run: | + curl -s \ + -H "Title: ✅ CI Passed: ${{ gitea.repository }}" \ + -H "Priority: default" \ + -H "Tags: white_check_mark,github" \ + -H "Click: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \ + -d "Branch: ${{ gitea.ref_name }} + Commit: ${{ gitea.event.head_commit.message || gitea.sha }} + Release: ${{ needs.release.result == 'success' && needs.release.outputs.version || 'skipped' }} + Docker: ${{ needs.docker.result }}" \ + ${{ env.NTFY_URL }}/gitea-ci - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - - name: Run tests - run: go test -v ./... - - - name: Run go vet - run: go vet ./... + - name: Notify on failure + if: needs.lint.result == 'failure' || needs.test.result == 'failure' + run: | + curl -s \ + -H "Title: ❌ CI Failed: ${{ gitea.repository }}" \ + -H "Priority: high" \ + -H "Tags: x,github" \ + -H "Click: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \ + -d "Branch: ${{ gitea.ref_name }} + Commit: ${{ gitea.event.head_commit.message || gitea.sha }} + Lint: ${{ needs.lint.result }} + Test: ${{ needs.test.result }}" \ + ${{ env.NTFY_URL }}/gitea-ci diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c818641 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,31 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - unconvert + - bodyclose + - nilerr + settings: + errcheck: + check-type-assertions: true + misspell: + locale: US + +formatters: + enable: + - gofmt + - goimports + +issues: + max-issues-per-linter: 50 + max-same-issues: 10 diff --git a/internal/config/config.go b/internal/config/config.go index 6348877..c33de1d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -180,7 +180,7 @@ func (c *Config) watchFileSecrets(ctx context.Context) { slog.Error("failed to create fsnotify watcher", "error", err) return } - defer watcher.Close() + defer func() { _ = watcher.Close() }() // Watch the secrets directory // Kubernetes updates secrets by changing the symlink, so watch the parent diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d37d821..112e7ac 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,8 +10,7 @@ import ( func TestGetEnv(t *testing.T) { // Set a test env var - os.Setenv("TEST_CONFIG_VAR", "test_value") - defer os.Unsetenv("TEST_CONFIG_VAR") + t.Setenv("TEST_CONFIG_VAR", "test_value") tests := []struct { name string @@ -112,12 +111,8 @@ func TestConfig_LoadWebhookFromSecret_TrimsWhitespace(t *testing.T) { } func TestLoad_ParsesTopics(t *testing.T) { - os.Setenv("NTFY_TOPICS", "alerts, updates , notifications") - os.Setenv("VAULT_ENABLED", "false") - defer func() { - os.Unsetenv("NTFY_TOPICS") - os.Unsetenv("VAULT_ENABLED") - }() + t.Setenv("NTFY_TOPICS", "alerts, updates , notifications") + t.Setenv("VAULT_ENABLED", "false") cfg, err := Load(context.Background()) if err != nil { @@ -138,9 +133,9 @@ func TestLoad_ParsesTopics(t *testing.T) { func TestLoad_Defaults(t *testing.T) { // Clear any existing env vars - os.Unsetenv("NTFY_URL") - os.Unsetenv("HTTP_PORT") - os.Unsetenv("VAULT_ENABLED") + t.Setenv("NTFY_URL", "") + t.Setenv("HTTP_PORT", "") + t.Setenv("VAULT_ENABLED", "") cfg, err := Load(context.Background()) if err != nil { @@ -161,8 +156,7 @@ func TestLoad_Defaults(t *testing.T) { } func TestLoad_VaultEnabled(t *testing.T) { - os.Setenv("VAULT_ENABLED", "true") - defer os.Unsetenv("VAULT_ENABLED") + t.Setenv("VAULT_ENABLED", "true") // This will fail to init Vault (no server), but should gracefully fall back cfg, err := Load(context.Background()) @@ -182,12 +176,8 @@ func TestLoad_VaultEnabled(t *testing.T) { func TestLoad_FallsBackToEnvVar(t *testing.T) { webhookURL := "https://discord.com/api/webhooks/env/test" - os.Setenv("DISCORD_WEBHOOK_URL", webhookURL) - os.Setenv("VAULT_ENABLED", "false") - defer func() { - os.Unsetenv("DISCORD_WEBHOOK_URL") - os.Unsetenv("VAULT_ENABLED") - }() + t.Setenv("DISCORD_WEBHOOK_URL", webhookURL) + t.Setenv("VAULT_ENABLED", "false") cfg, err := Load(context.Background()) if err != nil { diff --git a/internal/config/fuzz_test.go b/internal/config/fuzz_test.go index be12c2b..8dc5370 100644 --- a/internal/config/fuzz_test.go +++ b/internal/config/fuzz_test.go @@ -25,10 +25,7 @@ func FuzzParseTopics(f *testing.F) { return } - topics := make([]string, 0) - for _, topic := range splitAndTrim(input) { - topics = append(topics, topic) - } + topics := append([]string{}, splitAndTrim(input)...) // Accessing results should not panic _ = len(topics) diff --git a/internal/discord/fuzz_test.go b/internal/discord/fuzz_test.go index 90fc801..4c3f4a7 100644 --- a/internal/discord/fuzz_test.go +++ b/internal/discord/fuzz_test.go @@ -116,10 +116,7 @@ func FuzzWebhookPayloadJSON(f *testing.F) { } // Marshaling should not panic - _, err := json.Marshal(payload) - if err != nil { - // JSON encoding errors are acceptable for invalid UTF-8 - // but should not panic - } + // Marshaling should not panic; JSON encoding errors are acceptable for invalid UTF-8 + _, _ = json.Marshal(payload) }) } diff --git a/internal/discord/webhook.go b/internal/discord/webhook.go index 2813502..2c3dd35 100644 --- a/internal/discord/webhook.go +++ b/internal/discord/webhook.go @@ -117,7 +117,7 @@ func (c *Client) Send(ctx context.Context, webhookURL string, msg ntfy.Message) if err != nil { return fmt.Errorf("send request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Handle rate limiting if resp.StatusCode == http.StatusTooManyRequests { diff --git a/internal/ntfy/client.go b/internal/ntfy/client.go index 9fb92d7..4f47139 100644 --- a/internal/ntfy/client.go +++ b/internal/ntfy/client.go @@ -61,7 +61,7 @@ func (c *Client) Subscribe(ctx context.Context, msgCh chan<- Message) { err := c.connect(ctx, msgCh) if err != nil { if ctx.Err() != nil { - return // Context cancelled + return // Context canceled } slog.Error("ntfy connection failed", "error", err, "backoff", backoff) time.Sleep(backoff) @@ -90,7 +90,7 @@ func (c *Client) connect(ctx context.Context, msgCh chan<- Message) error { if err != nil { return fmt.Errorf("connect: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) diff --git a/internal/ntfy/client_test.go b/internal/ntfy/client_test.go index c8e3dd1..b778211 100644 --- a/internal/ntfy/client_test.go +++ b/internal/ntfy/client_test.go @@ -80,7 +80,7 @@ func TestClient_Subscribe_ReceivesMessages(t *testing.T) { for _, msg := range messages { data, _ := json.Marshal(msg) - fmt.Fprintf(w, "%s\n", data) + _, _ = fmt.Fprintf(w, "%s\n", data) } })) defer server.Close() @@ -134,7 +134,7 @@ func TestClient_Subscribe_FilterEvents(t *testing.T) { w.WriteHeader(http.StatusOK) for _, msg := range messages { data, _ := json.Marshal(msg) - fmt.Fprintf(w, "%s\n", data) + _, _ = fmt.Fprintf(w, "%s\n", data) } })) defer server.Close() @@ -194,7 +194,7 @@ func TestClient_Subscribe_ContextCancellation(t *testing.T) { func TestClient_connect_ServerError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) + _, _ = w.Write([]byte("internal error")) })) defer server.Close() @@ -218,7 +218,7 @@ func TestClient_connect_URLConstruction(t *testing.T) { defer server.Close() client := NewClient(server.URL, []string{"alerts", "updates"}) - client.connect(context.Background(), make(chan Message)) + _ = client.connect(context.Background(), make(chan Message)) expected := "/alerts,updates/json" if requestedURL != expected { diff --git a/internal/server/server.go b/internal/server/server.go index 33c763c..c229590 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,7 +71,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) + _ = json.NewEncoder(w).Encode(status) } func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { @@ -89,5 +89,5 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) + _ = json.NewEncoder(w).Encode(status) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8a7eea2..375fedd 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,7 +13,7 @@ func TestServer_HealthEndpoint_StatusCodes(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok","healthy":true}`)) + _, _ = w.Write([]byte(`{"status":"ok","healthy":true}`)) }) req := httptest.NewRequest(http.MethodGet, "/health", nil) @@ -35,7 +35,7 @@ func TestServer_ReadyEndpoint_StatusCodes(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ready","ready":true}`)) + _, _ = w.Write([]byte(`{"status":"ready","ready":true}`)) }) req := httptest.NewRequest(http.MethodGet, "/ready", nil) @@ -56,7 +56,7 @@ func TestServer_Shutdown(t *testing.T) { } // Start in background - go srv.ListenAndServe() + go func() { _ = srv.ListenAndServe() }() // Give it a moment to start time.Sleep(10 * time.Millisecond) @@ -77,7 +77,7 @@ func TestServer_MetricsEndpoint(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("# metrics here")) + _, _ = w.Write([]byte("# metrics here")) }) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) diff --git a/main.go b/main.go index 74c79dd..17d893a 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,7 @@ func main() { slog.Error("failed to load config", "error", err) os.Exit(1) } - defer cfg.Close() + defer func() { _ = cfg.Close() }() // Start the bridge b := bridge.New(cfg)