Skip to content

rickliujh/loom

Repository files navigation

Loom

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.

Installation

Download a release binary

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/

Build from source

Requires Go 1.25+.

go install github.com/rickliujh/loom@latest

Or clone and build:

git clone https://github.com/rickliujh/loom.git
cd loom
go build -o loom .

Verify

loom version

The Problem Loom Solves

GitOps 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.

How It Works

A Loom module is a directory. Inside it, you put:

  1. loom.yaml — the workflow definition. It declares parameters, operations, and optionally references child modules.
  2. Template files — any files alongside loom.yaml are treated as Go templates. Their directory structure mirrors where they'll land in the target repository.
  3. __functions/ — a conventional directory for patches, configs, and supporting files that should not be copied to the target. Add it to excludes in your loom.yaml to 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:

  1. Loads loom.yaml and resolves parameters
  2. Clones the target Git repository (or uses a local path you specify)
  3. Creates a feature branch if featureBranch is configured
  4. Walks through operations in order — rendering templates, running shell commands, committing, pushing, opening a PR
  5. 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.

The loom.yaml File

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_TOKEN

spec.params

Parameters 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.

spec.dynamicParams

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 fails

Dynamic 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 above

If 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.

spec.excludes and spec.includes

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:

  • .git directory
  • README.md file (case-insensitive)
  • loom.yaml and loom.jsonnet config 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.

spec.target

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 target is configured, operations like newFiles will write directly into the module directory, and shell commands will execute there. Git operations (commitPush, pr) will fail if the directory is not a Git repository. Only omit target for 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.

spec.modules

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.

spec.operations

The ordered list of steps. Each operation has a name and exactly one action type.

Operations Reference

Conditional execution (if)

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 shell and dynamicParams, param values are templated into the shell string, so treat untrusted params (e.g. from bulk data) with care.

newFiles — Render and Write Templates

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 root

Every 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).

patch — Patch Existing Files

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.

Strategic Merge Patch (default)

- 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: HEAD

Example — 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:
          - loom

If 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.

JSON6902

- 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/deprecated

Supported 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.

shell — Run a Command

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 mode

commitPush — Commit and Push

Stages 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.

pr — Open a Pull Request

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_TOKEN

GitLab URLs are parsed automatically, including self-hosted instances and SSH URLs (git@gitlab.example.com:group/repo.git).

llm — LLM Inference

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)

providerConfig

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)

Providers and Authentication

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.

Modify mode

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

Example with Vertex AI (ADC)

- 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"

Templates

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

Path Templating

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

Module Composition

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/ dir

Execution order:

  1. Child modules run first, in the order they're listed
  2. Each child module can have its own child modules (recursive)
  3. Then the parent's operations run
  4. All modules write into the same target directory

Sources can be:

  • Local paths (./relative/path or /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.

CLI

loom run

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.yaml

loom diff

Show 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 ./preview

Add --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

loom generate

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=fintech

Loom 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 shell operations with rm.
  • Renamed files become shell operations with mv.

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=platform

Authentication 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.

loom validate

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.

loom version

Print the version.

loom version

Installation

go install github.com/rickliujh/loom@latest

Or 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.

Design Philosophy

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

Architecture

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.

License

GPL-3.0

About

Loom automates the last mile of your GitOps

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages