Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,20 @@ permissions:
contents: read

jobs:
schema-drift:
name: Schema drift check
# Verifies the embedded internal/schema/ingest.v1.json matches
# tracebloc/data-ingestors' master. A green PR that silently
# diverges from upstream is a real correctness hazard — a
# customer's YAML could pass `tracebloc ingest validate` locally
# but be rejected by jobs-manager (or vice versa). Forcing the
# sync as a PR step keeps drift visible.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: scripts/sync-schema.sh --check
run: ./scripts/sync-schema.sh --check

test:
name: Test
runs-on: ubuntu-latest
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,14 +2,19 @@

## Local development

```bash
go build -o tracebloc ./cmd/tracebloc
./tracebloc version
The `Makefile` mirrors the CI pipeline — `make ci` runs the exact same checks that PR #N's GitHub Actions run. If `make ci` passes locally, CI will too (modulo non-deterministic flakes). **Run `make ci` before pushing.** Skipping it has cost us at least one PR's worth of fix-up commits per bug class so far.

go test ./...
golangci-lint run # https://golangci-lint.run/usage/install/
```bash
make ci # vet + test + lint + fmt-check + schema-check (run this before push)
make build # produces ./tracebloc
make fmt # fixes gofmt -s drift in place
make schema-sync # pulls latest ingest.v1.json from data-ingestors master
```

Individual targets are also runnable in isolation — `make test`, `make lint`, etc. See the `Makefile` for the full list.

Requires [`golangci-lint`](https://golangci-lint.run/usage/install/) (install via `brew install golangci-lint` or your platform's equivalent).

Cobra autocomplete for `bash` / `zsh` / `fish` / `powershell` is available via the `completion` subcommand. Useful while developing too:

```bash
Expand Down
75 changes: 75 additions & 0 deletions Makefile
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
# Top-level Makefile for tracebloc/cli.
#
# Purpose: keep the local feedback loop the same shape as the CI
# loop. Anything that fails in `make ci` would have failed on a PR,
# and vice versa. Don't add targets here that aren't also enforced
# by .github/workflows/build.yml — divergence between local and CI
# is the bug this file exists to prevent.

# ---- toggles -----------------------------------------------------

GO ?= go
GOLANGCI_LINT ?= golangci-lint
PKGS := ./...

# ---- top-level targets -------------------------------------------

.PHONY: ci
ci: vet test lint fmt-check schema-check
@echo "==> ci: all green"

.PHONY: build
build:
$(GO) build -o tracebloc ./cmd/tracebloc

.PHONY: install
install:
$(GO) install ./cmd/tracebloc

# ---- individual targets (also runnable in isolation) -------------

.PHONY: vet
vet:
$(GO) vet $(PKGS)

.PHONY: test
test:
$(GO) test -race -cover $(PKGS)

.PHONY: lint
lint:
@command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \
echo "==> $(GOLANGCI_LINT) not on PATH"; \
echo " install via: brew install golangci-lint"; \
echo " or see: https://golangci-lint.run/usage/install/"; \
exit 1; \
}
$(GOLANGCI_LINT) run

.PHONY: fmt
fmt:
gofmt -s -w .

.PHONY: fmt-check
fmt-check:
@diff="$$(gofmt -s -l . 2>/dev/null)"; \
if [ -n "$$diff" ]; then \
echo "==> gofmt -s needed on:"; \
echo "$$diff" | sed 's/^/ /'; \
echo "==> run \`make fmt\` to fix"; \
exit 1; \
fi

.PHONY: schema-check
schema-check:
./scripts/sync-schema.sh --check

.PHONY: schema-sync
schema-sync:
./scripts/sync-schema.sh

# ---- cleanup -----------------------------------------------------

.PHONY: clean
clean:
rm -rf tracebloc dist/ coverage.out coverage.html
32 changes: 27 additions & 5 deletions cmd/tracebloc/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
package main

import (
"fmt"
"os"

"github.com/tracebloc/cli/internal/cli"
Expand All@@ -34,13 +35,34 @@ var (
)

func main() {
if err := cli.NewRootCmd(cli.BuildInfo{
err := cli.NewRootCmd(cli.BuildInfo{
Version: version,
GitSHA: gitSHA,
BuildDate: buildDate,
}).Execute(); err != nil {
// cobra has already printed the error + usage to stderr by
// the time we get here; just propagate a non-zero exit.
os.Exit(1)
}).Execute()
if err == nil {
return
}

// Print the error to stderr before exiting. The root command
// sets SilenceErrors: true to keep cobra from prepending its
// own "Error: ..." line on top of structured handler output
// — but that puts the burden on us to surface the error
// message ourselves. Without this, every non-schema-violation
// failure (file-read errors, YAML parse errors, schema-compile
// errors) exits non-zero with NO message to the customer.
//
// Handlers that have already printed their own diagnostic
// (e.g. `ingest validate` prints per-violation lines) signal
// "silent" by returning an exitError with a nil inner — see
// cli.IsSilentError for the contract.
if !cli.IsSilentError(err) {
fmt.Fprintln(os.Stderr, "Error:", err)
}

// Map command-defined exit codes through. Handlers that want a
// specific exit code (e.g. `ingest validate` returns 2 for
// schema violations, 3 for parse errors) return a *cli.ExitError
// the package exports; everything else gets the default 1.
os.Exit(cli.ExitCodeFromError(err))
}
7 changes: 6 additions & 1 deletion go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,12 @@ module github.com/tracebloc/cli

go 1.22

require github.com/spf13/cobra v1.8.1
require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/cobra v1.8.1
golang.org/x/text v0.16.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
68 changes: 68 additions & 0 deletions internal/cli/exit.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
package cli

// ExitCodeFromError extracts an exit code from a handler-returned
// error. Handlers that need a specific exit code wrap their return
// in an *exitError (see ingest.go); everything else defaults to 1.
//
// Public-but-package-keyed: main.go is the only intended caller,
// and the exitError type itself stays unexported so subcommand
// handlers go through the constructor.
func ExitCodeFromError(err error) int {
if err == nil {
return 0
}
var ee *exitError
if asExitError(err, &ee) {
return ee.code
}
return 1
}

// IsSilentError reports whether a handler-returned error wants
// main() to suppress its own "Error: ..." stderr line on the way
// out. The contract: a handler that has already printed a
// structured diagnostic itself (e.g. the schema-validate path
// prints per-violation lines to stderr) returns
// `&exitError{code: N, err: nil}` to signal "exit non-zero but
// don't print anything more." Errors with a non-nil inner err
// (file-read failures, parse errors, schema-compile bugs) are
// NOT silent — main() prints them so the customer doesn't see a
// bare non-zero exit with no explanation.
//
// Caller pattern in main.go:
//
// if err != nil && !cli.IsSilentError(err) {
// fmt.Fprintln(os.Stderr, "Error:", err)
// }
// os.Exit(cli.ExitCodeFromError(err))
func IsSilentError(err error) bool {
if err == nil {
return false
}
var ee *exitError
if asExitError(err, &ee) {
return ee.err == nil
}
return false
}

// asExitError walks the wrapped-error chain looking for an
// *exitError. Same pattern as errors.As but with a typed target so
// callers don't have to import errors at every site.
func asExitError(err error, target **exitError) bool {
for cur := err; cur != nil; cur = unwrapError(cur) {
if ee, ok := cur.(*exitError); ok {
*target = ee
return true
}
}
return false
}

func unwrapError(err error) error {
type unwrapper interface{ Unwrap() error }
if u, ok := err.(unwrapper); ok {
return u.Unwrap()
}
return nil
}
Loading
Loading