Loom automates the last mile of your GitOps.
You've adopted GitOps. Your applications deploy through Git. But every time you onboard a new service, add a new environment, or wire up a new team, you find yourself doing the same thing: copying YAML files, editing five fields, opening a PR, and moving on. It's not hard work. It's just tedious, error-prone, and never worth building a whole internal tool for.
Loom sits in that gap. You describe the repetitive part once as a module — a folder of templates and a loom.yaml file that declares what to do with them — and then you run it whenever you need it. Loom renders your templates, writes them into a target Git repository, commits, pushes, and opens a pull request. One command, done.
loom run ./onboard-service -p serviceName=payments -p namespace=fintech
No scripting. No custom CI jobs. No imperative glue code. Just a declarative workflow that turns parameters into a pull request.
Pre-built binaries are available on the Releases page for Linux and macOS (amd64/arm64).
# Example: Linux amd64
curl -Lo loom https://github.com/rickliujh/loom/releases/latest/download/loom-linux-amd64
chmod +x loom
sudo mv loom /usr/local/bin/Requires Go 1.25+.
go install github.com/rickliujh/loom@latestOr clone and build:
git clone https://github.com/rickliujh/loom.git
cd loom
go build -o loom .loom versionGitOps repositories accumulate operational patterns. Onboarding a service means creating an ArgoCD Application, an AppProject, a Gatekeeper constraint, and a kustomization entry — every time. Teams handle this in different ways:
- Copy-paste: fast, but drifts. Someone forgets a label, uses the wrong namespace, or misses the constraint file entirely.
- Shell scripts: better, but fragile. They grow organically, have poor error handling, and nobody wants to maintain them.
- Internal platforms: correct, but expensive. Most teams can't justify building and maintaining a self-service portal for what amounts to templating and a
git push.
Loom gives you the structure of a platform without the cost. Your automation is version-controlled, composable, and runs from a single binary with no runtime dependencies.
A Loom module is a directory. Inside it, you put:
loom.yaml— the workflow definition. It declares parameters, operations, and optionally references child modules.- Template files — any files alongside
loom.yamlare treated as Go templates. Their directory structure mirrors where they'll land in the target repository. __functions/— a conventional directory for patches, configs, and supporting files that should not be copied to the target. Add it toexcludesin yourloom.yamlto prevent it from being rendered as template files.
onboard-service/
├── loom.yaml
├── argocd/
│ ├── application-{{ .serviceName }}.yaml <- file name is templated
│ └── project.yaml <- template, rendered with params
├── {{ .namespace }}/ <- folder name is templated
│ └── constraints/
│ └── pod-must-have-label.yaml
└── __functions/
└── patches/
└── add-app.yaml <- used by patch operations, not copied
When you run loom run ./onboard-service -p serviceName=payments, Loom:
- Loads
loom.yamland resolves parameters - Clones the target Git repository (or uses a local path you specify)
- Creates a feature branch if
featureBranchis configured - Walks through operations in order — rendering templates, running shell commands, committing, pushing, opening a PR
- Reports what it did
With --dry-run, nothing is written, committed, or pushed. Loom just shows you what would happen. To see the actual file diffs a run would produce, use the loom diff command.
With --local-run, Loom runs all local operations (rendering templates, writing files, committing) but skips anything that touches a remote — no push, no PR creation. Shell commands are also skipped by default in --local-run mode unless explicitly marked with pure: true in the operation config. --local-run requires --target-path so you have a persistent directory to inspect the results.
Every module starts with a loom.yaml. It follows a Kubernetes-style schema:
apiVersion: loom.rickliujh.github.io/v1beta1
kind: Loom
metadata:
name: onboard-service
spec:
params:
- name: serviceName
required: true
- name: namespace
default: "default"
dynamicParams:
- name: commitHash
command: "git rev-parse --short HEAD"
- name: branchLabel
command: "echo {{ .serviceName }}-{{ .namespace }}"
excludes:
- __functions
target:
url: "https://github.com/myorg/gitops-repo.git"
branch: "main"
featureBranch: "loom/onboard-{{ .serviceName }}"
modules:
- name: base-setup
source: "./base-module"
params:
namespace: "{{ .namespace }}"
operations:
- name: create-files
newFiles:
source: "."
dest: ""
- name: patch-app
patch:
engine: json6902
path: "__functions/patches/add-app.yaml"
target: "argocd/application.yaml"
- name: validate
shell:
command: "kubeval --strict argocd/{{ .serviceName }}.yaml"
timeout: "30s"
pure: true
- name: commit
commitPush:
message: "feat: onboard {{ .serviceName }}"
author: "loom-bot"
email: "loom@example.com"
- name: open-pr
pr:
provider: github
title: "Onboard {{ .serviceName }}"
baseBranch: main
labels: [automated]
tokenEnv: GITHUB_TOKENParameters are the inputs to your module. They're injected into every template — file contents, file paths, shell commands, commit messages, PR titles. Everything is templatable.
| Field | Description |
|---|---|
name |
Parameter name, referenced as {{ .name }} in templates |
required |
If true, the run fails when this param is not provided |
default |
Fallback value when the param is not provided |
Resolution priority: provided (-p) → default → required error.
Dynamic parameters are evaluated via shell commands after all regular params are resolved. Their commands are Go templates — they can reference any regular param or any previously declared dynamic param.
| Field | Description |
|---|---|
name |
Parameter name, referenced as {{ .name }} in templates |
command |
Shell command (sh -c) whose stdout becomes the value. Supports Go template syntax. |
default |
Fallback value if the command fails |
dynamicParams:
- name: commitHash
command: "git rev-parse --short HEAD"
- name: timestamp
command: "date +%s"
- name: configPath
command: "echo /configs/{{ .namespace }}/app.yaml" # references a regular param
- name: clusterRegion
command: "kubectl config view --minify -o jsonpath='{.clusters[0].name}'"
default: "us-east-1" # fallback if command failsDynamic params are evaluated in declaration order, so later entries can reference earlier ones:
dynamicParams:
- name: branch
command: "git rev-parse --abbrev-ref HEAD"
- name: branchLabel
command: "echo {{ .branch }}-{{ .namespace }}" # references the dynamic param aboveIf a value is explicitly passed via -p or --params-file, the command is skipped entirely. This means you can always override a dynamic parameter from the CLI.
Control which files and directories are picked up during template walking (e.g. by newFiles).
Implicit excludes — these are always excluded by default, without needing to list them:
.gitdirectoryREADME.mdfile (case-insensitive)loom.yamlandloom.jsonnetconfig files (always excluded, cannot be overridden)
Directories that contain shell scripts or supporting files — such as __functions — are not implicitly excluded. You must list them explicitly in excludes if you don't want them copied to the target.
excludes adds additional glob patterns to skip. includes overrides any exclusion (both implicit and user-defined), letting you bring back files that would otherwise be filtered out.
Both fields accept glob patterns matched against file or directory names.
spec:
# Exclude the __functions directory and all .env files
excludes:
- __functions
- "*.env"
# Override implicit excludes to include README.md and .git
includes:
- README.md
- .git| Field | Description |
|---|---|
excludes |
Glob patterns for files/directories to skip during template walking |
includes |
Glob patterns that override excludes (including implicit ones) |
Precedence: includes > excludes > implicit excludes. If a file matches both an exclude and an include pattern, it is included.
Where the rendered files go. This field is optional. Loom clones this repository, creates a feature branch, writes into it, and pushes.
If omitted (and no --target-path is provided), Loom uses the module directory itself as the target.
Warning: When no
targetis configured, operations likenewFileswill write directly into the module directory, andshellcommands will execute there. Git operations (commitPush,pr) will fail if the directory is not a Git repository. Only omittargetfor modules that don't need Git operations.
| Field | Description |
|---|---|
url |
Git repository URL (HTTPS or SSH) |
branch |
Base branch to clone from |
featureBranch |
Branch to create for changes (templated). If omitted, changes are made directly on the base branch. |
The featureBranch field supports templates, so you can generate unique branch names per run:
target:
url: "https://github.com/myorg/gitops-repo.git"
branch: "main"
featureBranch: "loom/onboard-{{ .serviceName }}"This is the intended workflow: Loom clones the base branch, creates loom/onboard-payments from it, runs all operations on the feature branch, commits, pushes, then opens a PR from the feature branch back to the base branch. Without featureBranch, the head and base of a PR would be the same branch — so if your pipeline includes a pr operation, you should always set featureBranch.
You can skip the clone entirely with --target-path /some/local/repo on the CLI.
Child modules to execute before this module's operations. This is how you compose workflows — a parent module orchestrates several smaller ones.
| Field | Description |
|---|---|
name |
Identifier for the child module |
source |
Path to the child module — local (./sub-module) or a Git URL |
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.
The ordered list of steps. Each operation has a name and exactly one action type.
Any operation can carry an optional if predicate — a shell expression that decides whether the operation runs. The string is templated with your params, then run via sh -c: exit 0 runs the operation, any non-zero exit skips it (standard shell semantics). Omit if and the operation always runs.
operations:
- name: apply-overlay
if: "test -f {{ .service }}/kustomization.yaml" # only if the file exists
patch:
path: "__functions/patches/overlay.yaml"
target: "{{ .service }}/kustomization.yaml"
- name: deploy
if: '[ {{ .env }} = prod ]' # only in prod
shell:
command: "kubectl apply -f ."An operation's if runs in the target directory, so predicates can inspect the repository being changed. Child modules take the same if field (see Module Composition). The predicate is evaluated in every mode, including --dry-run, since it determines which steps a run would perform — keep it side-effect-free (test, grep, file checks).
Like
shellanddynamicParams, param values are templated into the shell string, so treat untrusted params (e.g. frombulkdata) with care.
Copies template files from the module directory into the target repository, rendering Go template expressions along the way.
- name: create-files
newFiles:
source: "." # relative to module directory
dest: "" # relative to target repository rootEvery file in the source directory is treated as a Go template, subject to the exclude/include rules. By default, .git, README.md, loom.yaml, and loom.jsonnet are excluded. Directories like __functions/ must be explicitly listed in excludes to prevent them from being copied. The directory structure is preserved. File and folder names can also contain Go template expressions (see Path Templating).
Modifies YAML files already in the target repository. Loom supports two patch engines, selected with the engine field:
| Engine | Description |
|---|---|
smp |
Strategic Merge Patch — a partial YAML document that is deep-merged into the target. Default when engine is omitted. |
json6902 |
RFC 6902 JSON Patch — a list of explicit add/remove/replace/move/copy/test operations. |
Both engines are powered by the kustomize library and built into Loom — no external tools required.
- name: overlay-app
patch:
path: "__functions/patches/smp-overlay.yaml"
target: "argocd/application.yaml"The patch file is a partial YAML document. Loom deep-merges it into the target with the following rules:
- Maps are merged recursively — fields present in the patch overwrite the target; fields absent from the patch are left untouched.
- Lists of maps are merged by an inferred key. Loom detects a common string field (e.g.
name) across list items and uses it to match entries. Matched items are deep-merged; unmatched patch items are appended. - Lists of scalars (strings, numbers) append unique values from the patch. Existing values are preserved, and duplicates are skipped.
- Scalars are replaced by the patch value.
Original file formatting (indentation, key order, flow style) is preserved.
Example — merging scalar values into a map:
# __functions/patches/smp-overlay.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: "{{ .serviceName }}"
namespace: "{{ .namespace }}"
labels:
managed-by: loom
team: platform
spec:
source:
targetRevision: HEADExample — appending to a list of scalars inside a matched list item:
Given a target with a list of ClusterSecretStore entries keyed by name, this patch appends loom to the allowednamespace list of vault-example-2 without touching other entries:
# __functions/patches/clustersecretstore-patch.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: ClusterSecretStoreControl
metadata:
name: clustersecretstorecontrol
spec:
parameters:
ClusterSecretStore:
- name: vault-example-2
allowednamespace:
- loomIf the target's vault-example-2 already has allowednamespace: [istio-system, argocd], the result will be [istio-system, argocd, loom]. Other entries like vault-example-1 and vault-example-3 remain unchanged.
Strategic merge also supports Kubernetes patch directives: $patch: delete to remove a field or list element, $patch: replace to replace an entire subtree instead of merging. This is the same patching strategy used by kubectl apply and kustomize.
- name: patch-app
patch:
engine: json6902
path: "__functions/patches/add-app.yaml"
target: "argocd/application.yaml"The patch file is a YAML list of RFC 6902 operations. Values are templated, so you can inject parameters:
# __functions/patches/add-app.yaml
- op: replace
path: /metadata/name
value: "{{ .serviceName }}"
- op: replace
path: /metadata/namespace
value: "{{ .namespace }}"
- op: add
path: /metadata/labels/managed-by
value: loom
- op: remove
path: /metadata/annotations/deprecatedSupported operations: add, remove, replace, move, copy, test. Paths follow RFC 6901 JSON Pointer syntax — / separated segments, with ~0 for ~ and ~1 for / in key names. The add operation creates intermediate maps if they don't exist yet, and supports appending to arrays with -.
The __functions/ directory is the conventional place for patch files. Add it to excludes in your loom.yaml so patches are never copied to the target as new files.
Runs an arbitrary shell command in the target repository directory.
- name: validate
shell:
command: "kubeval --strict argocd/{{ .serviceName }}.yaml"
timeout: "30s"| Field | Description |
|---|---|
command |
Shell command to execute (sh -c), templated |
timeout |
Maximum duration (e.g. 30s, 5m). Operation fails if exceeded |
pure |
If true, this command has no external side effects and runs even in --local-run mode. Default: false |
The command is rendered as a template, so you can inject parameters. The working directory is the target repository. If the command fails, Loom stops.
In --local-run mode, shell commands are skipped by default because they may create remote resources (deploy, notify, etc.). Mark a command with pure: true to indicate it has no external side effects — for example, formatting or validation commands that only read or modify local files:
- name: format
shell:
command: "gofmt -w ."
pure: true # no side effects, safe in --local-run mode
- name: deploy
shell:
command: "kubectl apply -f manifests/"
# not marked pure — skipped in --local-run modeStages all changes, creates a commit, and pushes to the remote.
- name: commit
commitPush:
message: "feat: onboard {{ .serviceName }}"
author: "loom-bot"
email: "loom@example.com"The author and email fields are optional. When omitted, Loom falls back to the --author / --email CLI flags, then to your system git config (user.name / user.email). This is useful for generated modules that don't specify an author.
Push authentication uses the LOOM_GIT_TOKEN environment variable when using the Go library. If the library push fails, Loom falls back to the system git binary, which uses your existing credential helpers and SSH configuration.
In --local-run mode, the commit is created locally but the push is skipped.
Opens a pull request on the target repository.
- name: open-pr
pr:
provider: github
title: "Onboard {{ .serviceName }}"
body: "Automated onboarding for {{ .serviceName }}"
baseBranch: main
labels: [automated]
tokenEnv: GITHUB_TOKEN| Field | Description |
|---|---|
provider |
github or gitlab |
title |
PR/MR title, templated |
body |
PR/MR description, templated |
baseBranch |
Branch to merge into (default: main) |
labels |
Labels to apply |
tokenEnv |
Name of the environment variable holding the API token |
In --local-run mode, PR creation is skipped entirely.
Both GitHub and GitLab are fully supported. For GitLab, the same schema applies — Loom creates a merge request instead of a pull request:
- name: open-mr
pr:
provider: gitlab
title: "Onboard {{ .serviceName }}"
baseBranch: main
labels: [automated]
tokenEnv: GITLAB_TOKENGitLab URLs are parsed automatically, including self-hosted instances and SSH URLs (git@gitlab.example.com:group/repo.git).
Uses an LLM to generate or modify a file. The output is written directly to the target — since the result will be reviewed by a human via PR/MR, this is a practical way to automate content that benefits from AI generation.
- name: generate-docs
llm:
provider: openai
model: "gpt-4o"
prompt: "Generate a Kubernetes NetworkPolicy YAML for service {{ .serviceName }} in namespace {{ .namespace }} that allows ingress on port 8080."
target: "{{ .namespace }}/networkpolicy-{{ .serviceName }}.yaml"| Field | Description |
|---|---|
provider |
openai, anthropic, vertex, gemini, openrouter, or bedrock |
model |
Model name (e.g. gpt-4o, claude-sonnet-4-20250514, gemini-2.5-flash) |
prompt |
The prompt to send, templated with params |
systemPrompt |
Optional system prompt, templated |
target |
File path (relative to target dir) to write the output, templated |
mode |
generate (default) creates/overwrites the file. modify reads the existing file and includes its content in the prompt for the LLM to modify. |
maxTokens |
Maximum output tokens (optional) |
retries |
Max retry attempts on failure (default: 0, no retry) |
retryDelay |
Initial delay between retries (default: 2s), doubles each attempt (exponential backoff) |
providerConfig |
Provider-specific settings (see below) |
All provider-specific settings live under providerConfig. Secrets are referenced by env var name — they must never appear directly in loom.yaml.
| Field | Description |
|---|---|
tokenEnv |
Name of the env var holding the API key (openai, anthropic, gemini, openrouter). Not used by vertex or bedrock. |
project |
GCP project ID (required for vertex) |
location |
GCP region (default: us-central1, vertex only) |
| Provider | Auth | Default env var |
|---|---|---|
openai |
API key | OPENAI_API_KEY |
anthropic |
API key | ANTHROPIC_API_KEY |
gemini |
API key | GEMINI_API_KEY |
vertex |
GCP Application Default Credentials (ADC) | No key needed |
openrouter |
API key | OPENROUTER_API_KEY |
bedrock |
AWS credentials (env vars, shared config, IAM role) | No key needed |
The vertex provider uses ADC — if you're authenticated via gcloud auth application-default login or running on GCP, no API key is required. Just set project and optionally location.
The bedrock provider uses the AWS SDK default credential chain — environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), shared config (~/.aws/credentials), or IAM roles. No API key is needed.
In modify mode, Loom reads the existing file at target and includes its content in the prompt automatically. This is useful for having the LLM update or refactor an existing file:
- name: update-readme
llm:
provider: anthropic
model: "claude-sonnet-4-20250514"
prompt: "Update this README to document the new {{ .serviceName }} service. Keep the existing structure."
systemPrompt: "You are a technical writer. Output only the file content, no markdown fences."
target: "docs/README.md"
mode: modify- name: generate-policy
llm:
provider: vertex
model: "gemini-2.5-flash"
prompt: "Generate a GatekeeperConstraint for {{ .serviceName }}"
target: "constraints/{{ .serviceName }}.yaml"
providerConfig:
project: "my-gcp-project"
location: "us-central1"Loom uses Go's text/template syntax. Inside any templatable string, you can reference parameters with {{ .paramName }}.
Available functions:
| Function | Example | Result |
|---|---|---|
| Parameter access | {{ .serviceName }} |
payments |
| Default value | {{ default "prod" .env }} |
prod if .env is empty |
| Uppercase | {{ upper .serviceName }} |
PAYMENTS |
| Lowercase | {{ lower .serviceName }} |
payments |
Templates work in:
- File contents (newFiles)
- File and folder paths (newFiles) — see Path Templating
- Shell commands
- Commit messages
- PR/MR titles and bodies
- Feature branch names
- Child module parameters
File and folder names are rendered as Go templates, just like file contents. You can use {{ .paramName }} directly in file and directory names.
| Source path | With serviceName=payments, env=prod |
Result |
|---|---|---|
{{ .env }}/config.yaml |
prod/config.yaml |
|
application-{{ .serviceName }}.yaml |
application-payments.yaml |
|
{{ .env }}/{{ .serviceName }}-deploy.yaml |
prod/payments-deploy.yaml |
This means your module directory can look like:
onboard-service/
├── loom.yaml
├── {{ .env }}/
│ └── {{ .serviceName }}-app.yaml
└── shared/
└── config.yaml
Running with -p serviceName=payments -p env=prod produces:
prod/
└── payments-app.yaml
shared/
└── config.yaml
For convenience, Loom also supports a filesystem-friendly __paramName__ placeholder syntax. This is useful when your filesystem, shell, or editor has trouble with curly braces in filenames. Loom converts __paramName__ to {{ .paramName }} before rendering. Both syntaxes can be mixed freely.
__paramName__ syntax |
Equivalent Go template |
|---|---|
__env__/config.yaml |
{{ .env }}/config.yaml |
application-__serviceName__.yaml |
application-{{ .serviceName }}.yaml |
Modules can reference other modules. This is Loom's answer to the question: "how do I reuse automation across teams?"
# root module
spec:
params:
- name: serviceName
required: true
- name: namespace
default: "default"
modules:
- name: base-infra
source: "./modules/base-infra"
params:
namespace: "{{ .namespace }}"
- name: argocd-app
source: "https://github.com/myorg/loom-modules.git"
params:
serviceName: "{{ .serviceName }}"
namespace: "{{ .namespace }}"
operations:
- name: commit-all
commitPush:
message: "feat: full onboard for {{ .serviceName }}"
author: "loom-bot"
email: "loom@example.com"A child reference also accepts an if predicate to skip the whole module (and its operations and sub-modules) when the shell expression exits non-zero. It runs in the child's resolved target directory, so it can inspect that repository before deciding:
modules:
- name: argocd-app
source: "./modules/argocd-app"
if: "test -d argocd" # skip unless the target has an argocd/ dirExecution order:
- Child modules run first, in the order they're listed
- Each child module can have its own child modules (recursive)
- Then the parent's operations run
- All modules write into the same target directory
Sources can be:
- Local paths (
./relative/pathor/absolute/path) — resolved relative to the parent module - Git URLs — cloned to a temporary directory automatically
This means you can publish reusable modules as Git repositories. A platform team maintains the standard modules; product teams compose them.
Execute a module.
loom run [path] [flags]
| Flag | Description |
|---|---|
-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) |
--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 |
--local-run |
Run all operations locally but skip remote push and PR creation |
-v, --verbose |
Enable debug logging |
--log-level level |
Set log level: debug, info, warn, error |
# Full run against a remote repo
loom run ./onboard-service -p serviceName=payments
# Dry run against a local checkout
loom run ./onboard-service \
-p serviceName=payments \
--target-path ~/repos/gitops \
--dry-run
# See exactly what files would be created and changed
loom diff ./onboard-service \
-p serviceName=payments
# Local mode — render, write, and commit, but don't push or open a PR
# --target-path is required with --local-run so you can inspect the results
loom run ./onboard-service \
-p serviceName=payments \
--target-path ~/repos/gitops \
--local-run
# Parameters from file
loom run ./onboard-service --params-file params.yamlShow the changes a module run would produce, without opening a PR.
By default, loom diff runs the module in local mode — it clones each target, executes every operation (including pure: true shell commands), commits locally, and skips push and PR creation — then prints a git diff of each target against its base branch. Because operations actually run, this is the complete picture, including files rewritten by shell commands (formatters, codegen).
# Full diff: the complete change, including shell-op effects
loom diff ./onboard-service -p serviceName=payments
# Keep the clones for inspection instead of a temp dir
loom diff ./onboard-service -p serviceName=payments --target-path ./previewAdd --quick for a fast, no-execution preview: it simulates the run (dry-run) and prints unified diffs for newFiles and patch operations only. It never executes anything, so it cannot show changes made by shell commands.
# Quick preview: newFiles/patch diffs, executes nothing
loom diff ./onboard-service -p serviceName=payments --quick| Flag | Description |
|---|---|
--quick |
Simulate the run (dry-run); show newFiles/patch diffs only, execute nothing |
--partial |
On failure, still print the diff of changes made before the error (below a warning) |
-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 temp dir |
--author name |
Default git author name for commitPush |
--email email |
Default git author email for commitPush |
Generate a reusable loom module from an existing GitHub PR or GitLab MR. This is the fastest way to turn a manual change you've already made into repeatable automation.
loom generate <pr-url> [flags]
| Flag | Description |
|---|---|
-p, --param key=value |
Concrete value to parameterize (can be repeated). Every occurrence of the literal value is replaced with a template expression. |
-o, --output dir |
Output directory for the generated module (default: current directory) |
-n, --name name |
Module name (default: derived from PR title) |
--token-env VAR |
Env var holding the API token (default: GITHUB_TOKEN or GITLAB_TOKEN) |
--exclude-git-ops |
Skip generating target, commitPush, and pr operations |
Supported references:
https://github.com/owner/repo/pull/123
https://gitlab.com/group/repo/-/merge_requests/123
github:owner/repo#123
gitlab:group/repo!123
By default, the generated module is written to the current directory. This means you can cd into your module's Git repo and run loom generate directly — no nested folders. Use -o to write to a different directory instead:
loom generate <pr-url> -o ./my-module # writes into ./my-module/Example: You onboarded a service called "payments" via a PR. Now you want to make that repeatable:
loom generate https://github.com/myorg/gitops-repo/pull/42 \
-p serviceName=payments \
-p namespace=fintechLoom fetches the PR diff and produces a ready-to-use module:
- Added files become templates with
{{ .serviceName }}and{{ .namespace }}replacing the concrete values, including in file and folder names. - Modified YAML files become strategic merge patches under
__functions/patches/. - Deleted files become
shelloperations withrm. - Renamed files become
shelloperations withmv.
The generated loom.yaml declares all parameters as required and wires up the operations in order. You can then customize it, add defaults, compose it with other modules, or run it as-is:
loom run ./onboard-service -p serviceName=billing -p namespace=platformAuthentication uses GITHUB_TOKEN / GITLAB_TOKEN environment variables by default. If the token is not set but the gh or glab CLI is installed and authenticated, Loom falls back to it automatically.
Check that a loom.yaml is well-formed.
loom validate [path]
Validates: apiVersion, kind, required metadata, unique parameter names, unique operation names, and that each operation has exactly one action type.
Print the version.
loom version
go install github.com/rickliujh/loom@latestOr build from source:
git clone https://github.com/rickliujh/loom.git
cd loom
go build -o loom .Loom is a single static binary. It embeds Go libraries for Git, PR/MR creation, and YAML patching, so it can run with zero external dependencies. When the Git or PR libraries hit an edge case — an SSH agent configuration go-git doesn't handle, or a credential helper it can't talk to — Loom automatically falls back to the CLI tools already on your machine (git, gh, glab).
On a DevOps laptop where these tools are already authenticated and configured, Loom just works.
Declarative over imperative. You describe what should happen, not how. Loom handles the mechanics of cloning, rendering, committing, and pushing. Your module is a specification, not a script.
Files as the interface. A module is a folder. Templates are just files with Go template syntax. The directory structure is the destination structure. There's no abstraction layer between what you write and what lands in the repository.
Composable by default. Modules reference other modules. Parameters flow down. You build small, focused modules and combine them. The same module that onboards one service onboards a hundred — you just change the parameters.
Ordered operations, flat list. Operations execute top to bottom. There's no DAG, no dependency graph, no parallel execution. This is intentional. GitOps workflows are inherently sequential: render files, then validate, then commit, then open a PR. A flat list is easy to read, easy to debug, and hard to get wrong.
Template everywhere. Every string in operations — commands, commit messages, PR titles, file paths — is a Go template. You never have to switch between "static" and "dynamic" configuration. It's all dynamic, all the time.
Library first, CLI fallback. Loom embeds go-git for Git operations, uses the GitHub/GitLab APIs for PR creation, and implements RFC 6902 patching natively — no external binaries needed in CI or containers. But on a developer's laptop, the system git, gh, and glab are already authenticated and configured. When the library path fails (auth issues, unsupported SSH configs, token not set), Loom detects the local binary and retries through it. You get the portability of a self-contained binary and the compatibility of native tooling, without choosing between them.
| Operation | Library (primary) | CLI fallback |
|---|---|---|
| Clone, push, branch, commit | go-git | git |
| GitHub PR | go-github API | gh |
| GitLab MR | go-gitlab API | glab |
| YAML patch (SMP, JSON6902) | kustomize library | — |
loom run ./module -p key=val
│
▼
┌─────────────────┐
│ Config Loader │ Parse loom.yaml, validate schema
└────────┬────────┘
▼
┌─────────────────┐
│ Module Loader │ Resolve params (provided + dynamic + defaults)
└────────┬────────┘
▼
┌─────────────────┐
│ Clone + Branch │ Clone target repo, create feature branch
└────────┬────────┘
▼
┌─────────────────┐
│ Executor │ Walk child modules (recursive), then operations
└────────┬────────┘
▼
┌─────────────────┐
│ Action Dispatch │ Route each operation to its handler
└────────┬────────┘
▼
┌────┬────┬────┬────┬────┐
│ New │Pch │Shl │ CP │ PR │ Action implementations
│Files│ │ │ │ │
└────┴────┴────┴────┴────┘
│
▼
┌─────────────────┐
│ Git / Provider │ Library-first, CLI fallback
│ go-git → git │
│ go-github → gh │
│ go-gitlab → glab│
└─────────────────┘
Each action implements a single interface:
type Action interface {
Execute(ctx context.Context, execCtx *ExecutionContext) error
}Adding a new operation type means implementing this interface and registering it — nothing else changes.