Skip to content
Open
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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ Child modules to execute before this module's operations. This is how you compos
| Field | Description |
|-------|-------------|
| `name` | Identifier for the child module |
| `source` | Path to the child module — local (`./sub-module`) or a Git URL |
| `source` | Path to the child module — local (`./sub-module`) or a Git URL, optionally with a `//subdir` |
| `version` | Optional. Pins a git `source` to a branch, tag, or commit (equivalent to a `?ref=` suffix, which it overrides). Templatable |
| `params` | Parameters to pass down, rendered through the parent's context |

Child modules execute first, in order. Then the parent's operations run. This lets you build layered workflows: a base module that creates the namespace, a service module that creates the ArgoCD app, a policy module that adds the Gatekeeper constraint — all composed from a single root module.
Expand Down Expand Up @@ -703,9 +704,26 @@ Execution order:
Sources can be:
- **Local paths** (`./relative/path` or `/absolute/path`) — resolved relative to the parent module
- **Git URLs** — cloned to a temporary directory automatically
- **Git URLs with `//subdir`** — one repo hosting many modules (Terraform convention)

This means you can publish reusable modules as Git repositories. A platform team maintains the standard modules; product teams compose them.

**Pinning a version.** By default a git module tracks its default branch. Pin a child module to a released version with the `version` field so runs are reproducible:

```yaml
modules:
- name: onboard-service
source: https://github.com/myorg/loom-modules.git//onboard-service
version: v1.4.0 # branch, tag, or commit
```

The root module is named on the command line, so version it with the `--ref` flag or a `?ref=` suffix on the source (`--ref` wins if both are given). The `?ref=` suffix also works in a child `source`, though child modules should prefer the `version` field:

```bash
loom run https://github.com/myorg/loom-modules.git//onboard-service \
--ref v1.4.0 -p serviceName=payments
```

## CLI

### `loom run`
Expand All @@ -721,6 +739,7 @@ loom run [path] [flags]
| `-p, --param key=value` | Set a parameter (repeatable) |
| `--params-file file.yaml` | Load parameters from a YAML file |
| `--target-path /path` | Use a local directory as the target (skip git clone) |
| `--ref version` | Pin the module source to a git branch, tag, or commit (overrides a `?ref=` in the source; git sources only) |
| `--author name` | Default git author name for `commitPush` (used when not set in `loom.yaml`) |
| `--email email` | Default git author email for `commitPush` (used when not set in `loom.yaml`) |
| `--dry-run` | Show what would happen without writing anything |
Expand Down
4 changes: 3 additions & 1 deletion cmd/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var (
diffParams []string
diffParamsFile string
diffTargetPath string
diffRef string
diffAuthor string
diffEmail string
diffQuick bool
Expand Down Expand Up @@ -48,6 +49,7 @@ func init() {
diffCmd.Flags().StringArrayVarP(&diffParams, "param", "p", nil, "Parameter in key=value format (can be repeated)")
diffCmd.Flags().StringVar(&diffParamsFile, "params-file", "", "YAML file with parameters")
diffCmd.Flags().StringVar(&diffTargetPath, "target-path", "", "Directory for target clones. When set, it is kept for inspection instead of a cleaned-up temp dir")
diffCmd.Flags().StringVar(&diffRef, "ref", "", "Pin the module source to a git branch, tag, or commit (overrides a ?ref= in the source; git sources only)")
diffCmd.Flags().StringVar(&diffAuthor, "author", "", "Default git author name for commitPush operations")
diffCmd.Flags().StringVar(&diffEmail, "email", "", "Default git author email for commitPush operations")
diffCmd.Flags().BoolVar(&diffQuick, "quick", false, "Simulate the run (dry-run) and show newFiles/patch diffs only, without executing anything")
Expand Down Expand Up @@ -335,7 +337,7 @@ func resolveModuleAndTarget(ctx context.Context, source string, paramMap map[str
}
}

moduleDir, srcCleanup, err := module.ResolveSource(source, ".", logger)
moduleDir, srcCleanup, err := module.ResolveSource(source, diffRef, ".", logger)
if err != nil {
return nil, "", nil, err
}
Expand Down
7 changes: 5 additions & 2 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var (
params []string
paramsFile string
targetPath string
moduleRef string
gitAuthor string
gitEmail string
showSummary bool
Expand All @@ -37,6 +38,7 @@ func init() {
runCmd.Flags().StringArrayVarP(&params, "param", "p", nil, "Parameter in key=value format (can be repeated)")
runCmd.Flags().StringVar(&paramsFile, "params-file", "", "YAML file with parameters")
runCmd.Flags().StringVar(&targetPath, "target-path", "", "Directory for target files: with --local-run, target repos are cloned into numbered subdirectories here; modules without a target spec use it directly")
runCmd.Flags().StringVar(&moduleRef, "ref", "", "Pin the module source to a git branch, tag, or commit (overrides a ?ref= in the source; git sources only)")
runCmd.Flags().StringVar(&gitAuthor, "author", "", "Default git author name for commitPush operations")
runCmd.Flags().StringVar(&gitEmail, "email", "", "Default git author email for commitPush operations")
runCmd.Flags().BoolVar(&showSummary, "summary", false, "Print a list of PRs/MRs created during the run at the end")
Expand All @@ -51,8 +53,9 @@ func runModule(cmd *cobra.Command, args []string) error {
source = args[0]
}

// Resolve source — handles git URLs, //subdir, and local paths.
moduleDir, cleanup, err := module.ResolveSource(source, ".", logger)
// Resolve source — handles git URLs, //subdir, ?ref= version, and local paths.
// The --ref flag pins the root module and takes precedence over any ?ref=.
moduleDir, cleanup, err := module.ResolveSource(source, moduleRef, ".", logger)
if err != nil {
return err
}
Expand Down
61 changes: 61 additions & 0 deletions cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func resetFlags() {
dryRun = false
localRun = false
targetPath = ""
moduleRef = ""
params = nil
paramsFile = ""
verbose = false
Expand All @@ -56,6 +57,7 @@ func resetFlags() {
diffQuick = false
diffPartial = false
diffTargetPath = ""
diffRef = ""
diffParams = nil
diffParamsFile = ""
diffAuthor = ""
Expand Down Expand Up @@ -243,6 +245,65 @@ spec:
}
}

// M5: --ref pins the root git source to a tag; the older tagged content wins
// over the newer default branch.
func TestRun_RefFlag_PinsVersion(t *testing.T) {
resetFlags()

// Work repo: tag v1 has greeting "from v1"; default branch adds "from main".
work := t.TempDir()
initGitRepo(t, work)
tpl := filepath.Join(work, "templates")
os.MkdirAll(tpl, 0o755)
writeLoomYAML(t, work, `
apiVersion: loom.rickliujh.github.io/v1beta1
kind: Loom
metadata:
name: greeter
spec:
operations:
- name: write
newFiles:
source: "templates"
dest: ""
`)
os.WriteFile(filepath.Join(tpl, "greeting.txt"), []byte("from v1"), 0o644)
gitRun(t, work, "add", ".")
gitRun(t, work, "commit", "-m", "v1")
gitRun(t, work, "tag", "v1")
os.WriteFile(filepath.Join(tpl, "greeting.txt"), []byte("from main"), 0o644)
gitRun(t, work, "add", ".")
gitRun(t, work, "commit", "-m", "main")

bare := t.TempDir()
if out, err := exec.Command("git", "clone", "--bare", work, bare).CombinedOutput(); err != nil {
t.Fatalf("bare clone failed: %v\n%s", err, out)
}

targetDir := t.TempDir()
rootCmd.SetArgs([]string{"run", "file://" + bare, "--ref", "v1", "--target-path", targetDir})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

content, err := os.ReadFile(filepath.Join(targetDir, "greeting.txt"))
if err != nil {
t.Fatal("expected greeting.txt in target dir")
}
if string(content) != "from v1" {
t.Errorf("expected --ref v1 content 'from v1', got %q", string(content))
}
}

// gitRun runs a git command in dir, failing the test on error.
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
full := append([]string{"-C", dir}, args...)
if out, err := exec.Command("git", full...).CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}

// Run with git URL + //subdir resolves to subdirectory.
func TestRun_GitSourceWithSubdir(t *testing.T) {
resetFlags()
Expand Down
43 changes: 43 additions & 0 deletions docs/guide/module-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Sources can be:
- **Local paths** (`./relative/path` or `/absolute/path`) — resolved relative to the parent module
- **Git URLs** — cloned to a temporary directory automatically
- **Git URLs with `//` separator** — `repo.git//subdir` clones the repo and uses `subdir/` as the module root
- **Git URLs with `?ref=<version>`** — pins the clone to a branch, tag, or commit (see [Pinning a module version](#pinning-a-module-version))

The `//` convention (borrowed from Terraform) lets a single Git repository host multiple modules in different directories:

Expand Down Expand Up @@ -88,6 +89,48 @@ modules:

This means you can publish reusable modules as Git repositories. A platform team maintains the standard modules; product teams compose them.

### Pinning a module version

Without a version, loom clones a git module's default branch — so a run picks up
whatever is on `main` at the time. Pin a module to an exact **branch, tag, or
commit SHA** to make runs reproducible.

For a child module, add a `version` field next to `source`:

```yaml
modules:
- name: networking
source: https://github.com/myorg/loom-modules.git//networking
version: v1.4.0 # branch, tag, or commit
```

Because `version` is templatable, it can be a parameter:

```yaml
params:
- name: modVersion
default: "v1.4.0"
modules:
- name: networking
source: "https://github.com/myorg/loom-modules.git//networking"
version: "{{ .modVersion }}"
```

The **root module** is passed on the command line, so it has no `version` field
to set. Version it with the `--ref` flag, or with a `?ref=<version>` suffix on
the source (the Terraform convention — `?ref=` comes last, after any
`//subdir`). `--ref` takes precedence over a `?ref=` suffix:

```bash
loom run https://github.com/myorg/loom-modules.git//onboard --ref v2.0.0 -p serviceName=payments
loom run 'https://github.com/myorg/loom-modules.git//onboard?ref=v2.0.0' -p serviceName=payments
```

The `?ref=` suffix also works in a child `source`; the `version` field takes
precedence if you set both. Version pinning applies to git sources only —
combining it with a local path is an error, since a local path is already fixed
on disk.

## Parameter Flow

Parameters are passed from parent to child through the `params` field. Child params are rendered through the parent's template context, so you can reference any parent parameter:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ loom diff [path] [flags]
| `-p, --param key=value` | Set a parameter (repeatable) |
| `--params-file file.yaml` | Load parameters from a YAML file |
| `--target-path /path` | Keep target clones here for inspection instead of a cleaned-up temp dir |
| `--ref version` | Pin the module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only |
| `--author name` | Default git author name for `commitPush` |
| `--email email` | Default git author email for `commitPush` |
| `-v, --verbose` | Enable debug logging |
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/cli-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ loom run [path] [flags]
| `-p, --param key=value` | Set a parameter (repeatable) |
| `--params-file file.yaml` | Load parameters from a YAML file |
| `--target-path /path` | Directory for target files. With `--local-run`, each target repo is cloned into a numbered subdirectory (`00-<name>/`, `01-<name>/`, …). Modules without a `target` spec use it directly as the target directory. Ignored otherwise. |
| `--ref version` | Pin the module source to a git branch, tag, or commit. Overrides a `?ref=` in the source; git sources only |
| `--author name` | Default git author name for `commitPush` (used when not set in `loom.yaml`) |
| `--email email` | Default git author email for `commitPush` (used when not set in `loom.yaml`) |
| `--summary` | Print a list of PRs/MRs created during the run at the end |
Expand All @@ -31,6 +32,7 @@ loom run [path] [flags]
| `./relative` or `/absolute` | Local directory |
| `https://github.com/org/repo.git` | Git URL — clones to temp dir, `loom.yaml` at repo root |
| `https://github.com/org/repo.git//subdir` | Git URL with `//` separator — `loom.yaml` at `subdir/` within the clone |
| `https://github.com/org/repo.git?ref=<version>` | Git URL pinned to a branch, tag, or commit — the `?ref=` selector comes last, after any `//subdir` |

## Examples

Expand All @@ -47,6 +49,21 @@ loom run https://github.com/myorg/loom-modules.git//onboard-service \
-p serviceName=payments
```

### Full run — pinned module version

Pin the module to a branch, tag, or commit so the run is reproducible instead of
tracking the default branch. Use `--ref`, or append `?ref=<version>` to the
source (`--ref` wins if both are given):

```bash
loom run https://github.com/myorg/loom-modules.git//onboard-service \
--ref v1.4.0 -p serviceName=payments

# equivalently, in the source:
loom run 'https://github.com/myorg/loom-modules.git//onboard-service?ref=v1.4.0' \
-p serviceName=payments
```

### Dry run

Modules with a `target` spec clone the target repo into a temporary
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/loom-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Parameters are the inputs to your module. They're injected into every template
- `spec.excludes[]`, `spec.includes[]`
- `spec.dynamicParams[].command`, `spec.dynamicParams[].default`
- `spec.target.url`, `spec.target.branch`, `spec.target.featureBranch`
- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].params` values
- `spec.modules[].name`, `spec.modules[].source`, `spec.modules[].version`, `spec.modules[].params` values
- `newFiles.source`, `newFiles.dest`
- `patch.engine`, `patch.path`, `patch.target`
- `shell.command`, `shell.timeout`
Expand Down Expand Up @@ -202,7 +202,8 @@ Child modules to execute before this module's operations. See [Module Compositio
| Field | Description |
|-------|-------------|
| `name` | Identifier for the child module. Templatable. |
| `source` | Path to the child module. Accepts local paths, git URLs, or git URLs with `//subdir` separator. Templatable — rendered before source resolution. |
| `source` | Path to the child module. Accepts local paths, git URLs, and git URLs with a `//subdir` separator. Templatable — rendered before source resolution. |
| `version` | Optional. Pins a git `source` to a branch, tag, or commit. Templatable. Ignored for local sources (an error to combine). Equivalent to a `?ref=` suffix on the source, which it overrides. |
| `params` | Parameters to pass down, rendered through the parent's context |
| `if` | Optional shell predicate gating the child. Templatable (parent's params). Runs via `sh -c` in the child's resolved target dir — exit `0` runs the child, non-zero skips it. See [`if`](#conditional-execution-if). |

Expand All @@ -213,6 +214,7 @@ Source formats:
| `./relative` or `/absolute` | Local path, resolved relative to parent module directory |
| `https://github.com/org/repo.git` | Git URL — `loom.yaml` expected at repo root |
| `https://github.com/org/repo.git//path` | Git URL — `loom.yaml` expected at `path/` within the clone |
| `https://github.com/org/repo.git//path?ref=v1.4.0` | Git URL pinned to a branch, tag, or commit — `?ref=` comes last, after any `//path`. For child modules, prefer the `version` field above. |

## `spec.operations`

Expand Down
2 changes: 1 addition & 1 deletion pkg/bulk/bulk.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func Run(opts Options, logger *slog.Logger) error {
}

// B1: load and validate the child module.
moduleDir, cleanup, err := module.ResolveSource(opts.ModuleRef, ".", logger)
moduleDir, cleanup, err := module.ResolveSource(opts.ModuleRef, "", ".", logger)
if err != nil {
return err
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type ModuleRef struct {
Name string `yaml:"name"`
Source string `yaml:"source"`
Params map[string]string `yaml:"params,omitempty"`
// Version optionally pins a git module source to a specific branch, tag, or
// commit. Empty means the source's default branch. Templatable. Ignored for
// local sources (an error to combine). Equivalent to a "?ref=" suffix on the
// source, which the field takes precedence over.
Version string `yaml:"version,omitempty"`
// If is an optional shell predicate gating child execution. Empty means
// always run. Templated with the parent's params, then run via sh -c in
// the child's resolved target dir: exit 0 runs the child, non-zero skips it.
Expand Down
1 change: 1 addition & 0 deletions pkg/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func validate(lf *LoomFile, moduleDir string) error {
}
checkTmpl(fmt.Sprintf("module %q name", m.Name), m.Name)
checkTmpl(fmt.Sprintf("module %q source", m.Name), m.Source)
checkTmpl(fmt.Sprintf("module %q version", m.Name), m.Version)
checkTmpl(fmt.Sprintf("module %q if", m.Name), m.If)
paramKeys := make([]string, 0, len(m.Params))
for k := range m.Params {
Expand Down
10 changes: 10 additions & 0 deletions pkg/git/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ func cliClone(ctx context.Context, url, dir, branch string) error {
return nil
}

func cliCheckout(ctx context.Context, dir, ref string) error {
cmd := exec.CommandContext(ctx, "git", "checkout", ref)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout %s: %w\n%s", ref, err, output)
}
return nil
}

func cliCreateBranch(dir, name string) error {
cmd := exec.Command("git", "checkout", "-b", name)
cmd.Dir = dir
Expand Down
16 changes: 16 additions & 0 deletions pkg/git/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,19 @@ func runGitCmd(name string, args ...string) (string, error) {
out, err := cmd.CombinedOutput()
return string(out), err
}

func TestCliCheckout(t *testing.T) {
bare := initTaggedBareRepo(t)
ctx := context.Background()

dir := t.TempDir()
if err := cliClone(ctx, bare, dir, ""); err != nil {
t.Fatal(err)
}
if err := cliCheckout(ctx, dir, "v1"); err != nil {
t.Fatalf("cliCheckout v1: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "extra.txt")); !os.IsNotExist(err) {
t.Errorf("expected extra.txt absent at v1, stat err = %v", err)
}
}
Loading
Loading