.PHONY: up down restart status dev dev\:go dev\:next build build\:go build\:sidecar test cover docs-inventory docs-inventory\:strict lint security mutation sbom notices e2e e2e\:ui validate smoke-cli VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") # DIRTY — was the tree we are building from modified? (#1686) # # VERSION and COMMIT above are already worktree-correct, because git honours a # worktree's `.git` FILE. The dirty bit had no ldflags source at all, so it # came from the toolchain's vcs.modified — and cmd/go only recognises a `.git` # DIRECTORY, so in a nested worktree it stamps the ENCLOSING clone's bit. That # is how a `make build` from a clean worktree printed "(uncommitted changes)". # Empty (not a repo) is left empty on purpose: buildinfo reads that as # "unknown", which is a different fact from "clean". stderr is discarded for # the same reason the neighbours above discard it: a missing or non-executable # script must degrade to "unknown", not to build noise. DIRTY ?= $(shell ./scripts/build-stamp.sh dirty 2>/dev/null) LICENSE_PUBKEY ?= # Coverage knobs for `make cover` (see the Test & Lint section). COVER_PKGS ?= ./... # coverage/ is already gitignored, so the profile never lands in a commit. COVERPROFILE ?= coverage/go-cover.out # SENTRY_DSN is the Go-side crash-report endpoint, baked in at link time # via -X internal/crashreport.DSN. Empty default keeps local `make build` # fully telemetry-silent; CI/release paths export the real value from the # SENTRY_DSN GitHub Actions secret. Mirrors .goreleaser.yml + Dockerfile so # all three build paths agree on the contract. The frontend equivalent # (NEXT_PUBLIC_SENTRY_DSN, routed to the SENTRY_DSN_FRONTEND secret in CI) # is consumed directly by `pnpm build` from process env — no wiring needed # here as long as it's exported when `make build` is invoked. SENTRY_DSN ?= # SIDECAR_BUILD_HASH — content hash (sha256, first 12 hex chars — same shape # as internal/sidecar.selfExeHash) of the freshly built ./crewship-sidecar, # baked into the crewship binary so the server knows which sidecar it was # built alongside (#1160). Lets stale-sidecar detection catch ARTIFACT # staleness (deploy updated the server but forgot build:sidecar + copy — # runtime on-disk hashing compares old-vs-old and stays silent). # Recursively expanded (=, not :=) ON PURPOSE: the $(shell) runs when the # build recipe line expands — i.e. AFTER the build:sidecar prerequisite wrote # the file — not at makefile-read time. Missing file → empty → the server # falls back to on-disk hashing (fail-open), never a false alarm. # The pipeline shape is locked by TestSidecarHashShellContract # (internal/provider/docker/sidecar_binhash_test.go). SIDECAR_BUILD_HASH = $(shell (sha256sum crewship-sidecar 2>/dev/null || shasum -a 256 crewship-sidecar 2>/dev/null) | cut -c1-12) LDFLAGS = -ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) -X github.com/crewship-ai/crewship/internal/buildinfo.buildDirty=$(DIRTY) -X github.com/crewship-ai/crewship/internal/license.publicKey=$(LICENSE_PUBKEY) -X github.com/crewship-ai/crewship/internal/crashreport.DSN=$(SENTRY_DSN) -X github.com/crewship-ai/crewship/internal/provider/docker.buildExpectedSidecarHash=$(SIDECAR_BUILD_HASH)" # -trimpath strips workspace paths (/Users/.../crewship_2/...) from the # binary, giving reproducible builds across machines and shaving a few KB # off the embedded debug info. Adds nothing measurable to compile time. GO_BUILD = go build -trimpath $(LDFLAGS) # Multi-instance support: crewship_1 -> instance 1, crewship_2 -> instance 2, etc. INSTANCE_NUM := $(shell basename $(CURDIR) | sed -n 's/^crewship_\([0-9]*\)$$/\1/p') INSTANCE_NUM := $(or $(INSTANCE_NUM),0) NEXT_PORT := $(shell [ $(INSTANCE_NUM) -eq 0 ] && echo 3001 || echo $$((3010 + $(INSTANCE_NUM)))) # === Quick aliases (recommended) === up: @./dev.sh start down: @./dev.sh stop restart: @./dev.sh restart status: @./dev.sh status # === Individual services (advanced) === dev\:next: pnpm dev --port $(NEXT_PORT) dev\:go: air dev\:go-once: @set -a && . ./.env.local && set +a && \ CREWSHIP_LOG_LEVEL=debug \ go run ./cmd/crewship start # === Build === build: build\:sidecar pnpm build ./scripts/embed-web-out.sh sync $(GO_BUILD) -o crewship ./cmd/crewship build\:go: build\:sidecar $(GO_BUILD) -o crewship ./cmd/crewship # Build the crewship-sidecar binary as a standalone executable and stage # entrypoint.sh next to it. This lets users bring their own base image # (debian, ubuntu, alpine-glibc, etc.) while reusing the baked-in sidecar # via a host bind mount. See internal/provider/docker/docker.go buildMounts. # # ALWAYS GOOS=linux (#953): the sidecar is bind-mounted into the crew's # LINUX container and exec'd there, so it must be a Linux ELF for the # container's arch — NOT host-native. On macOS a native build produces a # Mach-O binary that fails in-container with exit 255 (misreported as a # glibc/musl mismatch). Mirrors dev.sh's arch mapping. # Host-arch → GOARCH mapping WITHOUT $(shell case …): GNU Make terminates # $(shell) at the first unbalanced ")" — the one closing "aarch64)" — so # the shell got an unterminated `case` and the recipe got the leaked rest # ("Syntax error: end of file unexpected"). Broke every `make build:sidecar` # since the case expression landed; CI never runs this target, deploys do. UNAME_M := $(shell uname -m) ifeq ($(UNAME_M),arm64) SIDECAR_GOARCH := arm64 else ifeq ($(UNAME_M),aarch64) SIDECAR_GOARCH := arm64 else ifeq ($(UNAME_M),x86_64) SIDECAR_GOARCH := amd64 else ifeq ($(UNAME_M),amd64) SIDECAR_GOARCH := amd64 else SIDECAR_GOARCH := $(shell go env GOARCH) endif build\:sidecar: CGO_ENABLED=0 GOOS=linux GOARCH=$(SIDECAR_GOARCH) go build -trimpath -ldflags="-s -w" -o crewship-sidecar ./cmd/crewship-sidecar cp scripts/entrypoint.sh ./entrypoint.sh chmod +x ./entrypoint.sh # === Test & Lint === test: pnpm test go test ./... # Repo-wide Go coverage profile + total, in one reproducible command. # -timeout 40m: the full run measures ~4m but `go test` applies its # per-package timeout to the whole binary set here; the 25m default is what # ad-hoc coverage runs kept tripping over. COVER_PKGS/COVERPROFILE are # overridable for a narrower measurement, e.g. # make cover COVER_PKGS=./internal/api/... cover: @mkdir -p $(dir $(COVERPROFILE)) go test $(COVER_PKGS) -coverprofile=$(COVERPROFILE) -covermode=atomic -timeout 40m @echo "→ Total coverage:" @go tool cover -func=$(COVERPROFILE) | tail -1 # API/CLI documentation truth inventory. Regenerates the public OpenAPI route # catalog, obtains the CLI's own command manifest, and writes the full # machine-readable + review-friendly reports under docs/prd/reports/. docs-inventory: go run ./cmd/gen-openapi go run ./scripts/docs-inventory # The same inventory, as a gate. CI runs this form so that "every operation # and command is documented" is a ratchet rather than a number that was true # on the day someone regenerated the report. docs-inventory\:strict: go run ./cmd/gen-openapi go run ./scripts/docs-inventory -strict # Regenerates internal/orchestrator/testdata/cli-fixtures/*.ndjson from the # REAL upstream CLI binaries — internal/orchestrator/e2e_multi_cli_test.go has # cited this target since it was written, but it never existed until now # (2026-07-21 quality-testability audit). NOT part of `test`/CI: it needs all # six CLIs installed, spends real API quota per adapter, and each upstream # tool changes independently — run it manually or from a nightly dev2 job, # never per-PR. Missing CLIs are skipped with a warning, not a failure, so a # partial local install can still refresh the fixtures it has. # # opencode.ndjson is currently authored from the documented schema (#943), not # captured — this target replaces it with a real capture the first time # opencode is installed where it runs. smoke-cli: @mkdir -p internal/orchestrator/testdata/cli-fixtures @if command -v claude >/dev/null 2>&1; then \ echo "→ capturing claude.ndjson"; \ claude --print --output-format stream-json "say hello" \ > internal/orchestrator/testdata/cli-fixtures/claude.ndjson; \ else echo "⚠ claude not installed — skipping claude.ndjson"; fi @if command -v codex >/dev/null 2>&1; then \ echo "→ capturing codex.ndjson"; \ codex exec --json --sandbox read-only -- "say hello" \ > internal/orchestrator/testdata/cli-fixtures/codex.ndjson; \ else echo "⚠ codex not installed — skipping codex.ndjson"; fi @if command -v gemini >/dev/null 2>&1; then \ echo "→ capturing gemini.ndjson"; \ gemini -p "say hello" --output-format stream-json \ > internal/orchestrator/testdata/cli-fixtures/gemini.ndjson; \ else echo "⚠ gemini not installed — skipping gemini.ndjson"; fi @if command -v opencode >/dev/null 2>&1; then \ echo "→ capturing opencode.ndjson"; \ opencode run --format json -- "say hello" \ > internal/orchestrator/testdata/cli-fixtures/opencode.ndjson; \ else echo "⚠ opencode not installed — skipping opencode.ndjson (stays hand-authored from #943 docs)"; fi @if command -v cursor-agent >/dev/null 2>&1; then \ echo "→ capturing cursor.ndjson"; \ cursor-agent -p --output-format stream-json --force -- "say hello" \ > internal/orchestrator/testdata/cli-fixtures/cursor.ndjson; \ else echo "⚠ cursor-agent not installed — skipping cursor.ndjson"; fi @if command -v droid >/dev/null 2>&1; then \ echo "→ capturing droid.ndjson"; \ droid exec --auto low -o stream-json -- "say hello" \ > internal/orchestrator/testdata/cli-fixtures/droid.ndjson; \ else echo "⚠ droid not installed — skipping droid.ndjson"; fi @echo "→ done. Review the diffs, then commit + bump pinnedNpmVersion in internal/orchestrator/cli_adapter_versions_test.go if any schema changed." lint: pnpm lint @if command -v golangci-lint >/dev/null 2>&1; then \ golangci-lint run ./...; \ else \ echo "golangci-lint not installed — falling back to go vet. Install with: brew install golangci-lint"; \ go vet ./...; \ fi # === Security (CRE-122) === security: @echo "→ Running govulncheck..." @if command -v govulncheck >/dev/null 2>&1; then \ govulncheck ./... || echo "⚠ govulncheck reported issues — see THIRD-PARTY-NOTICES.md for known accepted vulns"; \ else \ echo "⚠ govulncheck not installed — go install golang.org/x/vuln/cmd/govulncheck@latest"; \ fi @echo "→ Running gitleaks..." @if command -v gitleaks >/dev/null 2>&1; then \ gitleaks detect --no-banner --redact; \ else \ echo "⚠ gitleaks not installed — brew install gitleaks"; \ fi # Mutation testing (github.com/go-gremlins/gremlins), scoped to the four # packages in .gremlins.yaml — NOT repo-wide, see that file for why. # gremlins copies the whole Go module root per worker with no ignore # support; if your checkout has a large .claude/ (worktrees, caches) or # other big untracked dirs under the module root, point TMPDIR at a # volume with room, or run this from a clean `git worktree add` checkout. mutation: @if command -v gremlins >/dev/null 2>&1; then \ for pkg in ./internal/untrusted ./internal/scrubber ./internal/manifest/kinds ./internal/sidecar; do \ echo "→ gremlins unleash $$pkg"; \ gremlins unleash "$$pkg" --config .gremlins.yaml || exit 1; \ done; \ else \ echo "⚠ gremlins not installed — go install github.com/go-gremlins/gremlins/cmd/gremlins@latest"; \ fi sbom: @echo "→ Generating SBOMs (SPDX + CycloneDX)..." @if command -v syft >/dev/null 2>&1; then \ syft . -o spdx-json=sbom.spdx.json -o cyclonedx-json=sbom.cdx.json; \ echo "✓ sbom.spdx.json and sbom.cdx.json generated"; \ else \ echo "⚠ syft not installed — brew install syft"; \ fi notices: @./scripts/gen-notices.sh # Distribution license bundle (Go dependency texts + npm attribution # inventory + project legal files) into build/licenses/ — consumed by # goreleaser archives, nfpm packages and the container image build. licenses: @./scripts/gen-license-bundle.sh e2e: pnpm test:e2e e2e\:ui: pnpm test:e2e:ui validate: @./scripts/validate-flow.sh