Skip to content

Toyz/gw

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gw

gw is the workflow tool for Go monorepos. It turns a multi-module repo — many go.mod under one roof — into a coherent workspace: it auto-generates and maintains go.work, keeps one dependency version across modules, runs the go tool everywhere, and tells you exactly what a change affects. What Nx and Turborepo are for JavaScript, and Cargo workspaces are for Rust — for Go.

Install

go install github.com/toyz/gw@latest

Commands

Command What it does
gw init Bootstrap an existing multi-module repo: create go.work and move every replace directive out of each go.mod up into go.work. Refuses to clobber an existing go.work (use --force). --dry-run previews. --config also scaffolds gw.toml, --ext also scaffolds .gw/build.go, and --all does both — a one-shot bootstrap that skips whatever already exists (safe to re-run).
gw sync Regenerate go.work's use set from discovered modules (preserving replace/godebug), then run go work sync. --check (CI: exit non-zero if stale), --dry-run, --no-work-sync.
gw lint Report dependencies required at different versions across modules, plus mismatched go/toolchain directives. Exits non-zero on mismatch. --fix aligns dependency versions (--strategy highest|lowest); directive mismatches are reported, never auto-changed.
gw run -- <cmd> Run a command in every module's directory. -p parallel, --continue-on-error. Inject env with --env-file <f> (dotenv, repeatable) and --env KEY=VAL (repeatable); see Config.
gw build [args] go build across every module (default ./...) — a per-module compile check with a pass/fail summary. -p parallel; same --env-file/--env as run.
gw test [args] go test across every module (default ./...). -p parallel; same --env-file/--env as run.
gw vet [args] go vet across every module (default ./...). -p parallel; same flags as run.
gw generate [args] go generate across every module (default ./...). -p parallel; same flags as run.
gw tidy go mod tidy across every module. -p parallel; same --env-file/--env as run.
gw list List modules; -v adds go version + external requires; --json.
gw add <path> / gw remove <path> Add/remove a single module's use directive.
gw graph Print the intra-workspace dependency DAG (edge A->B = A requires B). Text, --dot (Graphviz), or --json. Edges come from direct/indirect requires and local replace targets.
gw doc [unit] [symbol] Show a unit's documentation: a Go module docs via go doc <import-path> [symbol] (resolved from a short name like api, or <unit>/<subpkg>, with go's -all/-src/-u/-c forwarded); a project docs via its toolchain's doc task (e.g. rust → cargo doc). With no unit, lists what's documentable — the workspace root has no package of its own, so bare go doc there fails.
gw affected --since <ref> Diff the working tree against a git ref, map changed files to owning modules, and walk the DAG to every impacted module. --seeds (only directly-changed), --dir, --json. --projects instead lists the [projects.<name>] a diff touches (by directory) — change-based rebuilds across languages, even non-Go. Feed selective CI: gw affected --since main.
gw doctor One-shot health check: missing/stale go.work, use entries with no go.mod, modules missing from go.work, un-hoisted replace directives, and version/directive drift. Exits non-zero on any error (--strict also fails on warnings).
gw config init Scaffold a commented starter gw.toml in the workspace root (won't clobber an existing config). gw config path prints the config file gw loads. See Config.
gw verify Check the release contract workspace mode hides: every require on another workspace module must resolve to a real published tag whose code still matches what's on disk. Inside the workspace such a require resolves to local code, so go build passes even when the version was never tagged — verify runs the checks an external consumer (or GOWORK=off release build) would hit. Also flags local-path replace leaks, and prints a release plan in dependency order. Exits non-zero on errors (--strict also on warnings); --json.
gw mcp Run gw as a Model Context Protocol stdio server so an AI agent can drive the workspace. See MCP.

-C, --root <dir> sets the workspace root (default: nearest ancestor with a go.work, else the current directory).

Config (optional gw.toml)

Zero-config works. To customize, run gw config init for a commented starter gw.toml in the workspace root (gw config path prints the file gw loads). TOML is preferred; a gw.yaml / gw.yml with the same keys still works — if both exist, gw.toml wins:

root = "."
ignore = ["examples/**", "**/testdata"]
env_files = ["ci.env"]          # dotenv files layered on top of [env], in order

[pins]                          # force these versions in `gw lint --fix`
"github.com/foo/bar" = "v1.4.0"

[env]                           # applied to run/test/tidy, hooks, and extensions
CGO_ENABLED = "0"

Directories .git, .gw, vendor, testdata, node_modules, .idea, .vscode are always skipped.

Environment injection (opt-in)

Nothing is injected unless you ask for it. Sources, lowest precedence to highest:

  1. [env] in the config file
  2. env_files in the config file (in order)
  3. --env-file <path> on run/test/tidy (repeatable, in order)
  4. --env KEY=VAL on run/test/tidy (repeatable)

Each layer overlays the ambient process environment; a later layer wins on a key collision. The config layers ([env] + env_files) apply to everything gw spawns — module commands, lifecycle hooks, and extension commands — so they are the place for workspace-wide settings. The --env* flags are per-invocation and scoped to that command's module runs. Dotenv files support # comments, a leading export , and single/double quotes (\n \t \r \" \\ inside double quotes); values are otherwise literal (no $VAR interpolation).

Commands & hooks (no .gw/build.go)

Declare custom commands and lifecycle hooks right in the config — run natively by gw, no compiled extension needed:

[commands.boot]                 # adds `gw boot`
desc = "build services, then codegen"
steps = [
  "worker:build",               # <unit>:<verb> → runs that unit's toolchain verb
  "api:build",
  "sqlc generate",              # any other string → shell command
]
dir = "services"                # working dir for shell steps

[commands.deploy]               # takes an argument: `gw deploy <service>`
desc = "deploy one service"
args = ["service"]
steps = [
  "${service}:build",           # arg picks the unit dynamically
  "helm upgrade $service ./charts/$1",   # $service (env) and $1 (positional)
]

[hooks.pre-build]               # runs before `gw build`
steps = ["sqlc generate"]

Each step is either <unit>:<verb> (verb ∈ build test vet generate tidy run, run via that unit's toolchain in its directory) or a shell command (run in dir, else the root). A command may declare args = [...]; each becomes <cmd> <name...>, bound in steps as $name (env), $1/$@ (positional), and ${name} in a unit ref. Hooks are keyed by event — pre-/post-<command> — the same events the gwext SDK uses. A compiled extension wins any name/event collision; config fills the rest. For real logic (loops, conditionals), use a .gw/build.go extension.

Projects (polyglot task dispatch)

gw is a polyglot monorepo task runner. A Go module is a build unit gw auto-discovers; a [projects.<name>] is a unit in another language. gw's verbs (build/test/vet/generate/tidy/run) dispatch to each unit's toolchain — go test ./... for a module, cargo test for Rust, whatever you declare for the rest. go and rust are first-party (built in); any other language is a few lines of [toolchains.<name>] — no recompile.

[projects.sat]
path = "sat"
toolchain = "rust"              # first-party: cargo build/test/clippy/run

[projects.web]
path = "web"
toolchain = "npm"               # defined below

[toolchains.npm]                # add a language: verb -> shell command
build = "npm run build"
test  = "npm test"

# override a first-party verb too:
[toolchains.rust]
test = "cargo nextest run"

Per-project overrides live under [projects.<name>.tasks] (e.g. test = "..."). gw test runs every unit's test; gw affected --since main --projects lists the projects a diff touches (by directory), even non-Go ones — pipe it into selective CI. In --json, affected projects appear under projects alongside seeds/impacted.

Note: overriding [toolchains.go] switches that verb to a shell command, which forfeits gw's automatic -tags/-ldflags build-provider injection (add flags yourself).

CI (GitHub Action)

gw ships a composite action. In a repo that uses a workspace:

- uses: actions/setup-go@v5
  with: { go-version: stable }
- uses: toyz/gw@v0
  with:
    command: doctor --strict   # default; or "sync --check", "lint", "affected --since main"

Inputs: command (default doctor --strict), version (default latest), working-directory (default .). Requires Go on the runner (actions/setup-go). See .github/workflows/example-consumer.yml.

Extensions (.gw/build.go)

Full API reference: pkg.go.dev/github.com/toyz/gw/gwext

Extend gw with your own commands and lifecycle hooks by writing a compiled Go extension — think Cargo's build.rs. Scaffold it:

gw ext init      # creates .gw/build.go + .gw/go.mod
gw ext list      # show the extension's commands + hooks
gw ext build     # compile (cached by content hash; auto-built on use)

.gw/build.go registers commands and hooks against the gwext SDK. Because it is real compiled Go, a command can orchestrate anything — build and run services in a fixed order, fan out in parallel, or drive polyglot tools:

package main

import (
	"fmt"

	"github.com/toyz/gw/gwext"
)

func main() {
	// `gw boot`: build then run three services in a fixed order.
	gwext.Command("boot", "build+run services in order", func(c *gwext.Context) error {
		for _, p := range []string{"example.com/worker", "example.com/api", "example.com/gateway"} {
			m := c.Mod(p)
			if err := m.Build(); err != nil { // typed: go build ./...
				return err
			}
			if err := m.Run(); err != nil {   // typed: go run .
				return err
			}
		}
		return nil
	})

	// Polyglot: run any tool in a module's directory.
	gwext.Command("web", "start the frontend", func(c *gwext.Context) error {
		return c.Mod("example.com/web").Exec("npm", "run", "dev")
	})

	// Hook: runs automatically after `gw sync`. Before/After wrap any command —
	// every built-in (typed constants: gwext.Sync, gwext.Build, …) and your own
	// custom verbs (a plain string, e.g. Before("boot", …)).
	gwext.After(gwext.Sync, func(c *gwext.Context) error {
		fmt.Printf("synced %d modules\n", len(c.Modules))
		return nil
	})

	gwext.Main()
}

Hooks run for every workspace command, pre-<cmd> before and post-<cmd> after (post- on success; --dry-run/--check runs skip them). The older gwext.Hook("post-sync", …) string form still works but is deprecated.

Context helpers (on *gwext.Context):

  • c.Modules, c.Module(path), c.Root, c.Args (command args), c.Event (hook).
  • c.Mod(path) -> typed handle: .Build() .Test() .Run() .Vet() .Generate() .Tidy() (each defaults to ./.../.), .Go(args...), .Exec(bin, args...), .Start(...) for long-lived processes.
  • Other toolchains: .Tool(bin) binds any executable and runs it in the module — c.Mod("web").Tool("yarn").Run("dev"); .Start(...) for long-lived ones.
  • c.Go(dir, args...), c.Run(dir, bin, args...), c.Start(...) for root-level or arbitrary directories.

Typed command flags: declare flags with gwext.Str/Bool/Int after the handler. gw parses them from the user's args (the handler reads typed values via c.String/c.Bool/c.Int, with leftover positionals in c.Args), and they show up in gw <cmd> --help and gw ext list. Both --flag value and --flag=value forms parse; add short aliases with .Alias(...) (either name sets the same value, read by the canonical name). gwext.Strs(name, help) declares a repeatable slice flag — pass it multiple times and/or comma-separated (--tag a --tag b, --tag a,b) and read with c.Strings(name):

gwext.Command("hello", "greet someone", func(c *gwext.Context) error {
	fmt.Println("hello", c.String("name"))
	return nil
}, gwext.Str("name", "world", "who to greet").Alias("n"), gwext.Bool("loud", "shout").Alias("l"))

Build providers (gwext.Provide) let an extension compute environment and Go build settings at run time — gw's analogue of a Cargo build script emitting cargo::rustc-env / rustc-cfg. Prebuilt providers ship in gwext, so common needs are one line — e.g. stamp the git SHA into your binary for free:

gwext.Provide(gwext.GitStamp("example.com/app/version")) // -ldflags -X the commit/branch/tag/time
gwext.Provide(gwext.GitEnv())                            // export GW_GIT_COMMIT, GW_GIT_BRANCH, ...

// In CI, prefer the runner's env — reliable on shallow / detached checkouts:
gwext.Provide(gwext.CIStamp("example.com/app/version")) // GitHub Actions + GitLab CI
gwext.Provide(gwext.CIEnv())                            // export GW_CI_COMMIT, GW_CI_TAG, ...

GitStamp fills string vars in the named package (Commit, Short, Branch, Tag, Time, Dirty); CIStamp fills the CI-sourced set (Provider, Commit, Ref, Tag, RunID, Repo, Actor). Because -X only affects the module that links the package, the stamp lands there alone. Git/CI built-ins are workspace-global — pair them with Provide, not ProvideEach. Roll your own for anything else:

gwext.Provide(func(c *gwext.Context) (gwext.BuildInfo, error) {
	return gwext.BuildInfo{
		Env:  map[string]string{"PORT": derivePort(c)},          // process env for every command
		Vars: map[string]string{"example.com/app/build.User": me}, // -ldflags -X (build/test)
		Tags: []string{"production"},                            // build tags (build/test/vet)
	}, nil
})

Env layers into every command between config and --env (so --env still wins). Vars become -ldflags "-X key=value" and Tags become -tags on the compiling commands. Providers may print freely — that goes to stderr, never the result. gwext.Git(dir) and gwext.CI() return the raw GitInfo / CIInfo for building your own.

Overriding builtins — decorate, don't shadow. A plain gwext.Command whose name collides with a builtin is ignored (builtins win). To extend one, register gwext.Override("run", ...). The right pattern is a superset: handle your added flag, then call through to the original with c.Builtin so the vanilla invocation behaves exactly as before. Never make the override a silent swap that breaks the original — extend the verb, don't replace its meaning.

gwext.Override("run", "run across modules; adds --mode=all launcher",
	func(c *gwext.Context) error {
		if c.Bool("mode-all") {
			return launchEverything(c) // the new behavior
		}
		return c.Builtin("run", c.Args...) // fall through to the real builtin
	},
	gwext.Bool("mode-all", "launch all services (orchestrated)"),
)

c.Builtin(name, args...) runs gw's own builtin in a child process with extensions disabled, so the override never recurses into itself. Overrides are always surfaced, never silent: gw --help labels the verb (overrides builtin) and gw ext list marks it override, so anyone can see a builtin is extended here. gwext.Hide("tidy", "generate") drops builtins entirely (also listed by gw ext list).

Because the call-through child runs with extensions off, a c.Builtin fall-through executes the pure builtin: lifecycle hooks are suppressed during it (this is what prevents an override from recursing) — build/test providers and env injection still apply. So gw build invoked directly fires pre-/post-build hooks, while a build override that calls c.Builtin("build") does not; run any such logic in the override itself. Works for every builtin — flag-parsed (run), go-passthrough (build/test/vet/generate/tidy), and the rest (sync/lint/doctor/graph/list/...).

Hook events: pre-/post- for sync, lint, run, build, test, vet, generate, tidy (e.g. post-sync, pre-test). The compiled binary is cached under .gw/bin/ (git-ignored) and rebuilt only when .gw sources change.

MCP server

gw mcp runs gw as a Model Context Protocol stdio server, so an agent can inspect and drive a Go workspace directly. Register it with Claude Code:

claude mcp add gw -- gw mcp

Tools exposed:

Tool What it returns
gw_list modules, dirs, go versions, direct requires (JSON)
gw_doctor health issues + error/warning/info counts
gw_lint cross-module dependency + go/toolchain version drift
gw_graph the intra-workspace dependency DAG (nodes + edges)
gw_affected {since} → seeds + transitively impacted modules (selective CI)
gw_sync regenerate go.work ({check} reports drift without writing)
gw_test go test across every module + pass/fail summary

Read-only insight tools (list/doctor/lint/graph/affected) call gw's workspace engine directly and return structured JSON; action tools (sync, test) invoke gw so they inherit hooks and build providers. Every tool takes an optional root (defaults to the nearest go.work).

License

MIT. See LICENSE.

About

The workflow tool for Go monorepos: auto go.work, one dependency version across modules, run-everywhere, change-affected CI, dependency graph, and compiled Go extensions.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages