cinzel, pronounced "sin-ZEL" (IPA: /sĩˈzɛl/), is the Portuguese word for chisel.
It converts CI/CD pipelines between YAML and HCL, in both directions, for GitHub Actions and GitLab CI/CD.
Made with ❤️ by YLD Limited.
YAML pipelines grow by copy and paste. There is no way to say "this step, the one I already wrote", so you write it again, and the fourth copy drifts from the first.
HCL has references. A step is a block, and a job points at it by name:
# .github/workflows/ci.yaml — tags, not SHAs, to keep the example short
name: CI
on:
push:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test
run: go test ./...# cinzel/ci.hcl — the same job. Its step blocks sit further down the file.
job "build_and_test" {
id = "build-and-test"
runs_on {
runners = "ubuntu-latest"
}
steps = [
step.checkout,
step.test,
]
}step.checkout is a real reference. Rename the block and every job using it
follows. Write the step once and ten jobs can share it. GitHub and GitLab still
only read YAML, so cinzel converts back.
Comments survive the trip, in both directions and in all three positions — a comment above a block, one beside a value, one closing a body. The note saying why a version is held back is still there after the conversion.
One cannot be carried: the comment above env, with, outputs or
secrets. Each is one mapping in YAML and one block per key in HCL, so there
is no block to hold a note about the whole set. Unparse drops it and prints
where it was, as file:line: path, rather than silently pinning it to the key
that happens to sort first.
Download a binary from Releases, or:
brew install --cask yldio/tap/cinzelgo install github.com/yldio/cinzel@latestThen cinzel --help.
Point unparse at a pipeline you already run:
cinzel github unparse --file .github/workflows/ci.yaml --output-directory ./cinzel
cinzel gitlab unparse --file .gitlab-ci.yml --output-directory ./cinzelEdit the HCL, then convert it back:
cinzel github parse --file ./cinzel/ci.hcl --output-directory .github/workflows
cinzel gitlab parse --file ./cinzel/.gitlab-ci.hcl --output-directory .Add --dry-run to either to see the result without writing anything.
Converting is a conversion, not a rewrite: what the YAML says is what the HCL
says, and parse writes back what the HCL holds. cinzel adds no keys of its own
— a workflow that declares no permissions gets none, and what a pipeline
ought to declare is for you and your linter to settle.
The two providers work the same way but their HCL differs, because the platforms do:
- GitHub Actions — workflows, composite actions, and the step reference graph.
- GitLab CI/CD — jobs, templates, includes, and the pipeline keywords.
parse and unparse share these:
| Flag | Description |
|---|---|
--file, -f |
Read one file. A directory is refused; --directory is for that. |
--directory, -d |
Read every matching file in a directory. Not with --file. Repeatable on unparse; parse takes one. |
--recursive, -r |
Walk subdirectories of --directory. |
--root |
Resolve every path against this directory instead of the working directory. Not with --output-directory. |
--output-directory |
Where to write. Parse defaults to .github/workflows (GitHub) or the working directory (GitLab); unparse defaults to ./cinzel. Superseded by --root. |
--dry-run |
Print instead of writing. |
parse also takes --yml to write .yml instead of .yaml. GitLab parse
always writes .gitlab-ci.yml, so it changes nothing there.
cinzel fmt rewrites the HCL in the canonical layout. With no flags it
formats wherever your HCL lives: the directories named in .cinzelrc.yaml,
or ./cinzel when there is no config. --file and --directory name one
explicitly, --recursive walks subdirectories, and --check names the files
that would change and exits non-zero without writing one. A run that finds no
HCL at all fails rather than reporting a clean check.
GitHub unparse always reads .github/workflows, whatever else --directory
names, since that is the only place GitHub reads a workflow from. Naming it
explicitly still works and warns, and a repository without one is passed over
rather than failed.
unparse --mirror lays the HCL out the way the repository is laid out —
cinzel/actions/setup.hcl rather than cinzel/setup.hcl — which reads
better in a tree. Where a file goes on the way back is its own path either
way, so this is a convenience, and a mirrored tree has to be parsed with
--recursive.
A composite action sits at <dir>/<name>/action.yml, so a folder of actions
holds no YAML of its own. -d .github/actions reads the actions one level
down without --recursive being typed as well; anything deeper still needs
the flag.
Workflows stay at the top of the output directory even under --mirror.
.github/workflows is the one place GitHub reads a workflow from, so it is
not a layout worth reproducing: parse writes every workflow back there
whatever the HCL is called, and a mirrored cinzel/.github/workflows is a
directory nothing reads.
If you keep composite actions in folders of their own, one run each way converts the lot:
cinzel github unparse -d actions-x -d actions-y -r --output-directory cinzel
cinzel github parse -d cinzelEach action remembers where it came from, so parse puts it back there. The
GitHub provider README covers the path attribute
that holds it.
--output-directory says where one kind of file goes. --root says where the
whole output shape goes, which is one answer instead of one per document type.
It must be a path relative to where cinzel runs:
cinzel github parse --root project-x
# → project-x/.github/workflows/ci.yaml
# → project-x/actions-x/setup/action.ymlIt applies to both directions and both providers, and on unparse it is the
base for what is read as well as what is written. Setting it together with
--output-directory is an error rather than a precedence rule.
--output-directory still works and is unchanged, and nothing is scheduled to
remove it. --root is the one to reach for in new work: it says where
everything goes rather than where one kind of file goes, which is the shape a
repository with actions outside .github/workflows needs.
Put the flags you always pass into .cinzelrc.yaml and stop typing them:
github:
parse:
directory: ./cinzel
output-directory: .github/workflows
yml: false
recursive: trueA flag on the command line still wins, including --recursive=false against a
file that sets it true. It covers parse and unparse only, per provider —
assist, pin, upgrade and init ignore it. The keys are file,
directory, output-directory, yml, recursive and ignore-unknown; the
last three are booleans, written bare rather than quoted. yml only affects
parse and ignore-unknown only unparse, so each is accepted and ignored under
the other. Anything else prints warning: ...: unknown key and is skipped.
Paths must be relative and written with forward slashes. The file is meant to
be committed and read on every machine that checks the repo out, so an absolute
path or a leading ~ is refused with an error naming the key. Forward slashes
are converted to whatever the running system separates with, so one spelling
works on Linux, macOS and Windows.
A YAML key the schema does not declare stops the conversion, naming the key and the line. That is the right answer for a typo: the file is left as it was, with nothing lost.
unparse --ignore-unknown drops those keys instead, naming each one with its
file and line. It is worth knowing what that costs. The key is gone from the
HCL, so it is gone from the YAML the next parse writes — over the file it came
from. diff reports no drift afterwards, because the HCL and the YAML agree:
they agree on a document missing a key you wrote. Reach for it when a key is
one cinzel's schema has not caught up with, not to get past a typo.
Once the HCL is the source, the YAML under .github/workflows is output, and
an edit made there goes away the next time anyone runs parse. Nothing stops
that edit at the time it is made, so check for it:
cinzel github diff
cinzel gitlab diffIt reports what a parse would change and writes nothing. As the first step of a workflow it fails the run when the committed YAML is not what the HCL produces:
- name: Pipelines match their HCL
run: cinzel github diffThree outcomes, so a workflow can tell them apart:
| Exit | Meaning |
|---|---|
| 0 | The committed YAML is exactly what the HCL produces. |
| 1 | Something drifted. Every file is named, with the lines that differ. |
| 2 | cinzel could not work it out: unreadable HCL, a missing directory. |
.github/workflows/pull-request.yaml differs
@@ -10,7 +10,7 @@
jobs:
pull_request:
name: "${{ matrix.os }}"
- runs-on: ubuntu-24.04
+ runs-on: "${{ matrix.os }}"
steps:
- id: checkout
name: Checkout
would delete: .github/workflows/old-ci.yaml (generated by cinzel, no longer written)
Diff summary: 3 checked, 1 differ, 0 would be created, 1 would be deletedRemoved lines are red and added ones green on a terminal, and plain
everywhere else — a pipe, a file, a CI log. Set NO_COLOR to anything, the
empty string included, to turn it off on a terminal too.
The - and + sit in their own column rather than against the text, so the
content starts in the same place on every line. Written the usual way, the
marker shifts the indentation of exactly the lines you are reading the
indentation of.
would delete is the one a plain diff -ru against a freshly parsed copy
cannot report. Parse prunes generated YAML it no longer writes, so a renamed
workflow leaves its old output behind — and a directory comparison reads that
as a file missing on one side, which looks the same as drift and never clears.
It takes parse's input and output flags — --file, --directory,
--recursive, --output-directory and --yml — and not --root or
--dry-run, since it writes nothing. It reads the same .cinzelrc.yaml
section, since it is answering what parse would do. A CRLF checkout is not
reported as drift: generated files are written with LF, and git converts line
endings on Windows by default.
One thing to know: the cinzel-source marker records the HCL file a workflow
came from, relative to where cinzel ran. Run diff from a different directory
than the last parse and that line differs, so the file is reported even
though its YAML says the same thing. Run both from the repository root.
cinzel github assist --prompt "golang PR with tests and linting"This asks an LLM for a pipeline, converts it through the same unparse path your
own YAML goes through, and writes HCL to a timestamped folder. Blocks matching
HCL you already have are replaced with a // reuses: comment rather than
duplicated. For GitHub, action versions are pinned to SHAs on the way out.
cinzel/assist/
20260317-150405/ # first prompt
assist.hcl
20260317-151200/ # second prompt
assist.hcl
Refine what came back, against the latest session or a named one:
cinzel github assist --refine "add slack notification on failure" --prompt "add to PR workflow"
cinzel github assist --refine "add caching" --from 20260317-150405It needs an API key, read from the environment:
export ANTHROPIC_API_KEY=...
# or
export OPENAI_API_KEY=...
cinzel github assist --ai openai --prompt "..."cinzel init writes a config holding your default provider and the model to
use for each. It deliberately does not hold a key: that file lands on disk and
gets swept into a backup of your home directory, so the environment is the
place for one.
Other assist flags:
| Flag | Description |
|---|---|
--output-directory |
Where session folders go (default cinzel/assist). |
--dry-run |
Print instead of writing. |
--acknowledge |
Skip the cost confirmation. |
--ai |
anthropic or openai. |
--model |
Model override. |
--no-context |
Do not send your existing HCL as context. |
--context-dir |
Where to read that context from (default cinzel). |
Both providers support assist.
GitHub only; GitLab has no equivalent.
cinzel github pin # rewrite every action tag as the SHA it points at
cinzel github upgrade # bump to latest, then pin
cinzel github upgrade --parse # and regenerate the YAMLBoth take --dry-run, --check, --file/-f and --directory/-d (default
cinzel) and --json. upgrade also takes --cooldown-days and
--only-action. upgrade --parse writes to --output-directory (default
.github/workflows).
Either command exits non-zero when it could not resolve an action. Each failure is named as it happens and the summary line counts them.
--check is the CI form, the way fmt --check is. It writes nothing, and ends
non-zero when there was something to do — pin --check on an action still
following a tag, upgrade --check on one with a newer release:
cinzel github pin --check # fails if anything still follows a tag
cinzel github upgrade --check # fails if anything has a newer releasewould pin actions/checkout@v4.1.0 → 8ade135a41bc
Pin summary: 1 not pinned, 15 already pinned, 0 failed
--dry-run prints the same report and exits 0 whatever it finds, so it answers
"what would this do" rather than "is this done". A step gating on an unpinned
action wants --check.
upgrade takes whatever the newest release is, which means one published
minutes ago reaches your pipeline before anyone else has run it. A cooldown
leaves a release alone until it has been out for a while:
# .cinzelrc.yaml
github:
upgrade:
cooldown-days: 7 # an example, not a recommendationSeven days is what the example uses because it is a round number, not because it is the right one. How long to wait is a judgement about the actions you depend on and how quickly you want their fixes, so pick your own.
would upgrade actions/upload-pages-artifact: 56afc609e742 → v5.0.0 (fc324d354710)
held actions/deploy-pages: d6db90164ac5 → v5.0.1
published 2026-11-03, inside the 7d cooldown
upgrades on or after 2026-11-10
Upgrade summary: 1 upgraded, 13 already current, 0 failed, 1 held in cooldown
A held release says when it becomes eligible, so a run that declines an upgrade says why rather than looking like one that found none.
--cooldown-days overrides the file for one run, and --cooldown-days 0 turns
the policy off. Without either there is no cooldown, which is how upgrade
behaved before this existed.
A held release does not fail --check. The policy working is not a problem to
go red over, and a check that failed on one would stay red for the length of
every window.
--only-action moves the action you name and leaves the rest of the file
alone, which is how a bump you are unsure about gets taken on its own:
cinzel github upgrade --only-action actions/deploy-pageswould upgrade actions/deploy-pages: d6db90164ac5 → v5.0.1 (368f82528645)
Upgrade summary: 1 upgraded, 0 already current, 0 failed, 14 not targeted
A name that matches nothing fails rather than reporting a clean run, so a typo does not look like a directory with nothing to do.
--json on either command reports the run on stdout, with the per-action
progress on stderr so stdout stays a document:
cinzel github upgrade --check --json | jq '.actions[] | select(.status == "held")'{
"action": "actions/deploy-pages",
"status": "held",
"from": "v4",
"fromSHA": "d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e",
"to": "v5.0.1",
"toSHA": "368f8285286458fd5d2cbd5c4e95a8a62aae3b53",
"published": "2026-09-01",
"eligibleAt": "2026-11-30"
}Each end of a move is a release and the commit it names, so from/fromSHA
and to/toSHA pair up. An action still on a tag has no fromSHA until the
run resolves one.
Each action carries a status: pinned, already-pinned or failed for
pin, and upgraded, current, held or failed for upgrade. A failure
carries its error, so a script that captured stdout does not have to read
the human output as well to find out what went wrong.
The exit code is the same either way — the flag changes how a run is reported, not what it concludes.
No token is needed for public actions. GITHUB_TOKEN raises the rate limit
from 60 an hour to 5000.
--check writes nothing and exits non-zero when it found something to do, so
it works as a step on its own:
- run: cinzel github pin --check # fails if anything follows a tag
- run: cinzel github upgrade --check # fails if anything has a newer release--dry-run exits 0 whatever it finds, so it answers "what would this do"
rather than "is this done". A step meant to go red wants --check.
Upgrading everything in one commit means one review covering several unrelated bumps, and a revert that takes the good ones with the bad. Ask what is available, then move them one at a time:
cinzel github upgrade --dry-run --json |
jq -r '.actions[] | select(.status == "upgraded" or .status == "held") | .action'jdx/mise-action
actions/upload-pages-artifact
actions/deploy-pages
Each name goes back in as a target. The JSON already holds both ends of the move, so the commit message can say what changed rather than only what was touched:
cinzel github upgrade --dry-run --json |
jq -r '.actions[] | select(.status == "upgraded") |
[.action, .from, .to] | @tsv' |
while IFS=$'\t' read -r action from to; do
branch="upgrade/$(echo "$action" | tr / -)"
git switch -c "$branch" main
cinzel github upgrade --only-action "$action" --parse
git commit -am "chore(deps): upgrade ${action##*/}" \
-m "$action $from -> $to" \
-m "Pinned to the commit $to names. The YAML is regenerated from
the HCL in the same commit, so the two stay in step."
gh pr create --fill
git switch main
doneFor actions/deploy-pages, that gives:
branch upgrade/actions-deploy-pages
chore(deps): upgrade deploy-pages
actions/deploy-pages v4 -> v5.0.1
Pinned to the commit v5.0.1 names. The YAML is regenerated
from the HCL in the same commit, so the two stay in step.
The version is in the body, not the header. Conventional-commit linters cap
a header — this project at 50 characters — and both the owner and the
version push past it on a real action:
chore(deps): upgrade upload-pages-artifact to v5.0.0 is 52, and with the
owner it is 60. The body has no such limit, so the full name and both
versions go there, where they are searchable and nothing truncates them.
--fill then takes that commit as the pull request title and body, so the
two agree without writing it twice.
Three things that bite:
--parse is not optional here. Without it the HCL moves and the generated
YAML does not, and the two stay out of step until something regenerates them.
Select held as well as upgraded if you have a cooldown. A release inside
its window is reported as held, so filtering on upgraded alone silently
skips exactly the upgrades the policy is holding for you.
Each run is one API request per action, plus one for the survey. That is fine authenticated; the unauthenticated 60 an hour is reachable on a large directory.
The same loop on a schedule. One job surveys, a second opens a pull request per action it found:
on:
schedule:
- cron: "0 9 * * 1"
permissions:
contents: write
pull-requests: write
jobs:
survey:
runs-on: ubuntu-latest
outputs:
actions: ${{ steps.find.outputs.actions }}
steps:
# Tags here for readability. Pin this workflow too — see below.
- uses: actions/checkout@v5
- run: go install github.com/yldio/cinzel@latest
- id: find
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
actions=$(cinzel github upgrade --dry-run --json |
jq -c '[.actions[] | select(.status == "upgraded") | .action]')
echo "actions=$actions" >> "$GITHUB_OUTPUT"
upgrade:
needs: survey
if: needs.survey.outputs.actions != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
action: ${{ fromJSON(needs.survey.outputs.actions) }}
steps:
- uses: actions/checkout@v5
- run: go install github.com/yldio/cinzel@latest
- env:
ACTION: ${{ matrix.action }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
branch="upgrade/${ACTION//\//-}"
git switch -c "$branch"
cinzel github upgrade --only-action "$ACTION" --parse
git commit -am "chore(deps): upgrade $ACTION"
git push -u origin "$branch"
gh pr create --fillfail-fast: false so one action that will not resolve does not cancel the
pull requests for the rest. The if is there because an empty matrix is a
workflow error rather than a job that does nothing.
The uses lines above name tags, which is easier to read on a page and not
what you want in the repository. This workflow has the same supply chain as
any other, so pin it along with the rest — cinzel github pin rewrites the
HCL it came from, and pin --check in CI keeps it that way.
Put the cooldown in .cinzelrc.yaml rather than in the workflow. A scheduled
job that has to remember a flag is one edit away from silently not having it,
and the file is read by the runs you do by hand too.
There is a skill for Claude Code, Codex, opencode and pi, so an agent knows what the HCL looks like and which command goes which way:
/plugin marketplace add yldio/cinzel
/plugin install cinzel@cinzel-plugin
See plugins/cinzel/README.md for the other
agents, which read the same SKILL.md from their own skills directory.
- Changelog
- Contributing — and the Discussions, worth reading before opening an issue or a PR
- Code of Conduct
- Homebrew release automation, for release operators
Licensed under Apache-2.0. See LICENSE.
