diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d09a048 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "go" + + - package-ecosystem: "npm" + directory: "/web" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "javascript" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53bac4d..06ca164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,20 @@ concurrency: cancel-in-progress: true jobs: + repo-refs: + name: Repo reference guard + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for stale/broken repo slug references + run: ./scripts/check-repo-refs.sh + + - name: Check the version is consistent everywhere it is written down + run: ./scripts/check-version.sh + lint-test: name: Lint & Test runs-on: ubuntu-latest @@ -35,21 +49,53 @@ jobs: cache: npm cache-dependency-path: web/package-lock.json + # `make web-deps`, not a bare `npm ci`: `npm ci` refuses to run at all when + # web/package-lock.json does not match web/package.json, and the lock in this + # tree predates the vitest/eslint devDependencies. web-deps tries `npm ci` + # first and falls back to `npm install`, printing the real fix either way. - name: Install web deps - run: cd web && npm ci + run: make web-deps - - name: Build web UI - run: cd web && npm run build && mkdir -p ../cmd/slmcode/ui && cp -r dist/* ../cmd/slmcode/ui/ + - name: Build web UI into the go:embed directory + run: make ui-react - - name: Go Lint - run: make lint + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.5.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - name: Go Test - run: make test + - name: make check (fmt, vet, lint, unit+race tests, web lint+build) + run: make check - name: Go Build run: make build + - name: golangci-lint ratchet (blocking — the baseline is zero) + run: make lint-strict + + - name: Studio API offline smoke test + run: ./scripts/e2e_prime_smoke.sh + + race-and-coverage: + name: Race detector & coverage floor + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Race tests (pkg/...) + run: make race + + - name: Coverage floor + run: make cover + web-check: name: Web Frontend Check runs-on: ubuntu-latest @@ -65,8 +111,12 @@ jobs: cache: npm cache-dependency-path: web/package-lock.json + # `make web-deps`, not a bare `npm ci`: `npm ci` refuses to run at all when + # web/package-lock.json does not match web/package.json, and the lock in this + # tree predates the vitest/eslint devDependencies. web-deps tries `npm ci` + # first and falls back to `npm install`, printing the real fix either way. - name: Install web deps - run: cd web && npm ci + run: make web-deps - name: Type check run: cd web && npx tsc --noEmit @@ -100,11 +150,20 @@ jobs: with: python-version: "3.12" + # `make web-deps`, not a bare `npm ci`: `npm ci` refuses to run at all when + # web/package-lock.json does not match web/package.json, and the lock in this + # tree predates the vitest/eslint devDependencies. web-deps tries `npm ci` + # first and falls back to `npm install`, printing the real fix either way. - name: Install web deps - run: cd web && npm ci + run: make web-deps + + - name: Build web UI into the go:embed directory + run: make ui-react - - name: Build web UI - run: cd web && npm run build && mkdir -p ../cmd/slmcode/ui && cp -r dist/* ../cmd/slmcode/ui/ + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.5.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run pre-commit uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..c0fc873 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,37 @@ +name: Vulnerability scan + +on: + schedule: + # 06:00 UTC every Monday. + - cron: "0 6 * * 1" + workflow_dispatch: + push: + branches: [main] + paths: + - "go.mod" + - "go.sum" + - ".github/workflows/govulncheck.yml" + +permissions: + contents: read + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb7c199..ea886da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,11 +8,19 @@ on: permissions: contents: write +env: + # Keep in lockstep with .github/workflows/ci.yml. The release gate must be the + # same gate CI runs, not a weaker one. + GOLANGCI_LINT_VERSION: v2.5.0 + jobs: release: name: Build & Publish runs-on: ubuntu-latest - timeout-minutes: 30 + # `make check` = tidy-check + lint + coverage + race + web lint/build, and the + # cross-compile matrix follows it. CI splits that across three jobs with 25+20+15 + # minutes; this job runs all of it plus six builds. + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,22 +40,105 @@ jobs: cache: npm cache-dependency-path: web/package-lock.json - - name: Install web deps - run: cd web && npm ci - - name: Extract version id: ver run: | + set -euo pipefail TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::tag ${TAG} is not a vX.Y.Z release tag" >&2 + exit 1 + fi echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Lint & test + # Fail before spending 40 minutes on a build whose binaries would report a + # version nobody asked for. This catches the classic release mistake: the + # tag says v0.17.0, cmd/slmcode/version.go still says 0.16.0, and every + # published artifact carries the tag in its filename and the old version + # in `slmcode version`. + - name: Version consistency (tag == version.go == Makefile == Formula) + run: ./scripts/check-version.sh --tag "${{ steps.ver.outputs.tag }}" + + - name: Repo reference guard + run: ./scripts/check-repo-refs.sh + + # NOT a bare `cd web && npm ci`. `npm ci` installs strictly from + # package-lock.json and refuses to run when the lock does not match + # package.json — which is exactly the state web/ is in until a regenerated + # lock is committed. A stale lock is a reason to fix the lock, never a + # reason for a tagged release to abort before it builds anything. + # `make web-deps` tries `npm ci` first and falls back to `npm install`, + # printing the fix either way. + - name: Install web deps + run: make web-deps + + - name: Install golangci-lint + run: | + set -euo pipefail + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ + | sh -s -- -b "$(go env GOPATH)/bin" "${GOLANGCI_LINT_VERSION}" + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + # ORDER IS LOAD-BEARING. `make ui-react` builds web/ and syncs web/dist into + # cmd/slmcode/ui/, which is what `//go:embed all:ui` compiles into every + # binary. It MUST run before any `go build`, or cmd/slmcode/ui/ holds nothing + # but .gitkeep, every published binary falls back to the placeholder page + # compiled into pkg/server, and every user who runs `slmcode studio` gets + # "Studio not built" — with no way to fix it short of a new release. The + # step below turns that from a silent downgrade into a failed release. + - name: Build the Studio UI + run: make ui-react + + # `sourcemap: 'hidden'` in web/vite.config.ts still WRITES .map files into + # web/dist; it only suppresses the sourceMappingURL comment. `go:embed all:ui` + # takes the directory as-is, so every release binary would carry the full TSX + # source. Drop them after the sync and before the compile. + - name: Strip sourcemaps from the embedded UI + run: | + set -euo pipefail + find cmd/slmcode/ui -name '*.map' -print -delete + + # The same gate contributors run and the same one CI's lint-test job runs: + # gofmt, vet, golangci-lint (blocking — golangci-lint is installed above, or + # scripts/lint.sh silently SKIPS it), the coverage floor, -race, and the web + # build. A release used to go out on `make lint && make test` alone, which + # skipped the race detector, the coverage floor and the web lint. + - name: Run the full gate (make check) + run: make check + + # The single check that stands between this release and shipping a + # placeholder Studio to every user. `make check` does NOT enforce it: + # scripts/ui-check.sh treats "no build output at all" as a valid state, so + # contributors without Node can still build — the binary then serves the + # placeholder page compiled into pkg/server. For a RELEASE that state is + # fatal, so this step asserts the *built* branch specifically. + - name: Verify the real Studio UI is embedded (fatal) run: | - make ui-react - make lint - make test + set -euo pipefail + ui=cmd/slmcode/ui + fail() { echo "::error::$*"; exit 1; } + + # Shared contract first (tracked .gitkeep, no half-built tree). + ./scripts/ui-check.sh + + [[ -f "$ui/index.html" ]] || fail "$ui/index.html is missing — 'make ui-react' did not sync web/dist. Every published binary would serve the pkg/server placeholder page." + [[ -d "$ui/assets" ]] || fail "$ui/assets is missing — 'make ui-react' did not sync web/dist. Every published binary would serve the pkg/server placeholder page." + + # A real Vite index.html mounts #root and loads a hashed module bundle. + grep -q 'id="root"' "$ui/index.html" \ + || fail "$ui/index.html has no