diff --git a/.github/workflows/ci-autofix.yml b/.github/workflows/ci-autofix.yml new file mode 100644 index 0000000..ca8b75f --- /dev/null +++ b/.github/workflows/ci-autofix.yml @@ -0,0 +1,94 @@ +name: CI Auto-Fix + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: write + actions: write + +concurrency: + group: ci-autofix-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + autofix: + if: > + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_branch != '' + runs-on: ubuntu-latest + steps: + - name: Checkout failed branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Stop on stale branch head + id: freshness + run: | + current_sha="$(git rev-parse HEAD)" + expected_sha="${{ github.event.workflow_run.head_sha }}" + if [ "$current_sha" != "$expected_sha" ]; then + echo "should_skip=true" >> "$GITHUB_OUTPUT" + echo "Branch advanced from ${expected_sha} to ${current_sha}; skipping stale auto-fix run." + exit 0 + fi + echo "should_skip=false" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.freshness.outputs.should_skip != 'true' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Download dependencies + if: steps.freshness.outputs.should_skip != 'true' + run: go mod download + + - name: Run gofmt auto-fix + if: steps.freshness.outputs.should_skip != 'true' + run: gofmt -w . + + - name: Run golangci-lint auto-fix + id: golangci_autofix + if: steps.freshness.outputs.should_skip != 'true' + continue-on-error: true + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 + args: --fix + + - name: Detect changes + id: changes + if: steps.freshness.outputs.should_skip != 'true' + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No auto-fixable changes detected." + exit 0 + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Commit and push auto-fixes + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "ci: auto-fix formatting and lint" + git push origin HEAD:${{ github.event.workflow_run.head_branch }} + + - name: Re-dispatch CI + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches \ + -d '{"ref":"${{ github.event.workflow_run.head_branch }}"}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821b632..78f3975 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read @@ -35,6 +36,20 @@ jobs: - name: Download dependencies run: go mod download + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 + - name: Run tests run: go test -race -coverprofile=coverage.out ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..24062d5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,8 @@ +version: "2" + +linters: + default: none + enable: + - govet + - ineffassign + - unused diff --git a/README.md b/README.md index fb3a9d6..7c9eac4 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npx skills add https://github.com/disk0Dancer/climate --skill climate-generator ## Publish -Push a generated CLI to GitHub with CI and release workflows: +Push a generated CLI to GitHub with CI, CI auto-fix, and release workflows: ```bash climate publish myapi --owner disk0Dancer @@ -64,7 +64,7 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end | `list` | Show registered CLIs | | `remove` | Delete a generated CLI | | `upgrade` | Regenerate from updated spec | -| `publish` | Push CLI to GitHub with CI/release | +| `publish` | Push CLI to GitHub with CI/auto-fix/release | | `skill generate` | Emit agent skill prompt | ## Docs @@ -72,6 +72,7 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end - [Site](https://disk0dancer.github.io/climate/) - [LLM index](https://disk0dancer.github.io/climate/llms.txt) - [Compose design](docs/design-compose.md) +- [CI auto-fix design](docs/design-ci-autofix.md) - [Mock design](docs/design-mock.md) - [OpenAPI 3.0 support matrix](docs/openapi-3-support-matrix.md) diff --git a/cmd/climate/commands/compose.go b/cmd/climate/commands/compose.go index ba7ba07..9feb648 100644 --- a/cmd/climate/commands/compose.go +++ b/cmd/climate/commands/compose.go @@ -74,7 +74,7 @@ Examples: // Update manifest. mf, err := manifest.Load() if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err) } else { mf.Upsert(manifest.CLIEntry{ Name: result.CLIName, @@ -85,7 +85,7 @@ Examples: OpenAPISpec: opts.SpecSource, }) if saveErr := mf.Save(); saveErr != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) } } diff --git a/cmd/climate/commands/generate.go b/cmd/climate/commands/generate.go index 331bb23..c191c14 100644 --- a/cmd/climate/commands/generate.go +++ b/cmd/climate/commands/generate.go @@ -58,7 +58,7 @@ Examples: // Update manifest mf, err := manifest.Load() if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err) } else { mf.Upsert(manifest.CLIEntry{ Name: result.CLIName, @@ -69,7 +69,7 @@ Examples: OpenAPISpec: specSource, }) if saveErr := mf.Save(); saveErr != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) } } diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index 26323e5..bc1a069 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -79,7 +79,7 @@ Examples: if err != nil { exitError("Failed to emit event", err) } - fmt.Fprintf(cmd.OutOrStdout(), + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Emitted %s event from %s to %s (status: %d)\n", method, mockEventPath, mockEmitURL, statusCode) return nil @@ -89,14 +89,14 @@ Examples: latency := time.Duration(mockLatency) * time.Millisecond s := mock.NewServer(openAPI, addr, latency) - fmt.Fprintf(cmd.OutOrStdout(), "Mock server for %q listening on http://localhost%s\n", + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Mock server for %q listening on http://localhost%s\n", openAPI.Info.Title, addr) if mockLatency > 0 { - fmt.Fprintf(cmd.OutOrStdout(), "Artificial latency: %dms\n", mockLatency) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Artificial latency: %dms\n", mockLatency) } - fmt.Fprintln(cmd.OutOrStdout(), "\nRoutes:") - fmt.Fprint(cmd.OutOrStdout(), s.Summary()) - fmt.Fprintln(cmd.OutOrStdout(), "\nPress Ctrl+C to stop.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nRoutes:") + _, _ = fmt.Fprint(cmd.OutOrStdout(), s.Summary()) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nPress Ctrl+C to stop.") if err := s.ListenAndServe(); err != nil { exitError("Mock server error", err) diff --git a/cmd/climate/commands/publish.go b/cmd/climate/commands/publish.go index db70190..a08dac7 100644 --- a/cmd/climate/commands/publish.go +++ b/cmd/climate/commands/publish.go @@ -82,7 +82,7 @@ Authentication is read from --github-token, GITHUB_TOKEN, or GH_TOKEN.`, mf.Upsert(publish.PublishedManifestEntry(entry, result)) if saveErr := mf.Save(); saveErr != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) } writeJSON(result) diff --git a/cmd/climate/commands/skill.go b/cmd/climate/commands/skill.go index 30115e1..b6f11db 100644 --- a/cmd/climate/commands/skill.go +++ b/cmd/climate/commands/skill.go @@ -70,7 +70,7 @@ Modes: return nil } - fmt.Fprint(cmd.OutOrStdout(), prompt) + _, _ = fmt.Fprint(cmd.OutOrStdout(), prompt) return nil }, } @@ -86,7 +86,7 @@ This is the content of skills/climate.md shipped with climate. An LLM agent can read it to learn how to use climate and self-register it as the climate.generator skill.`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprint(cmd.OutOrStdout(), skills.ClimateMD) + _, _ = fmt.Fprint(cmd.OutOrStdout(), skills.ClimateMD) return nil }, } diff --git a/docs/design-ci-autofix.md b/docs/design-ci-autofix.md new file mode 100644 index 0000000..906d15f --- /dev/null +++ b/docs/design-ci-autofix.md @@ -0,0 +1,42 @@ +# CI Auto-Fix Design + +## Problem + +The primary CI workflow should remain deterministic and reviewable, but simple +formatting and auto-fixable lint failures are still noisy and time-consuming to +clean up by hand. + +## Goals + +- Keep `CI` as a check-only workflow. +- Run a separate workflow automatically after a failed `CI` run. +- Apply `gofmt` and `golangci-lint --fix` on same-repository branches. +- Push fixes back to the branch and explicitly re-dispatch `CI` on the updated + branch head. +- Reuse the same lifecycle behavior for repositories bootstrapped by + `climate publish`. + +## Non-Goals + +- Auto-fixing test, build, or `go vet` failures. +- Pushing changes to forked pull request branches. +- Rewriting user-managed workflow files that do not carry the climate marker. + +## Workflow Shape + +1. `CI` runs on `push`, `pull_request`, and `workflow_dispatch`. +2. `CI Auto-Fix` listens for failed `CI` completions via `workflow_run`. +3. The auto-fix job only runs when the failing branch belongs to the same + repository and the branch head still matches the SHA that failed. +4. The job runs `gofmt -w .` and `golangci-lint --fix`. +5. If changes were produced, the workflow commits and pushes them, then calls + the Actions dispatch API to run `CI` again on that branch. + +## Edge Cases + +- If the branch advanced after the failing `CI` run, the auto-fix job exits + without writing to a stale branch state. +- If the failure is not auto-fixable, the workflow exits cleanly with no commit. +- A push created with `GITHUB_TOKEN` does not trigger `push` workflows, so the + explicit re-dispatch step is required for the fixed commit to get a fresh + `CI` run. diff --git a/docs/index.md b/docs/index.md index dd10e9f..47b3293 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,7 +47,7 @@ petstore pet get --pet-id 1 | `list` | Show registered CLIs | | `remove` | Delete a generated CLI | | `upgrade` | Regenerate from updated spec | -| `publish` | Push CLI to GitHub with CI/release | +| `publish` | Push CLI to GitHub with CI/auto-fix/release | | `skill generate` | Emit agent skill prompt | ## Agent skills @@ -66,6 +66,7 @@ npx skills add https://github.com/disk0Dancer/climate --skill climate-generator ## Design docs - [Compose design](./design-compose.md) +- [CI auto-fix design](./design-ci-autofix.md) - [Mock design](./design-mock.md) - [OpenAPI 3.0 support matrix](./openapi-3-support-matrix.md) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 0de67cf..c2b677a 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -241,11 +241,11 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) if !seenVars[varName] { seenVars[varName] = true flagName := kebabCase(scheme.Name) + "-key" - authVarDecls.WriteString(fmt.Sprintf("\t%s string\n", varName)) - authFlagInits.WriteString(fmt.Sprintf( + _, _ = fmt.Fprintf(&authVarDecls, "\t%s string\n", varName) + _, _ = fmt.Fprintf(&authFlagInits, "\trootCmd.PersistentFlags().StringVar(&%s, %q, \"\", %q)\n", varName, flagName, "API key for "+scheme.Name, - )) + ) keyExpr := fmt.Sprintf(` if %s == "" { %s = os.Getenv(%q) @@ -253,21 +253,21 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) switch scheme.Spec.In { case "header": authHeadersBody.WriteString(keyExpr) - authHeadersBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authHeadersBody, ` if %s != "" { headers[%q] = %s } -`, varName, scheme.Spec.Name, varName)) +`, varName, scheme.Spec.Name, varName) case "query": authQueryBody.WriteString(keyExpr) - authQueryBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authQueryBody, ` if %s != "" { params[%q] = %s } -`, varName, scheme.Spec.Name, varName)) +`, varName, scheme.Spec.Name, varName) case "cookie": authHeadersBody.WriteString(keyExpr) - authHeadersBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authHeadersBody, ` if %s != "" { if existing, ok := headers["Cookie"]; ok { headers["Cookie"] = existing + "; " + %q + "=" + %s @@ -275,7 +275,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) headers["Cookie"] = %q + "=" + %s } } -`, varName, scheme.Spec.Name, varName, scheme.Spec.Name, varName)) +`, varName, scheme.Spec.Name, varName, scheme.Spec.Name, varName) } } @@ -285,7 +285,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) envVar := cliUpper + "_TOKEN" authVarDecls.WriteString("\tbearerToken string\n") authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&bearerToken, \"token\", \"\", \"Bearer token for authentication\")\n") - authHeadersBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authHeadersBody, ` { tok := bearerToken if tok == "" { @@ -295,7 +295,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) headers["Authorization"] = "Bearer " + tok } } -`, envVar)) +`, envVar) } case auth.SchemeHTTPBasic: @@ -306,7 +306,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) authVarDecls.WriteString("\tpassword string\n") authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&username, \"username\", \"\", \"Username for basic auth\")\n") authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&password, \"password\", \"\", \"Password for basic auth\")\n") - authHeadersBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authHeadersBody, ` { u := username if u == "" { @@ -320,7 +320,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(u+":"+p)) } } -`, cliUpper+"_USERNAME", cliUpper+"_PASSWORD")) +`, cliUpper+"_USERNAME", cliUpper+"_PASSWORD") } case auth.SchemeOAuth2: @@ -345,7 +345,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&oauth2Token, \"token\", \"\", \"OAuth2 access token (overrides client credentials flow)\")\n") authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&clientID, \"client-id\", \"\", \"OAuth2 client ID\")\n") authFlagInits.WriteString("\trootCmd.PersistentFlags().StringVar(&clientSecret, \"client-secret\", \"\", \"OAuth2 client secret\")\n") - authHeadersBody.WriteString(fmt.Sprintf(` + _, _ = fmt.Fprintf(&authHeadersBody, ` { tok := oauth2Token if tok == "" { @@ -375,7 +375,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) cliUpper+"_CLIENT_ID", cliUpper+"_CLIENT_SECRET", tokenURL, - )) + ) } } } @@ -621,7 +621,7 @@ func commandsGoContent(openAPI *spec.OpenAPI, cliName string) (string, error) { sb.WriteString("\t\"fmt\"\n") sb.WriteString("\t\"os\"\n") sb.WriteString("\t\"strings\"\n\n") - sb.WriteString(fmt.Sprintf("\t\"%s/internal/client\"\n", cliName)) + _, _ = fmt.Fprintf(&sb, "\t\"%s/internal/client\"\n", cliName) sb.WriteString("\t\"github.com/spf13/cobra\"\n") sb.WriteString(")\n\n") diff --git a/internal/githubutil/githubutil.go b/internal/githubutil/githubutil.go index 605e3a9..b4d6e43 100644 --- a/internal/githubutil/githubutil.go +++ b/internal/githubutil/githubutil.go @@ -173,7 +173,9 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body interface if err != nil { return fmt.Errorf("calling github api: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index fa1a9ae..7ffa1ec 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -18,9 +18,6 @@ func TestManifestLoadSave(t *testing.T) { t.Fatalf("LoadFrom() error = %v", err) } - if mf.List() == nil { - // nil slice is fine, but let's check length - } if len(mf.List()) != 0 { t.Errorf("expected empty manifest, got %d entries", len(mf.List())) } diff --git a/internal/mock/mock.go b/internal/mock/mock.go index f08309d..1fe48b7 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -171,7 +171,7 @@ func (s *Server) makeHandler(path string, item spec.PathItem) http.HandlerFunc { w.WriteHeader(statusCode) if encErr := json.NewEncoder(w).Encode(body); encErr != nil { // The header is already written; log to stderr as best effort. - fmt.Fprintf(w, `{"error":"response encoding failed"}`) + _, _ = fmt.Fprintf(w, `{"error":"response encoding failed"}`) } } } @@ -364,7 +364,9 @@ func EmitEvent(targetURL string, method string, payload interface{}) (int, error if err != nil { return 0, fmt.Errorf("send event: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() return resp.StatusCode, nil } diff --git a/internal/mock/mock_test.go b/internal/mock/mock_test.go index 8aab238..8110e84 100644 --- a/internal/mock/mock_test.go +++ b/internal/mock/mock_test.go @@ -91,7 +91,9 @@ func TestMock_GetList(t *testing.T) { if err != nil { t.Fatalf("GET /pets: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } @@ -114,7 +116,9 @@ func TestMock_GetByID(t *testing.T) { if err != nil { t.Fatalf("GET /pets/42: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } @@ -137,7 +141,9 @@ func TestMock_Post(t *testing.T) { if err != nil { t.Fatalf("POST /pets: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusCreated { t.Errorf("status = %d, want 201", resp.StatusCode) } @@ -150,7 +156,9 @@ func TestMock_Delete(t *testing.T) { if err != nil { t.Fatalf("DELETE /pets/1: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusNoContent { t.Errorf("status = %d, want 204", resp.StatusCode) } @@ -163,7 +171,9 @@ func TestMock_MethodNotAllowed(t *testing.T) { if err != nil { t.Fatalf("PUT /pets: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", resp.StatusCode) } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 40f5789..a5b7f8d 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -191,7 +191,9 @@ func ensureOriginRemote(sourceDir, remoteURL string) error { func commitLifecycleBootstrap(sourceDir, repoFullName string) error { messagePath := filepath.Join(sourceDir, ".climate-git-commit.txt") - defer os.Remove(messagePath) + defer func() { + _ = os.Remove(messagePath) + }() message := fmt.Sprintf(`Publish generated CLI as a managed GitHub project diff --git a/internal/publish/templates.go b/internal/publish/templates.go index 52bd417..279b27d 100644 --- a/internal/publish/templates.go +++ b/internal/publish/templates.go @@ -32,7 +32,9 @@ func EnsureLifecycleFiles(sourceDir string, meta ProjectMetadata) ([]string, err }{ {Path: "README.md", Marker: markdownManagedMarker, Content: readmeContent(meta)}, {Path: ".gitignore", Marker: textManagedMarker, Content: gitignoreContent()}, + {Path: ".golangci.yml", Marker: textManagedMarker, Content: golangciContent()}, {Path: filepath.Join(".github", "workflows", "ci.yml"), Marker: textManagedMarker, Content: ciWorkflowContent(meta)}, + {Path: filepath.Join(".github", "workflows", "ci-autofix.yml"), Marker: textManagedMarker, Content: ciAutofixWorkflowContent(meta)}, {Path: filepath.Join(".github", "workflows", "release.yml"), Marker: textManagedMarker, Content: releaseWorkflowContent(meta)}, } @@ -172,6 +174,19 @@ dist/ ` } +func golangciContent() string { + return textManagedMarker + ` +version: "2" + +linters: + default: none + enable: + - govet + - ineffassign + - unused +` +} + func ciWorkflowContent(meta ProjectMetadata) string { return fmt.Sprintf(`%s name: CI @@ -181,6 +196,10 @@ on: branches: [%s] pull_request: branches: [%s] + workflow_dispatch: + +permissions: + contents: read jobs: test: @@ -192,16 +211,145 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" + go-version-file: go.mod + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 - name: Run tests run: go test ./... + - name: Run go vet + run: go vet ./... + - name: Build run: go build ./... `, textManagedMarker, meta.DefaultBranch, meta.DefaultBranch) } +func ciAutofixWorkflowContent(meta ProjectMetadata) string { + return fmt.Sprintf(`%s +name: CI Auto-Fix + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: write + actions: write + +concurrency: + group: ci-autofix-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + autofix: + if: > + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_branch != '' + runs-on: ubuntu-latest + steps: + - name: Checkout failed branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Stop on stale branch head + id: freshness + run: | + current_sha="$(git rev-parse HEAD)" + expected_sha="${{ github.event.workflow_run.head_sha }}" + if [ "$current_sha" != "$expected_sha" ]; then + echo "should_skip=true" >> "$GITHUB_OUTPUT" + echo "Branch advanced from ${expected_sha} to ${current_sha}; skipping stale auto-fix run." + exit 0 + fi + echo "should_skip=false" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.freshness.outputs.should_skip != 'true' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Download dependencies + if: steps.freshness.outputs.should_skip != 'true' + run: go mod download + + - name: Run gofmt auto-fix + if: steps.freshness.outputs.should_skip != 'true' + run: gofmt -w . + + - name: Run golangci-lint auto-fix + id: golangci_autofix + if: steps.freshness.outputs.should_skip != 'true' + continue-on-error: true + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 + args: --fix + + - name: Detect changes + id: changes + if: steps.freshness.outputs.should_skip != 'true' + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No auto-fixable changes detected." + exit 0 + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Commit and push auto-fixes + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "ci: auto-fix formatting and lint" + git push origin HEAD:${{ github.event.workflow_run.head_branch }} + + - name: Re-dispatch CI + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + curl --fail --silent --show-error \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches \ + -d '{"ref":"${{ github.event.workflow_run.head_branch }}"}' +`, textManagedMarker) +} + func releaseWorkflowContent(meta ProjectMetadata) string { return fmt.Sprintf(`%s name: Release @@ -238,7 +386,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" + go-version-file: go.mod - name: Build binary env: diff --git a/internal/publish/templates_test.go b/internal/publish/templates_test.go index f20942d..daea5ac 100644 --- a/internal/publish/templates_test.go +++ b/internal/publish/templates_test.go @@ -25,8 +25,8 @@ func TestEnsureLifecycleFilesCreatesManagedFiles(t *testing.T) { if err != nil { t.Fatalf("EnsureLifecycleFiles() error = %v", err) } - if len(files) != 4 { - t.Fatalf("expected 4 managed files, got %d", len(files)) + if len(files) != 6 { + t.Fatalf("expected 6 managed files, got %d", len(files)) } readmeData, err := os.ReadFile(filepath.Join(dir, "README.md")) @@ -39,6 +39,36 @@ func TestEnsureLifecycleFilesCreatesManagedFiles(t *testing.T) { if !strings.Contains(string(readmeData), "go install github.com/disk0Dancer/petstore@latest") { t.Fatal("README should include install snippet") } + + ciData, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("reading ci workflow: %v", err) + } + if !strings.Contains(string(ciData), "workflow_dispatch:") { + t.Fatal("CI workflow should support workflow_dispatch") + } + if !strings.Contains(string(ciData), "Run golangci-lint") { + t.Fatal("CI workflow should include golangci-lint") + } + + autoFixData, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "ci-autofix.yml")) + if err != nil { + t.Fatalf("reading ci autofix workflow: %v", err) + } + if !strings.Contains(string(autoFixData), "workflow_run:") { + t.Fatal("CI autofix workflow should trigger from workflow_run") + } + if !strings.Contains(string(autoFixData), "actions/workflows/ci.yml/dispatches") { + t.Fatal("CI autofix workflow should re-dispatch CI after pushing fixes") + } + + golangciData, err := os.ReadFile(filepath.Join(dir, ".golangci.yml")) + if err != nil { + t.Fatalf("reading golangci config: %v", err) + } + if !strings.Contains(string(golangciData), "ineffassign") { + t.Fatal("golangci config should include baseline linters") + } } func TestEnsureLifecycleFilesPreservesUserEditedFiles(t *testing.T) { diff --git a/internal/spec/loader.go b/internal/spec/loader.go index cfbe0ff..a54b164 100644 --- a/internal/spec/loader.go +++ b/internal/spec/loader.go @@ -123,7 +123,9 @@ func fetchURL(rawURL string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("fetching %s: %w", rawURL, err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetching %s: HTTP %d", rawURL, resp.StatusCode) } diff --git a/internal/spec/utils.go b/internal/spec/utils.go index 9662d55..5b9e440 100644 --- a/internal/spec/utils.go +++ b/internal/spec/utils.go @@ -67,7 +67,9 @@ func FetchURL(rawURL string, timeout time.Duration) ([]byte, error) { if err != nil { return nil, fmt.Errorf("fetching %s: %w", rawURL, err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetching %s: HTTP %d", rawURL, resp.StatusCode) } diff --git a/skills/climate-generator/SKILL.md b/skills/climate-generator/SKILL.md index a7b6568..68d1979 100644 --- a/skills/climate-generator/SKILL.md +++ b/skills/climate-generator/SKILL.md @@ -104,7 +104,8 @@ climate publish [--owner ] [--repo ] [--visibility public|private] ``` This command creates or reuses a GitHub repository through the GitHub API, -writes lifecycle files, initializes git, and pushes the generated source tree. +writes lifecycle files including CI, CI auto-fix, and release workflows, +initializes git, and pushes the generated source tree. ### Print the built-in climate skill diff --git a/skills/climate.md b/skills/climate.md index e1f9df7..e7570c6 100644 --- a/skills/climate.md +++ b/skills/climate.md @@ -112,7 +112,7 @@ climate publish [--owner ] [--repo ] [--visibility public|private] ``` Creates or reuses a GitHub repository through the GitHub API, writes a -bootstrap README plus CI/release workflows, initializes git, and pushes the +bootstrap README plus CI/auto-fix/release workflows, initializes git, and pushes the generated source tree. Authentication is read from `--github-token`, `GITHUB_TOKEN`, or `GH_TOKEN`.