diff --git a/.changelog/unreleased/51-go-ci-lane.md b/.changelog/unreleased/51-go-ci-lane.md new file mode 100644 index 0000000..b9d8e04 --- /dev/null +++ b/.changelog/unreleased/51-go-ci-lane.md @@ -0,0 +1 @@ +- **First-class Go lane.** New reusable `go-ci.yml` discovers every tracked `go.mod` via `git ls-files` (skipping gitignored build artifacts and generated dirs) and runs `go build` / `go vet` / `go test` per module, with optional codegen + dirty-tree drift check. `repo-required-gate.yml` gains `stack: go` and `go` support inside `polyglot` (via `go-paths`), plumbed through the classifier (`runGoCi`) and the `decision` gate. (#51) diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..f482eb1 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,200 @@ +name: Go CI (reusable) + +# Generic multi-module Go verification for repos with one or many Go modules. +# +# Module discovery uses `git ls-files`, NOT a filesystem `find`: tracked go.mod +# files only. This deliberately skips gitignored build artifacts (e.g. +# dist//go.mod) and never recurses into gitignored generated dirs +# whose conflicting package decls would break `go build ./...`. Each discovered +# module root is verified independently: `go build`, `go vet`, `go test`. +# +# Designed to be called from repo-required-gate.yml's `go-ci` job, but usable +# standalone. Every step is opt-out via inputs. + +on: + workflow_call: + inputs: + go-versions: + description: 'JSON array of Go versions for setup-go, e.g. `["1.26.x"]` or `["stable"]`.' + type: string + default: '["stable"]' + os-matrix: + description: 'JSON array of OS runners, e.g. `["ubuntu-latest"]`.' + type: string + default: '["ubuntu-latest"]' + exclude-modules: + description: | + Newline-separated path prefixes (relative to repo root) whose go.mod + roots are skipped — e.g. `tsunami/demo/`. A module dir is excluded + when `/` starts with any prefix. Comments (`#`) and blanks + ignored. + type: string + default: "" + run-build: + description: "Run `go build` per module. Empty/false skips." + type: boolean + default: true + run-vet: + description: "Run `go vet ./...` per module." + type: boolean + default: true + run-tests: + description: "Run `go test` per module." + type: boolean + default: true + build-args: + description: "Args for `go build` (word-split). Default builds all packages." + type: string + default: "./..." + test-args: + description: "Args for `go test` (word-split)." + type: string + default: "./..." + cgo-enabled: + description: "CGO_ENABLED override (`0` or `1`). Empty = Go default (1 when a C compiler is present)." + type: string + default: "" + cache: + description: "Enable setup-go module/build cache (keyed on all go.sum)." + type: boolean + default: true + setup-command: + description: | + Optional shell command run after checkout + Go setup, before + generate/verify (e.g. install Task or system deps). Empty = skip. + type: string + default: "" + generate-command: + description: | + Optional codegen command run once at repo root before module + verification (e.g. `task generate`). Empty = skip. + type: string + default: "" + verify-generated: + description: | + When true (and generate-command set), fail if codegen leaves a dirty + git tree — catches stale committed generated output (e.g. Go→TS + bindings). No-op when generate-command is empty. + type: boolean + default: false + +jobs: + ci: + name: ${{ matrix.os }} / go ${{ matrix.go }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + go: ${{ fromJSON(inputs.go-versions) }} + os: ${{ fromJSON(inputs.os-matrix) }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: ${{ inputs.cache }} + cache-dependency-path: ${{ inputs.cache && '**/go.sum' || '' }} + + - name: Setup command + if: inputs.setup-command != '' + shell: bash + run: ${{ inputs.setup-command }} + + - name: Generate + if: inputs.generate-command != '' + shell: bash + run: ${{ inputs.generate-command }} + + - name: Verify generated code is in sync + if: inputs.generate-command != '' && inputs.verify-generated + shell: bash + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::generate-command left a dirty tree — committed generated code is out of sync. Run the generate command locally and commit the result." + git --no-pager diff --stat + exit 1 + fi + echo "Generated code is in sync (clean tree)." + + - name: Discover and verify Go modules + shell: bash + env: + RUN_BUILD: ${{ inputs.run-build }} + RUN_VET: ${{ inputs.run-vet }} + RUN_TESTS: ${{ inputs.run-tests }} + BUILD_ARGS: ${{ inputs.build-args }} + TEST_ARGS: ${{ inputs.test-args }} + CGO_ENABLED_INPUT: ${{ inputs.cgo-enabled }} + EXCLUDE_MODULES: ${{ inputs.exclude-modules }} + run: | + set -euo pipefail + + if [ -n "$CGO_ENABLED_INPUT" ]; then + export CGO_ENABLED="$CGO_ENABLED_INPUT" + fi + + # Exclusion prefixes (strip comments + blanks). + excludes="$(printf '%s\n' "$EXCLUDE_MODULES" | sed -e 's/#.*//' -e 's/[[:space:]]*$//' | grep -v '^[[:space:]]*$' || true)" + + excluded() { + local dir="$1/" pre + [ -z "$excludes" ] && return 1 + while IFS= read -r pre; do + [ -z "$pre" ] && continue + case "$dir" in + "$pre"*) return 0 ;; + esac + done <<< "$excludes" + return 1 + } + + # Tracked go.mod roots only — git ls-files skips gitignored artifacts. + modules="$(git ls-files | grep -E '(^|/)go\.mod$' | xargs -r -n1 dirname | sort -u)" + if [ -z "$modules" ]; then + echo "::error::no tracked go.mod found; is stack=go correct for this repo?" + exit 1 + fi + + rc=0 + { + echo "### Go CI — module verification" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + while IFS= read -r dir; do + [ -z "$dir" ] && continue + if excluded "$dir"; then + echo "skip (exclude-modules): $dir" + echo "- \`$dir\` — skipped (exclude-modules)" >> "$GITHUB_STEP_SUMMARY" + continue + fi + echo "::group::module $dir" + if ( + cd "$dir" + go mod download + if [ "$RUN_BUILD" = "true" ]; then + # shellcheck disable=SC2086 + go build $BUILD_ARGS + fi + if [ "$RUN_VET" = "true" ]; then + go vet ./... + fi + if [ "$RUN_TESTS" = "true" ]; then + # shellcheck disable=SC2086 + go test $TEST_ARGS + fi + ); then + echo "- \`$dir\` — ok" >> "$GITHUB_STEP_SUMMARY" + else + rc=1 + echo "- \`$dir\` — FAILED" >> "$GITHUB_STEP_SUMMARY" + fi + echo "::endgroup::" + done <<< "$modules" + + if [ "$rc" -ne 0 ]; then + echo "::error::one or more Go modules failed verification (see step summary)." + fi + exit $rc diff --git a/.github/workflows/repo-required-gate.yml b/.github/workflows/repo-required-gate.yml index 07b1fed..4c22969 100644 --- a/.github/workflows/repo-required-gate.yml +++ b/.github/workflows/repo-required-gate.yml @@ -144,6 +144,51 @@ on: case-insensitive. type: string default: "ci:full" + # ---- Go lane (#51) --------------------------------------------------- + go-versions: + description: 'JSON array of Go versions for the Go lane, e.g. `["stable"]`.' + type: string + default: '["stable"]' + go-paths: + description: | + Newline-separated patterns selecting Go-owned roots for a polyglot + stack (same syntax as node-paths). For stack=polyglot, at least one + of node-paths / python-paths / go-paths must be set. Ignored for + non-polyglot stacks. + type: string + default: "" + go-exclude-modules: + description: "Newline-separated path prefixes whose go.mod roots the Go lane skips (e.g. `tsunami/demo/`)." + type: string + default: "" + go-build-args: + description: "Args for `go build` in the Go lane (word-split)." + type: string + default: "./..." + go-test: + description: "Run `go test` in the Go lane." + type: boolean + default: true + go-test-args: + description: "Args for `go test` in the Go lane (word-split)." + type: string + default: "./..." + go-cgo-enabled: + description: "CGO_ENABLED override for the Go lane (`0`/`1`). Empty = Go default." + type: string + default: "" + go-setup-command: + description: "Optional command run before Go verification (e.g. install Task)." + type: string + default: "" + go-generate-command: + description: "Optional codegen command run once before Go verification (e.g. `task generate`)." + type: string + default: "" + go-verify-generated: + description: "Fail if go-generate-command leaves a dirty tree (stale committed generated code)." + type: boolean + default: false workflow-library-ref: description: | Git ref (tag, branch, or SHA) of ArchonVII/github-workflows used @@ -166,6 +211,7 @@ jobs: run-ci: ${{ steps.detect.outputs.run-ci }} run-node-ci: ${{ steps.detect.outputs.run-node-ci }} run-python-ci: ${{ steps.detect.outputs.run-python-ci }} + run-go-ci: ${{ steps.detect.outputs.run-go-ci }} run-dependency-review: ${{ steps.detect.outputs.run-dependency-review }} run-workflow-validation: ${{ steps.detect.outputs.run-workflow-validation }} run-policy-validation: ${{ steps.detect.outputs.run-policy-validation }} @@ -193,6 +239,7 @@ jobs: STACK: ${{ inputs.stack }} NODE_PATHS: ${{ inputs.node-paths }} PYTHON_PATHS: ${{ inputs.python-paths }} + GO_PATHS: ${{ inputs.go-paths }} SNAPSHOT_PATHS: ${{ inputs.snapshot-paths }} SNAPSHOT_TEST_COMMAND: ${{ inputs.snapshot-test-command }} FORCE_FULL_CI_LABEL: ${{ inputs.force-full-ci-label }} @@ -239,6 +286,7 @@ jobs: stack: process.env.STACK, nodePaths: process.env.NODE_PATHS, pythonPaths: process.env.PYTHON_PATHS, + goPaths: process.env.GO_PATHS, snapshotPaths: process.env.SNAPSHOT_PATHS, snapshotTestCommand: process.env.SNAPSHOT_TEST_COMMAND, forceFullCiLabel: process.env.FORCE_FULL_CI_LABEL, @@ -252,6 +300,7 @@ jobs: core.setOutput('run-ci', String(result.outputs.runCi)); core.setOutput('run-node-ci', String(result.outputs.runNodeCi)); core.setOutput('run-python-ci', String(result.outputs.runPythonCi)); + core.setOutput('run-go-ci', String(result.outputs.runGoCi)); core.setOutput('run-dependency-review', String(result.outputs.runDependencyReview)); core.setOutput('run-workflow-validation', String(result.outputs.runWorkflowValidation)); core.setOutput('run-policy-validation', String(result.outputs.runPolicyValidation)); @@ -518,6 +567,25 @@ jobs: typecheck-command: ${{ inputs.python-typecheck-command }} test-command: ${{ inputs.python-test-command }} + go-ci: + name: go ci + needs: + - detect + - pr-contract + if: always() && needs.detect.outputs.ok == 'true' && needs.detect.outputs.run-go-ci == 'true' && (github.event_name != 'pull_request' || inputs.run-pr-contract == false || needs.pr-contract.result == 'success') + uses: ArchonVII/github-workflows/.github/workflows/go-ci.yml@v1 + with: + go-versions: ${{ inputs.go-versions }} + os-matrix: ${{ inputs.os-matrix }} + exclude-modules: ${{ inputs.go-exclude-modules }} + run-tests: ${{ inputs.go-test }} + build-args: ${{ inputs.go-build-args }} + test-args: ${{ inputs.go-test-args }} + cgo-enabled: ${{ inputs.go-cgo-enabled }} + setup-command: ${{ inputs.go-setup-command }} + generate-command: ${{ inputs.go-generate-command }} + verify-generated: ${{ inputs.go-verify-generated }} + snapshot-validation: name: snapshot validation needs: @@ -565,6 +633,7 @@ jobs: - dependency-review - node-ci - python-ci + - go-ci - snapshot-validation if: always() runs-on: ubuntu-latest @@ -576,6 +645,7 @@ jobs: OK: ${{ needs.detect.outputs.ok }} RUN_NODE_CI: ${{ needs.detect.outputs.run-node-ci }} RUN_PYTHON_CI: ${{ needs.detect.outputs.run-python-ci }} + RUN_GO_CI: ${{ needs.detect.outputs.run-go-ci }} RUN_DEPENDENCY_REVIEW: ${{ inputs.run-dependency-review && needs.detect.outputs.run-dependency-review == 'true' }} RUN_WORKFLOW_VALIDATION: ${{ inputs.run-workflow-validation && needs.detect.outputs.run-workflow-validation == 'true' }} RUN_POLICY_VALIDATION: ${{ inputs.run-policy-validation && needs.detect.outputs.run-policy-validation == 'true' }} @@ -587,6 +657,7 @@ jobs: DEPENDENCY_RESULT: ${{ needs.dependency-review.result }} NODE_RESULT: ${{ needs.node-ci.result }} PYTHON_RESULT: ${{ needs.python-ci.result }} + GO_RESULT: ${{ needs.go-ci.result }} SNAPSHOT_RESULT: ${{ needs.snapshot-validation.result }} EVENT_NAME: ${{ github.event_name }} RUN_PR_CONTRACT: ${{ inputs.run-pr-contract }} @@ -634,6 +705,10 @@ jobs: require_success "python ci" "$PYTHON_RESULT" fi + if [ "$RUN_GO_CI" = "true" ]; then + require_success "go ci" "$GO_RESULT" + fi + if [ "$RUN_SNAPSHOT_VALIDATION" = "true" ]; then require_success "snapshot validation" "$SNAPSHOT_RESULT" fi diff --git a/README.md b/README.md index 5855728..392af3e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ workflow, see [`docs/release-workflow-breakdown.md`](docs/release-workflow-break | [`repo-required-gate.yml`](.github/workflows/repo-required-gate.yml) | Always-reporting PR gate for branch protection. Detects changed files, runs relevant internal checks, and exposes one stable `repo-required-gate / decision` check. | [`examples/repo-required-gate.yml`](examples/repo-required-gate.yml) | | [`node-ci.yml`](.github/workflows/node-ci.yml) | Install + lint + typecheck + test for Node projects. Auto-detects `npm` / `pnpm` / `yarn` from lockfile. Matrix over Node versions and OS. | [`examples/node-ci.yml`](examples/node-ci.yml) | | [`python-ci.yml`](.github/workflows/python-ci.yml) | Install (uv or pip) + ruff lint + ruff format-check + pyright + pytest. Each step opt-out. Matrix over Python versions and OS. | [`examples/python-ci.yml`](examples/python-ci.yml) | +| [`go-ci.yml`](.github/workflows/go-ci.yml) | Discover every tracked `go.mod` (`git ls-files`, skipping build artifacts) and run `go build` / `go vet` / `go test` per module. Optional codegen + dirty-tree drift check. Matrix over Go versions and OS. | [`examples/go-ci.yml`](examples/go-ci.yml) | ### Agent workflow diff --git a/examples/go-ci.yml b/examples/go-ci.yml new file mode 100644 index 0000000..7594234 --- /dev/null +++ b/examples/go-ci.yml @@ -0,0 +1,39 @@ +# Standalone caller for the reusable Go CI in ArchonVII/github-workflows. +# Most repos consume Go CI through repo-required-gate.yml (stack: go, or +# go-paths under stack: polyglot) instead — use this only when you want a +# separate, non-gated Go check. +# +# Discovers every tracked go.mod (via `git ls-files`, skipping gitignored +# build artifacts) and runs build/vet/test per module. + +name: Go CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + uses: ArchonVII/github-workflows/.github/workflows/go-ci.yml@v1 + with: + go-versions: '["stable"]' + # os-matrix: '["ubuntu-latest","windows-latest"]' + # exclude-modules: | + # examples/ + # internal/demo/ + # cgo-enabled: "1" + # run-tests: true + # build-args: '-tags osusergo ./...' + # generate-command: task generate + # verify-generated: true diff --git a/examples/repo-required-gate.yml b/examples/repo-required-gate.yml index 5f2ae5b..16cdf71 100644 --- a/examples/repo-required-gate.yml +++ b/examples/repo-required-gate.yml @@ -32,7 +32,7 @@ jobs: # Pick ONE of the call shapes below for your consumer's CI. Comment the # others. The gate auto-routes PRs to the smallest lane that still gives # real signal: docs-only / workflow-only / policy-only / snapshot-refresh / - # code (node|python|polyglot) / forced-full (via the `ci:full` label). + # code (node|python|go|polyglot) / forced-full (via the `ci:full` label). # ------------------------------------------------------------------------- repo-required-gate: uses: ArchonVII/github-workflows/.github/workflows/repo-required-gate.yml@v1 @@ -51,11 +51,19 @@ jobs: # python-versions: '["3.12"]' # python-use-uv: true - # ------ Polyglot (Node + Python in one repo) ------------------------ - # When stack=polyglot, you MUST declare at least one of node-paths or - # python-paths. Files are matched with `path/**` (prefix), `path/` - # (prefix), or exact filename. node-ci runs only when a node-path file - # changed; python-ci runs only when a python-path file changed. + # ------ Go-only stack ----------------------------------------------- + # Discovers every tracked go.mod and runs build/vet/test per module. + # stack: go + # go-versions: '["stable"]' + # go-cgo-enabled: "1" # set if your build needs cgo + # go-exclude-modules: | # skip example/demo modules + # examples/ + + # ------ Polyglot (Node + Python + Go in one repo) ------------------- + # When stack=polyglot, you MUST declare at least one of node-paths, + # python-paths, or go-paths. Files match `path/**` (prefix), `path/` + # (prefix), or exact filename. Each language CI runs only when one of + # its own paths changed. # # stack: polyglot # node-versions: '["22"]' @@ -68,6 +76,31 @@ jobs: # package-lock.json # python-paths: | # analysis-service/** + # go-paths: | + # cmd/** + # pkg/** + # go.mod + # go.sum + + # ------ Node + Go polyglot (e.g. an Electron + Go-backend app) ------ + # stack: polyglot + # node-versions: '["22"]' + # npm-typecheck-script: typecheck + # node-paths: | + # frontend/** + # emain/** + # package.json + # package-lock.json + # go-paths: | + # cmd/** + # pkg/** + # go.mod + # go.sum + # tsunami/** + # go-exclude-modules: | + # tsunami/demo/ + # go-generate-command: task generate # optional: regen Go→TS bindings + # go-verify-generated: true # fail if generated output drifts # ------ Snapshot-refresh lane (any stack) --------------------------- # PRs that touch ONLY paths matching snapshot-paths skip language CI diff --git a/scripts/classify-pr.mjs b/scripts/classify-pr.mjs index c7440cb..a064a3e 100644 --- a/scripts/classify-pr.mjs +++ b/scripts/classify-pr.mjs @@ -13,10 +13,11 @@ // 5. policy-only — only AGENTS.md / .agent/** // 6. code (node) — stack=node + code/pkg touched // 7. code (python) — stack=python + code/pkg touched -// 8. code (polyglot: node) — polyglot + only node paths -// 9. code (polyglot: python) — polyglot + only python paths -// 10. code (polyglot: node+python) — polyglot + both -// 11. pass-through — no files / non-PR with minimal stack +// 8. code (go) — stack=go + code/pkg touched +// 9. code (polyglot: ) — polyglot + matched language +// paths (node / python / go, in +// that order, joined with `+`) +// 10. pass-through — no files / non-PR with minimal stack // // This module is intentionally pure: it accepts already-fetched PR data via // its function signature and performs no network or SDK calls. SDK access @@ -43,7 +44,7 @@ const DEFAULT_CODE_EXTENSIONS = const DEFAULT_PACKAGE_RE = /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|pyproject\.toml|requirements(-dev)?\.txt|uv\.lock|poetry\.lock|Pipfile(\.lock)?|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum)$/; -const VALID_STACKS = new Set(['node', 'python', 'minimal', 'polyglot']); +const VALID_STACKS = new Set(['node', 'python', 'go', 'minimal', 'polyglot']); /** * @typedef {Object} ClassifyInput @@ -70,6 +71,7 @@ const VALID_STACKS = new Set(['node', 'python', 'minimal', 'polyglot']); * @property {boolean} outputs.runCi Legacy aggregate; true if any language CI runs. * @property {boolean} outputs.runNodeCi * @property {boolean} outputs.runPythonCi + * @property {boolean} outputs.runGoCi * @property {boolean} outputs.runDependencyReview * @property {boolean} outputs.runWorkflowValidation * @property {boolean} outputs.runPolicyValidation @@ -92,6 +94,7 @@ export function classifyPR(input) { stack, nodePaths = '', pythonPaths = '', + goPaths = '', snapshotPaths = '', snapshotTestCommand = '', forceFullCiLabel = 'ci:full', @@ -111,6 +114,7 @@ export function classifyPR(input) { const nodePatterns = parsePatterns(nodePaths); const pythonPatterns = parsePatterns(pythonPaths); + const goPatterns = parsePatterns(goPaths); const snapshotPatterns = parsePatterns(snapshotPaths); const docPrefixList = parsePatterns(docPrefixes); @@ -120,10 +124,11 @@ export function classifyPR(input) { if ( stack === 'polyglot' && nodePatterns.length === 0 && - pythonPatterns.length === 0 + pythonPatterns.length === 0 && + goPatterns.length === 0 ) { errors.push( - 'stack=polyglot requires at least one of node-paths or python-paths to be set; both were empty.', + 'stack=polyglot requires at least one of node-paths, python-paths, or go-paths to be set; all were empty.', ); return failedResult(errors); } @@ -144,6 +149,7 @@ export function classifyPR(input) { runCi: stack !== 'minimal', runNodeCi: stack === 'node' || (stack === 'polyglot' && nodePatterns.length > 0), runPythonCi: stack === 'python' || (stack === 'polyglot' && pythonPatterns.length > 0), + runGoCi: stack === 'go' || (stack === 'polyglot' && goPatterns.length > 0), runDependencyReview: false, // dep-review only meaningful on PR diff runWorkflowValidation: true, runPolicyValidation: true, @@ -243,6 +249,11 @@ export function classifyPR(input) { names.some((name) => pythonPatterns.some((pat) => matchPattern(name, pat)), ); + const goTouched = + goPatterns.length > 0 && + names.some((name) => + goPatterns.some((pat) => matchPattern(name, pat)), + ); // -------- forced-full override ------------------------------------------- if (forcedFull) { @@ -255,6 +266,9 @@ export function classifyPR(input) { runPythonCi: stack === 'python' || (stack === 'polyglot' && pythonPatterns.length > 0), + runGoCi: + stack === 'go' || + (stack === 'polyglot' && goPatterns.length > 0), runDependencyReview: true, runWorkflowValidation: true, runPolicyValidation: true, @@ -280,6 +294,7 @@ export function classifyPR(input) { runCi: false, runNodeCi: false, runPythonCi: false, + runGoCi: false, runDependencyReview: false, runWorkflowValidation: false, runPolicyValidation: false, @@ -305,6 +320,7 @@ export function classifyPR(input) { runCi: false, runNodeCi: false, runPythonCi: false, + runGoCi: false, runDependencyReview: false, runWorkflowValidation: false, runPolicyValidation: false, @@ -330,6 +346,7 @@ export function classifyPR(input) { runCi: false, runNodeCi: false, runPythonCi: false, + runGoCi: false, runDependencyReview: false, runWorkflowValidation: true, runPolicyValidation: false, @@ -355,6 +372,7 @@ export function classifyPR(input) { runCi: false, runNodeCi: false, runPythonCi: false, + runGoCi: false, runDependencyReview: false, runWorkflowValidation: false, runPolicyValidation: true, @@ -387,20 +405,25 @@ export function classifyPR(input) { // Stack-dependent language-CI fan-out. let runNodeCi = false; let runPythonCi = false; + let runGoCi = false; if (stack === 'node') { runNodeCi = packageTouched || codeTouched; } else if (stack === 'python') { runPythonCi = packageTouched || codeTouched; + } else if (stack === 'go') { + runGoCi = packageTouched || codeTouched; } else if (stack === 'polyglot') { runNodeCi = nodeTouched; runPythonCi = pythonTouched; + runGoCi = goTouched; // Polyglot fallback: if package files were touched but path predicates // didn't match (e.g. root package.json + nothing else), run node CI by // convention because every npm-workspaces consumer has a root manifest. // Consumers that want stricter behavior should put package.json in - // node-paths explicitly (recommended in plan §1). - if (!runNodeCi && !runPythonCi && packageTouched) { + // node-paths explicitly (recommended in plan §1). Go consumers should + // list go.mod/go.sum in go-paths so module bumps route to go-ci directly. + if (!runNodeCi && !runPythonCi && !runGoCi && packageTouched) { runNodeCi = nodePatterns.length > 0; runPythonCi = nodePatterns.length === 0 && pythonPatterns.length > 0; } @@ -410,9 +433,10 @@ export function classifyPR(input) { const outputs = { docsOnly: false, - runCi: runNodeCi || runPythonCi, + runCi: runNodeCi || runPythonCi || runGoCi, runNodeCi, runPythonCi, + runGoCi, runDependencyReview: packageTouched, runWorkflowValidation: workflowOrHook, runPolicyValidation: policy, @@ -424,7 +448,7 @@ export function classifyPR(input) { return { ok: true, errors, - lane: codeLaneLabel(stack, runNodeCi, runPythonCi), + lane: codeLaneLabel(stack, runNodeCi, runPythonCi, runGoCi), outputs, jobsRequired: requiredJobs(outputs, true), jobsSkipped: skippedJobs(outputs, true), @@ -432,15 +456,17 @@ export function classifyPR(input) { }; } -function codeLaneLabel(stack, runNodeCi, runPythonCi) { +function codeLaneLabel(stack, runNodeCi, runPythonCi, runGoCi) { if (stack === 'polyglot') { - if (runNodeCi && runPythonCi) return 'code (polyglot: node+python)'; - if (runNodeCi) return 'code (polyglot: node)'; - if (runPythonCi) return 'code (polyglot: python)'; - return 'pass-through'; + const langs = []; + if (runNodeCi) langs.push('node'); + if (runPythonCi) langs.push('python'); + if (runGoCi) langs.push('go'); + return langs.length ? `code (polyglot: ${langs.join('+')})` : 'pass-through'; } if (stack === 'node' && runNodeCi) return 'code (node)'; if (stack === 'python' && runPythonCi) return 'code (python)'; + if (stack === 'go' && runGoCi) return 'code (go)'; if (stack === 'minimal') return 'pass-through (minimal)'; return 'pass-through'; } @@ -453,6 +479,7 @@ function requiredJobs(outputs, runPrContract) { if (outputs.runDependencyReview) jobs.push('dependency-review'); if (outputs.runNodeCi) jobs.push('node-ci'); if (outputs.runPythonCi) jobs.push('python-ci'); + if (outputs.runGoCi) jobs.push('go-ci'); if (outputs.runSnapshotValidation) jobs.push('snapshot-validation'); jobs.push('decision'); return jobs; @@ -465,6 +492,7 @@ function skippedJobs(outputs, isPullRequest) { 'dependency-review', 'node-ci', 'python-ci', + 'go-ci', 'snapshot-validation', ]; const required = new Set(requiredJobs(outputs, isPullRequest)); @@ -485,6 +513,7 @@ function failedResult(errors) { runCi: false, runNodeCi: false, runPythonCi: false, + runGoCi: false, runDependencyReview: false, runWorkflowValidation: false, runPolicyValidation: false, diff --git a/scripts/classify-pr.test.mjs b/scripts/classify-pr.test.mjs index 1802870..f74b52f 100644 --- a/scripts/classify-pr.test.mjs +++ b/scripts/classify-pr.test.mjs @@ -449,3 +449,122 @@ describe('classifyPR — interaction edge cases', () => { expect(r.lane).toBe('snapshot-refresh'); }); }); + +describe('classifyPR — stack=go (first-class Go lane, #51)', () => { + it('38. go code change → code (go), runs go-ci only', () => { + const r = classifyPR(input({ stack: 'go', files: ['pkg/wshrpc/foo.go'] })); + expect(r.ok).toBe(true); + expect(r.lane).toBe('code (go)'); + expect(r.outputs.runGoCi).toBe(true); + expect(r.outputs.runNodeCi).toBe(false); + expect(r.outputs.runPythonCi).toBe(false); + expect(r.outputs.runCi).toBe(true); + expect(r.jobsRequired).toContain('go-ci'); + expect(r.jobsRequired).toContain('decision'); + }); + + it('39. go.mod bump → runGoCi + runDependencyReview', () => { + const r = classifyPR(input({ stack: 'go', files: ['go.mod'] })); + expect(r.outputs.runGoCi).toBe(true); + expect(r.outputs.runDependencyReview).toBe(true); + }); + + it('40. go stack, docs-only PR → docs-only lane, go-ci skipped', () => { + const r = classifyPR(input({ stack: 'go', files: ['README.md'] })); + expect(r.lane).toBe('docs-only'); + expect(r.outputs.runGoCi).toBe(false); + expect(r.jobsSkipped).toEqual(expect.arrayContaining(['go-ci'])); + }); + + it('41. go stack push event → broad routing runs go-ci', () => { + const r = classifyPR(input({ stack: 'go', isPullRequest: false })); + expect(r.lane).toMatch(/push-event/); + expect(r.outputs.runGoCi).toBe(true); + expect(r.outputs.runNodeCi).toBe(false); + }); + + it('42. go stack + ci:full label → forced-full runs go-ci', () => { + const r = classifyPR( + input({ stack: 'go', files: ['README.md'], labels: ['ci:full'] }), + ); + expect(r.outputs.forcedFull).toBe(true); + expect(r.outputs.runGoCi).toBe(true); + }); + + it('43. go stack, minimal-invariant unaffected: stack=go + code is valid', () => { + const r = classifyPR(input({ stack: 'go', files: ['cmd/server/main.go'] })); + expect(r.ok).toBe(true); + expect(r.outputs.runGoCi).toBe(true); + }); +}); + +describe('classifyPR — polyglot with Go (node+go, #51)', () => { + // archon's real shape: Electron/React frontend + Go backend. + const NODE = 'frontend/**\nemain/**\npackage.json\npackage-lock.json'; + const GO = 'cmd/**\npkg/**\ngo.mod\ngo.sum\ntsunami/**'; + const PY = 'analysis-service/**'; + + it('44. polyglot, go-only PR → code (polyglot: go), go-ci only', () => { + const r = classifyPR( + input({ stack: 'polyglot', nodePaths: NODE, goPaths: GO, files: ['pkg/wshrpc/foo.go'] }), + ); + expect(r.lane).toBe('code (polyglot: go)'); + expect(r.outputs.runGoCi).toBe(true); + expect(r.outputs.runNodeCi).toBe(false); + expect(r.outputs.runPythonCi).toBe(false); + }); + + it('45. polyglot, node+go mixed → both lanes, label lists both', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + goPaths: GO, + files: ['frontend/app/app.tsx', 'pkg/service/foo.go'], + }), + ); + expect(r.lane).toBe('code (polyglot: node+go)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runGoCi).toBe(true); + }); + + it('46. polyglot with ONLY go-paths set satisfies the guardrail', () => { + const r = classifyPR( + input({ stack: 'polyglot', goPaths: GO, files: ['pkg/foo.go'] }), + ); + expect(r.ok).toBe(true); + expect(r.outputs.runGoCi).toBe(true); + }); + + it('47. polyglot with all three empty path lists → ok=false', () => { + const r = classifyPR( + input({ stack: 'polyglot', nodePaths: '', pythonPaths: '', goPaths: '' }), + ); + expect(r.ok).toBe(false); + expect(r.errors[0]).toMatch(/polyglot requires at least one/); + }); + + it('48. polyglot, node+python+go all touched → label lists all three in order', () => { + const r = classifyPR( + input({ + stack: 'polyglot', + nodePaths: NODE, + pythonPaths: PY, + goPaths: GO, + files: ['frontend/a.tsx', 'analysis-service/b.py', 'pkg/c.go'], + }), + ); + expect(r.lane).toBe('code (polyglot: node+python+go)'); + expect(r.outputs.runNodeCi).toBe(true); + expect(r.outputs.runPythonCi).toBe(true); + expect(r.outputs.runGoCi).toBe(true); + }); + + it('49. polyglot push event with go-paths → go-ci runs alongside node-ci', () => { + const r = classifyPR( + input({ stack: 'polyglot', nodePaths: NODE, goPaths: GO, isPullRequest: false }), + ); + expect(r.outputs.runGoCi).toBe(true); + expect(r.outputs.runNodeCi).toBe(true); + }); +});