6 releases
| new 0.0.7 | May 22, 2026 |
|---|---|
| 0.0.6 | May 21, 2026 |
#281 in Text processing
2MB
22K
SLoC
ravelact
ravelact is a standalone GitHub Actions estate-analysis CLI for reading, tracing, and untangling complex workflow graphs. The name combines ravel (to follow and untangle a complicated structure) with act (the GitHub Actions domain).
It builds an IR from .github/workflows/*.{yml,yaml} and any action.{yml,yaml} in the repository, then turns that map into practical answers: what runs from a trigger, who calls a reusable workflow or local action, what a PR diff impacts, which declarations are orphaned, and where wiring, permissions, secrets, or refactor opportunities need attention.
Note
ravelact runs offline against the local repository β no GitHub API calls and no token is required. Local-action discovery prunes derived / VCS-internal directories at any depth: .git, target, node_modules, dist, build.
Table of Contents
- Features
- Installation
- AgentSkill for AI Agents
- Quick Start
- Usage
- Annotations
- CI Integration
- Documentation
- License
Features
π§ Workflow Graph Navigation
- Trace every workflow reachable from an event with
trace. - Ask "who calls this workflow / action?" in reverse with
callers. - Export the whole estate as a Mermaid
graph LRwithgraph.
π Change Impact & Dead API Detection
- Feed
git diff --name-onlyintoimpactto surface transitively affected entry-point workflows. - Use
orphansto find declared-but-unused reusable workflows, local actions, inputs, and outputs in one pass.
π Permissions & Secrets Audits
- Analyse callerβcallee
permissionschains using GitHub Actions' monotonic-decrease rule. - Check
secretspropagation where each reusable-workflow layer must explicitly opt in.
π οΈ Refactor Signals
- Rank composite-action extraction candidates from duplicated step sequences with
extract. - Cluster near-duplicate workflows with
dedupusing single-linkage union-find.
π€ AI-Agent Ready
- Ships an AgentSkill so coding assistants (Claude Code, Copilot, Cursor, Codex, Gemini CLI, β¦) know which command to run for which question.
Installation
From crates.io
cargo install ravelact
From Binary Releases
Prebuilt binaries are published for macOS (arm64 / x86_64) and Linux (arm64 / x86_64) on every release.
# Pick your platform asset from the latest release, then:
gh release download --repo wadackel/ravelact --pattern 'ravelact-darwin-arm64'
install -m 0755 ravelact-darwin-arm64 /usr/local/bin/ravelact
ravelact --version
The same binary works without gh:
curl -L -o ravelact https://github.com/wadackel/ravelact/releases/latest/download/ravelact-darwin-arm64
install -m 0755 ravelact /usr/local/bin/
In GitHub Actions
Use the repository's setup action to install ravelact on GitHub-hosted Linux and macOS runners:
- uses: wadackel/[email protected]
- run: ravelact --version
Replace vX.Y.Z with the first ravelact release that includes action.yaml, or any newer release. Older release tags cannot be used as the action ref because they do not contain the action metadata. For security-sensitive workflows, pin wadackel/ravelact to a full commit SHA and set version to the intended ravelact release so both the action code and installed binary are fixed.
When the action is referenced from a branch or commit that is not a v* tag, pass version explicitly. Use a release tag to install a specific released binary, or latest to opt in to the latest GitHub Release:
- uses: wadackel/[email protected]
with:
version: v0.0.5
With Nix
Nix users can install or run ravelact directly from the project flake. These commands require Nix with flakes enabled.
Persistent install:
nix profile install github:wadackel/ravelact
ravelact --version
One-off run:
nix run github:wadackel/ravelact -- --version
To pin a release, replace vX.Y.Z with an available ravelact release tag:
nix profile install 'github:wadackel/ravelact?ref=vX.Y.Z'
From a local checkout:
nix profile install .#ravelact
nix run .#ravelact -- --version
From Source
Install from the GitHub repository directly when you want the latest source build:
cargo install --git https://github.com/wadackel/ravelact --locked
For a full development setup (Nix dev shell, just recipes, snapshot tests), see docs/development.md.
AgentSkill for AI Agents
ravelact ships an AgentSkill under skills/ravelact/ that teaches AI coding agents (GitHub Copilot, Claude Code, Cursor, Codex, Gemini CLI, and many more) which command fits which question and how to interpret each output format β so you can ask "who calls this reusable workflow?" or "what runs on push?" and the agent picks the right ravelact invocation automatically.
Install the skill (recommended)
Requires GitHub CLI v2.90.0+ (the gh skill command, GA on 2026-04-16).
# User-scope install for a specific agent (example: Claude Code)
gh skill install wadackel/ravelact ravelact --agent claude-code --scope user
# Pin to a specific ravelact release
gh skill install wadackel/ravelact [email protected] --agent claude-code --scope user
Run gh skill install --help for the full list of supported --agent values.
Install from a local checkout (for skill development)
When iterating on the skill itself, install from the local working tree instead of the published repo:
gh skill install ./skills/ravelact ravelact --from-local --agent claude-code --scope user --force
How drift is prevented
Each PR runs script/check-skill-drift.sh in CI, which asserts that every ravelact subcommand printed by ravelact --help is also referenced in skills/ravelact/SKILL.md. The same job runs gh skill publish --dry-run to validate the skill against the agentskills.io specification.
Quick Start
# Forward walk: which workflows fire on `push`?
ravelact trace push
# Trigger discovery: which events exist here?
ravelact triggers
# Reverse lookup: who calls this reusable workflow?
ravelact callers .github/workflows/_build.yaml
# PR-diff impact: which entry-points are affected by these changes?
git diff --name-only origin/main... | ravelact impact
# Estate-wide hygiene: declared but unused?
ravelact orphans
# Render the call graph as Mermaid
ravelact graph > graph.mmd
Example trace push tree output (illustrative β your repo's paths will differ):
trace push 1 entry workflow
ββ push (filters=none)
β°ββ .github/workflows/caller.yaml [wf]
β°ββ .github/workflows/leaf.yaml [wf]
β°ββ acme/shared/.github/workflows/deploy.yml [ext-wf] @v1
The same call graph rendered as Mermaid:
%% generated by ravelact
graph LR
subgraph push
wf_0__push[".github/workflows/caller.yaml<br/>Caller"]
end
wf_1[".github/workflows/leaf.yaml<br/>Leaf"]
extwf_0["acme/shared/.github/workflows/deploy.yml@v1"]
wf_0__push --> wf_1
wf_1 --> extwf_0
Usage
ravelact ships a flat command surface β every command sits at the top level. ravelact --help groups them visually.
Cheat sheet
| Command | Group | Purpose | Exit code |
|---|---|---|---|
trace <event> |
Inspect | Forward walk from a trigger event | 0 |
triggers |
Inspect | Summary of trigger events declared by workflows | 0 |
callers <ref>... |
Inspect | Reverse lookup: who calls these workflows / actions | 0 |
impact <files>... |
Inspect | Entry-points transitively affected by changed files | 0 |
orphans |
Inspect | Declared-but-unused workflows / actions / inputs / outputs | 0 |
permissions |
Check | Effective permissions: audit across callerβcallee chains |
0/1 |
secrets |
Check | secrets: propagation audit across reusable-workflow chains |
0/1 |
wiring |
Check | Verify dependency edges resolve and dispatches are declared | 0/1 |
extract |
Suggest | Composite-action extraction candidates from duplicated steps | 0 |
dedup |
Suggest | Near-duplicate workflow clusters | 0 |
dump |
Export | Print the IR as JSON | 0 |
graph |
Export | Render the call graph as Mermaid graph LR |
0 |
build |
Other | Build IR and persist the cache | 0 |
completion <shell> |
Other | Print shell-completion setup snippet (bash / zsh / fish) | 0 |
Global flags
The following flags are global and apply to every subcommand:
| Flag | Purpose |
|---|---|
--root <path> |
Repository root to scan (default: current working directory). |
--no-cache |
Bypass the IR cache (under ${XDG_STATE_HOME}/ravelact/) and force a full rebuild. |
--exclude <glob> |
Exclude local-action manifests whose workspace-relative path matches the glob. Repeatable. Useful for skipping tests/fixtures/**-style intentional test data when dogfooding ravelact on its own repo, or for narrowing analysis to a sub-tree of a monorepo. Workflow files under .github/workflows/ are not affected. |
--color auto|always|never |
Control ANSI colour. auto colourises only when stdout is a TTY and NO_COLOR is unset; always forces colour unless NO_COLOR is set; never disables it. JSON output never includes ANSI escape codes. |
Output format matrix
The --format flag is opt-in per command. Most report commands accept text|json|markdown; trace uses command-specific formats (tree|table|json|markdown); graph emits Mermaid as text|markdown. text, tree, and table render human-readable output; json emits machine-readable output suitable for jq; markdown emits a self-contained ### <Section> heading plus a Markdown table or Mermaid block β ready to drop into a PR comment or GitHub Job Summary.
| Command | Formats | Notes |
|---|---|---|
trace |
tree, table, json, markdown |
Default tree; --ascii affects tree only. |
triggers |
text, json, markdown |
|
callers |
text, json, markdown |
|
impact |
text, json, markdown |
|
orphans |
text, json, markdown |
|
permissions |
text, json, markdown |
Exits 1 when findings exist. |
secrets |
text, json, markdown |
Exits 1 when findings exist. |
wiring |
text, json, markdown |
Exits 1 when findings exist. |
extract |
text, json, markdown |
|
dedup |
text, json, markdown |
|
graph |
text, markdown |
Use dump for IR JSON. |
dump |
fixed JSON | No --format flag. |
build |
fixed text | No --format flag. |
completion |
shell snippet | No --format flag. |
Human text output uses compact status lines such as impact no impacted targets and dedup no near-duplicate clusters (threshold=0.80) for empty states β when present, the parens-wrapped trailing segment is the run summary (counts, descriptors). Markdown-capable reports keep the longer "No β¦ found" prose for PR comments and GitHub Job Summaries.
Inspect
Inspect commands are non-blocking reports and exit 0.
trace <event>
Forward walk from a trigger (push, pull_request, workflow_dispatch, schedule, workflow_run, β¦). --format tree|table|json|markdown chooses the output; --ascii falls back to ASCII border characters for terminals that mangle Unicode in tree output.
When you do not know which trigger event to inspect, run ravelact triggers first to list the events declared in the repository.
ravelact trace push
ravelact trace push --format table
ravelact trace pull_request --type opened --branch main
Example tree output:
trace push 1 entry workflow
ββ push (filters=none)
β°ββ .github/workflows/caller.yaml [wf]
β°ββ .github/workflows/leaf.yaml [wf]
β°ββ acme/shared/.github/workflows/deploy.yml [ext-wf] @v1
The trigger event becomes the synthetic root, with every entry workflow hanging off it as a child. Filter metadata (e.g. filters=none, branches=[main], types=[opened]) appears as a parens-wrapped summary next to the event name, so the status header carries only the count. Table view (below) keeps filter metadata in the status header β see notes for why. When a step has an if: guard, the condition appears as a β°β if: <expr> synthetic child line under that step (multi-line block-scalar if: | is split per source line and aligned under the if: text column, so long expressions never break the connector grid).
For events that have an activity-type concept (issues, pull_request, release, workflow_run, β¦), each entry workflow also gets a types: β¦ sub-line that surfaces the matched trigger declaration:
trace issues 3 entry workflows
ββ issues (filters=none)
βββ .github/workflows/labeler.yaml [wf]
β β°β types: labeled, opened
βββ .github/workflows/any.yaml [wf]
β β°β types: any
β°ββ .github/workflows/closed.yaml [wf]
β°β types: closed
The sub-line follows the same β°β / ββ connector grammar as if: guards. There are four cases:
- Explicit list (
types: [labeled, opened]in YAML) βtypes: labeled, opened - All activity types β event has
types:support but the workflow omits the key (e.g.issues:with notypes:) βtypes: any - GitHub default subset β
pull_request/pull_request_targetwithtypes:omitted βtypes: opened, synchronize, reopened (default) - No activity-type concept β
push,schedule,workflow_dispatch, etc. β no sub-line at all
Example table output:
trace push 1 entry workflow (filters=none)
dep kind edge target note
0 wf entry .github/workflows/caller.yaml entry
1 wf uses .github/workflows/leaf.yaml reusable
2 ext-wf uses acme/shared/.github/workflows/deploy.yml@v1 -
In --format table, the same trigger metadata is embedded in the entry row's note column β for example entry, types: labeled, opened for an issues: workflow with explicit types β so grep workflows that scan the table stay self-contained.
Notes & rendering details
- Default format:
--format treerenders a Unicode rounded box-drawing tree with the trigger event as a synthetic root (ββ <event> (<summary>)) and every entry workflow hanging beneath it viaβββ/β°ββ(branch connectors) andβ(vertical guides), plus a colored[kind]tag column on the right ([wf]cyan,[ext-wf]magenta,[ac]cyan,[ext-ac]magenta,[docker]yellow,[ann]cyan,[cyc]red). Top-level entry workflows are separated by a singleβspacer line for breathing room; deeper rows flow tightly to keep related items visually grouped. Step-levelif:conditions render as aβ°β if: <expr>synthetic child line under the guarded step, so multi-line block-scalar guards stay readable without breaking the grid. Pass--asciito fall back to+-/|->/\->/|. --format table: emits a lightweight 5-column table (dep / kind / edge / target / note) suitable for grep, CI logs, and audit diffs. KIND values:wf(workflow),ac(local action β composite / JavaScript / Docker),ext-ac(external action),ext-wf(external reusable workflow),docker(Docker action ref βuses: docker://...),ann(annotation edge),cyc(cycle guard). Annotation edges (# ravelact:dispatches/triggers) appear asannrows whoseedgecolumn carries the verb; the resolved target workflow then appears on the next row as a separatewfentry.--format json: emits structured trace entries for machine consumers.--format markdown: emits a### Traceheading plus the same trace rows as a Markdown table.- Empty result: when no entry-point workflow matches the requested
<event>, bothtreeandtableprint the same status header (e.g.trace schedule no entry-point matches (filters=none), with atypes=[...]summary entry when--typeis set). --type <name>: filters entry-points whosetypes:declaration includes the listed activity (OR semantics across repeats). When omitted,pull_request/pull_request_targetworkflows that omittypes:still match the GitHub default subset (opened/synchronize/reopened).repository_dispatch.types(customevent_typevalues) is matched the same way.- Ref / path filters:
--branch <name>,--tag <name>, and--path <pattern>(each repeatable) further narrow the result by the trigger'sbranches:/branches-ignore:/tags:/tags-ignore:/paths:/paths-ignore:filter fields. A trigger that omits a filter is treated as "fires for all values" (matches GitHub Actions behaviour). Repeating a flag is OR within that filter; combining different flags is AND across filter types. Each--path Xis interpreted as the single-file changeset ofXβ so a workflow withpaths-ignore: [docs/**]rejects--path docs/x.mdbut accepts--path src/foo.rs. Pattern syntax follows the globset crate's glob subset;*,**,[abc], and the leading!negation behave per the GitHub Filter pattern cheat sheet, while?is treated as exactly one character (vs. GHA's zero-or-one) and the GHA-only+quantifier is unsupported. Patterns globset cannot compile print a warning to stderr and are treated as non-matching.
triggers
Summarize trigger events declared across .github/workflows/*.{yml,yaml}. This is a discovery command for large workflow estates where you may not know whether to start with trace push, trace pull_request, trace schedule, or another event. Use --format json for automation or --format markdown for PR comments.
ravelact triggers
Example output:
triggers 6 trigger events (35 trigger declarations)
event entry workflows declarations typed filtered examples
push 12 12 0 7 .github/workflows/ci.yml, .github/workflows/lint.yml, .github/workflows/release.yml
pull_request 8 8 3 5 .github/workflows/ci.yml, .github/workflows/test.yml
workflow_call 0 6 0 0 .github/workflows/_build.yml, .github/workflows/_publish.yml
Rows are sorted by descending entry workflow count, then event name. workflow_call appears because it is important estate context, but it is not an entry-point trigger, so its entry workflows count is 0. typed counts explicit types: declarations, filtered counts branch/tag/path filters, and examples is capped at three stable-sorted workflow paths.
callers <ref>...
Reverse lookup: who calls these workflows / actions. Accepts one or more positional targets, or paths piped via stdin (one per line); - mixes stdin with positional args.
ravelact callers .github/workflows/leaf.yaml
printf '%s\n' .github/workflows/leaf.yaml | ravelact callers
Example output:
callers 1 caller for .github/workflows/leaf.yaml
CALLERS
kind location detail
job-call .github/workflows/caller.yaml call::_jobcall
impact <files>...
Given a list of changed files (typically a PR diff), list the entry-point workflows transitively affected and the local actions that consume the changed local actions. Inputs may be workflow YAML, action.yaml, or any path under a local action directory; unknown paths are warned to stderr and skipped. The input nodes themselves are excluded from the output β only downstream consumers are listed. Inputs may also be piped via stdin (one path per line); - mixes stdin with positional args. Each affected entry is rendered with the row label workflow for workflows and local-action-<kind> (composite / javascript / docker) for local actions.
git diff --name-only origin/main... | ravelact impact
ravelact impact .github/workflows/_reusable.yaml --format json
Example text output:
impact 2 impacted targets (2 workflows)
WORKFLOWS
Β· .github/workflows/entry-a.yaml
Β· .github/workflows/entry-b.yaml
orphans
Declared-but-unused report. Four kinds in one pass: reusable workflows / local actions (composite / JavaScript / Docker) that nothing references; declared inputs that the callee body never references; declared outputs that no caller consumes via needs.<job>.outputs.<X> or steps.<id>.outputs.<X>. Local-action rows are tagged with the kind (local-action-composite / local-action-javascript / local-action-docker) in text output and as {"id": ..., "kind": "composite|javascript|docker"} objects under the actions key in JSON. Always exits 0.
ravelact orphans
ravelact orphans --format markdown
Example output:
orphans 1 unused declaration (1 actions)
ACTIONS
kind target
javascript .
Notes & limitations
- Scope: only local callees (
uses: ./.github/workflows/X.yamlanduses: ./action-dir). External (owner/repo[/sub]@ref) and cross-repoworkflow_callare not analysed for declared-but-unused detection. - Exit code: always
0(informational).orphansis hygiene reporting; CI gating is intentionally not coupled to its findings. - JS / Docker actions:
runs.using: node*andruns.using: dockerskip the input-reference scan because theirinputs:are consumed outside YAML (by JS / Docker code). Onlyruns.using: compositeactions participate. workflow_dispatch-only workflows: excluded from declared-input / declared-output detection. Only workflows that expose aworkflow_callsignature are scanned.- Expression scanning: only references inside
${{ ... }}blocks count. Dynamic accesses such as${{ inputs[var] }}cause theunreferenced_inputsdetection to bail out conservatively (no false positives β but no warning if the dynamic key is itself a typo). - Step-level
env:is scanned, but job-levelenv:and workflow-level top-levelenv:are not. References that appear only at job- or workflow-levelenv:can produce false-positiveunreferenced_inputsentries. - Caller-side
with:validation (missing required inputs, unknown keys, type mismatches, undeclared input references): not in scope.actionlintv1.7+ covers those rules cross-file; run it alongsideravelact.
Check
Check commands exit 0 when no findings are present and exit 1 when findings exist.
Warning
permissions, secrets, and wiring exit non-zero on findings. Wire them as required PR checks deliberately β they will block merges.
permissions
Compute the effective permissions: scope across callerβcallee chains using GitHub Actions' "monotonic decrease" rule, and surface unexpected escalations and overly broad scopes. Findings are sorted by severity (high β medium):
- OverlyBroadCoarse (high) β an entry-point workflow declares
permissions: write-allat the workflow or any job level. - CalleeEscalatesCaller (high) β a callee reusable workflow's job declares broader permissions than the caller-job's effective scope for at least one scope (a semantic violation; GitHub caps it at runtime).
- ImplicitRepoDefault (medium) β an entry-point workflow declares no
permissions:at the workflow level and one or more jobs also have nopermissions:, so the run resolves to the repository defaultGITHUB_TOKENconfiguration (which on legacy repos is permissive).
ravelact permissions
ravelact permissions --format json
Example finding output (exits 1 when findings are present):
permissions 1 finding (1 medium)
! medium implicit-repo-default
.github/workflows/ci.yaml
entry-point workflow `.github/workflows/ci.yaml` declares no `permissions:` and jobs [test] inherit the repository default
Notes & semantics
- Scope: only local callees (
uses: ./.github/workflows/X.yaml). External (owner/repo/.github/workflows/X.yml@ref) and cross-repoworkflow_callcallees are opaque and skipped. - Exit code:
0when no findings,1when one or more findings are reported. - Entry-point only for OverlyBroadCoarse and ImplicitRepoDefault: only fire on workflows whose
on:lists at least one entry-point trigger (push/pull_request/schedule/workflow_dispatch/workflow_run/ etc.). Reusable-only workflows (workflow_callonly) inherit their cap from the caller; escalations are caught byCalleeEscalatesCaller. permissions: {}is explicit, not implicit: an empty mapping is a deliberate hardening (every scope set tonone) and does NOT triggerImplicitRepoDefault. Only the absence of thepermissions:key entirely counts as implicit.- Effective scope semantics: a job's effective scope is
job.permissions.unwrap_or(workflow.permissions)(job-level fully overrides workflow-level per the GitHub Actions specification β the two are not intersected). Comparison happens per-scope using the orderingnone < read < write. - Coarse forms: only
read-allandwrite-allare recognised as fan-outs over the known scope set. Other coarse strings yield no claims (the analyser stays conservative). metadata: implicit-read in GitHub Actions and not a declarable scope. It is not part of the comparison set.
secrets
Trace secrets: propagation across entry-point β reusable workflow chains using GitHub Actions' "each layer must explicitly opt in" rule (secrets: inherit is not transitive β every hop must restate it or pass an explicit map). Findings are sorted by severity (high β medium):
- MissingSecretPropagation (high) β a directly-called callee declares a
required: truesecret, but the caller'ssecrets:does not pass it (depth = 1). - SecretsInheritChainBreak (high) β in a depth β₯ 2 chain, the leaf callee requires a secret but somewhere along the path a hop dropped it (a missing key in an explicit map,
secrets: {}, or a layer that omittedsecrets:and passed nothing forward). - EnvironmentInWorkflowCallCallee (medium) β a reusable callee reached from an entry-point chain has a job-level
environment:β per the GHA spec the job's environment-scoped secrets shadow any caller-passed secret of the same name, so the caller's intent is silently dropped at runtime.
ravelact secrets
ravelact secrets --format json
Example clean output:
secrets no findings
Notes & semantics
- Scope: only local callees (
uses: ./.github/workflows/X.yaml). External (cross-repo) callees are opaque and skipped. - Exit code:
0when no findings,1when one or more findings are reported. - Reachability: all three findings fire only on chains that DFS reaches from at least one entry-point trigger. Isolated reusable workflows that nothing references are not analysed for
EnvironmentInWorkflowCallCallee. - Path-local reachable set: each DFS path tracks the set of secret names that have actually reached the current hop. Entry-points start with the symbolic "all names" set; each layer's
secrets:declaration narrows it.inheritonly forwards what the current hop actually holds β soA -(explicit{Y})-> B -(inherit)-> Ccarries only{Y}toC, not all ofA's secrets. SecretsPasssemantics:secrets: inheritβ forward the current reachable set unchanged.secrets: { K1: ..., K2: ... }β restrict the reachable set to the keys; values are not evaluated, only key presence.secrets: {}β drops everything (explicit empty map β reachable becomesβ).- No
secrets:key β drops everything (SecretsPass::None).
- Mutually exclusive (a) vs (b): for a given secret on a given path, at most one of
MissingSecretPropagation/SecretsInheritChainBreakis emitted. Depth = 1 yields (a); depth β₯ 2 yields (b) regardless of where in the chain the drop occurred. - Documented limitations:
- Expression-driven
secrets:values (e.g.secrets: ${{ matrix.foo }}) parse asSecretsPass::Noneand therefore appear asMissingSecretPropagation. Known false positive β runtime expression evaluation is out of scope, mirroring howorphansbails on dynamic${{ inputs[var] }}accesses. ${{ secrets.X }}reference scanning inside callee bodies is not performed; onlyon.workflow_call.secrets[required=true]declarations participate in (a) and (b).on.workflow_call.environment:(syntactically invalid per the GHA spec) is the domain ofactionlint.secretsonly flags the related runtime hazard β job-levelenvironment:in a reusable callee, which silently shadows caller-passed secrets of the same name.
- Expression-driven
wiring
Verify that declared dependency edges resolve and that observable dispatches are declared. Reports four kinds of finding:
- Unannotated
gh workflow run <X>invocations insiderun:blocks β observable dispatches that the IR cannot connect to a target without a# ravelact:dispatchesannotation. - Dangling
# ravelact:annotations β see Annotations β whose target path does not resolve. - Unresolvable
on.workflow_run.workflowsentries β the named upstream workflow is not present in the local IR. - Dangling local
uses: ./<path>references β step-level local actions or job-level local reusable workflows whose target is not present in the IR. Catches typos and stale paths.
Caller-side with: validation (missing required inputs, unknown keys, type mismatches, undeclared input references) is not part of wiring; actionlint v1.7+ covers those rules cross-file. ravelact complements actionlint with estate-wide declared-but-unused detection (orphans), permissions / secrets propagation (permissions / secrets), and graph queries.
ravelact wiring
ravelact wiring --format json
Example finding output (exits 1 when findings are present):
wiring 1 finding dangling-local-uses=1
! dangling-local-uses
location: .github/workflows/ci.yaml:10
`uses: ./.github/actions/typo` references a local action that is not present in the IR
Suggest
Suggest commands report refactor candidates, never mutate files, and exit 0.
extract [--min-length N] [--min-occurrences N]
Detect duplicated step sequences across local workflows / composite actions and emit ranked composite-action extraction candidates with a sketch action.yml per candidate. Defaults: --min-length=3, --min-occurrences=2. Non-mutating β never rewrites files; the sketch is starting material for a manual extraction.
ravelact extract
ravelact extract --min-length 4 --min-occurrences 3 --format json
Example output (excerpt):
extract 1 extraction candidate min-length=3 min-occurrences=2
-- Candidate 1
score length occurrences
8 4 3
-- Occurrences
container steps
.github/workflows/a.yaml:build 0..4
.github/workflows/b.yaml:build 0..4
.github/workflows/c.yaml:build 0..4
-- Sketch action.yml
name: extracted-bootstrap
description: Extracted from duplicated step sequences. Review and parametrize before using.
runs:
using: composite
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
# TODO: parametrize via inputs:
- shell: bash
run: |
npm ci
- uses: actions/cache@v4
# TODO: parametrize via inputs:
The sketch mirrors the duplicated steps it found; review it, parameterize it, and pin action refs to full commit SHAs before adopting.
Notes & ranking
- Normalization (MVP): each step is reduced to a signature β
uses:target (canonical form) wins overrun:first non-empty line. Shell preambles (set -e,set -eu,set -euo pipefail) and shebangs are skipped before picking the line.with:,env:,if:,name:are intentionally ignored, so two callsites of the sameuses:target with differentwith:values still match. - Sketch
action.yml: emitted as a starting template, not a finished composite.with:is dropped and replaced with a# TODO: parametrize via inputs:comment peruses:step that originally carried awith:.run:bodies are copied verbatim from the first occurrence. - Citation format:
<container>:<job>:<start>..<end>for workflow steps and<action_id>:_composite:<start>..<end>for composite-action steps. Step indices are used in place of source line numbers (the IR currently does not carry line information). - Maximal candidates: when a length-N candidate fully covers a strictly shorter candidate at every occurrence, the shorter candidate is suppressed. A shorter candidate with at least one occurrence outside any longer candidate is emitted alongside.
- Ranking:
score = length * (occurrences - 1)(= the number of duplicated steps the extraction would remove), descending. Ties break by length, occurrences, and citation order.
dedup [--threshold 0.8]
Cluster near-duplicate workflows. A weighted Jaccard score over (trigger event names, job ids, step uses: references, whitespace-tokenized step run: bodies) is computed for every pair of local workflows; pairs scoring at or above the threshold are linked, and clusters are formed via single-linkage union-find. Each cluster is rendered with a representative workflow, member list (with similarity to the representative), shared and divergent uses:, and whether trigger sets differ. Non-mutating β reports clusters only; nothing is rewritten or merged.
ravelact dedup
ravelact dedup --threshold 0.9 --format json
Example output (excerpt):
dedup 2 workflow clusters (threshold=0.80)
CLUSTER 1
metric value
workflows 2
representative .github/workflows/ci.yaml
triggers differ yes
MEMBERS
member similarity
.github/workflows/release.yaml 0.83
USES
label value
common (none)
divergent (none)
Notes & clustering details
- Non-mutating: reports clusters only β
dedupnever rewrites, merges, or deletes any workflow. CI gating uses--format json. - Scope: local workflows only. Local actions (composite / JavaScript / Docker) and cross-repo
workflow_callcallees are not dedup targets in this MVP. - Default threshold:
0.8(inclusive β pairs with similarity equal to the threshold are linked). - Similarity formula:
0.15 * J(triggers) + 0.10 * J(job ids) + 0.40 * J(step uses) + 0.35 * J(run tokens)whereJis Jaccard over the feature set.usesandrun-tokens dominate so identifier-only variations across otherwise-identical workflows still cluster, while trigger and job-id differences act as tie-breakers. - Run-token similarity: each step's
run:body is concatenated per workflow and split on ASCII whitespace into aBTreeSet. Identifier differences (URLs, repo names, file paths) typically account for a small fraction of total tokens, so near-duplicates that only differ in identifiers stay above threshold. - External
uses:normalisation: the version pin (@ref) is stripped, soactions/checkout@v4andactions/checkout@v6are treated as the same use. Intentional β version diversity is noise for near-duplicate detection. - Single-linkage clustering: the cluster header reports the configured threshold, not a guaranteed intra-cluster minimum. Transitive members (AβB and BβC above threshold but AβC below) end up in the same cluster.
- Representative selection: the lexicographically smallest workflow path within the cluster. Members are listed in lexicographic order; the representative is not repeated in the member list.
triggers_differ:trueiff at least one cluster member has a different trigger-event set than the others. A single boolean, not a member-aligned matrix.- Exit code: always
0. CI gating can be added with--format jsonandjq(e.g.ravelact dedup --format json | jq -e 'length == 0').
Export
Export commands emit output artifacts for downstream tools or renderers.
dump
Print the IR as JSON.
ravelact dump
Example output (excerpt):
{
"workflows": [
{
"id": ".github/workflows/caller.yaml",
"name": "Caller"
}
],
"actions": []
}
graph [--event <event>] [--format text|markdown]
Render the call graph as a Mermaid graph LR. Entry workflows are grouped into subgraph blocks per trigger event (multi-trigger workflows appear as one alias per trigger); reusable workflows / local actions / external actions are shared nodes; edges follow uses / workflow_call / workflow_run. --event <event> filters to entry workflows for a single trigger and is recommended for large estates where the unfiltered graph is too dense to read.
ravelact graph --event push
ravelact graph --event push --format markdown
Example Mermaid output:
%% generated by ravelact
graph LR
subgraph push
wf_0__push[".github/workflows/caller.yaml<br/>Caller"]
end
wf_1[".github/workflows/leaf.yaml<br/>Leaf"]
extwf_0["acme/shared/.github/workflows/deploy.yml@v1"]
wf_0__push --> wf_1
wf_1 --> extwf_0
Paste the resulting Mermaid into a renderer (mermaid.live, GitHub Flavored Markdown ```mermaid block, etc.) to visualise the call graph.
--format text (default) emits raw Mermaid suitable for > graph.mmd or for piping into a renderer. --format markdown wraps the same Mermaid in a ### Graph heading + fenced ```mermaid block, ready to drop into a PR comment or GitHub Job Summary (GitHub renders ```mermaid blocks natively). Use dump for IR JSON. When --event <event> matches no entry-points, the Markdown output still emits the fenced block; the body is a %% (no entry-point matches event <event>) diagnostic comment.
Build & cache
build
Discover workflows + local actions (composite / JavaScript / Docker), then populate or reuse the IR cache at ${XDG_STATE_HOME}/ravelact/repo-<sha8>/cache.json (or $HOME/.local/state/ravelact/... when XDG_STATE_HOME is unset). --no-cache forces a rebuild. Prints a status header plus a summary table; the --format flag does not apply.
ravelact build
ravelact build --no-cache
Example output:
build workflow estate index built
-- Summary
metric value
workflows 12
local actions 3
external actions 5
diagnostics 0
cache ~/.local/state/ravelact/repo-<sha8>/cache.json
All other IR-consuming commands build the IR implicitly on first run via the same code path and reuse the cache thereafter, so build is not a prerequisite β it exists as an explicit entry point for pre-warming the cache (e.g. in CI) and printing the cache summary.
Cache location
ravelact keeps its IR cache under the user state directory, never inside your repository:
| Condition | Cache path |
|---|---|
XDG_STATE_HOME set and non-empty |
${XDG_STATE_HOME}/ravelact/repo-<sha8>/cache.json |
XDG_STATE_HOME unset |
$HOME/.local/state/ravelact/repo-<sha8>/cache.json |
<sha8> is the first 8 hex chars of an FNV-1a hash of the absolute repo path, so separate checkouts never collide and local directory names do not leak into cache paths.
Important
At least one of XDG_STATE_HOME or HOME must be set. If both are unset (e.g. in a stripped-down container), every IR-consuming subcommand exits with an error pointing at this requirement; pass XDG_STATE_HOME=/tmp/ravelact-state (or any writable directory) to recover.
The cache is keyed by an internal SCHEMA_VERSION; schema-changing releases rebuild it automatically. Adopters do not need any .gitignore entry for ravelact β the cache lives outside the repository.
Shell completion
ravelact ships dynamic shell completion for bash, zsh, and fish via clap_complete's unstable-dynamic mode. Completion includes subcommand names, flags, ValueEnum choices (--format text|json|markdown for reports, --format tree|table|json|markdown for trace, --format text|markdown for graph), known trigger event names (push, pull_request, workflow_dispatch, β¦), and workflow / local-action paths under .github/workflows/ and .github/actions/ (rooted at --root if given, otherwise the current directory).
Note
Empty-prefix completion intentionally omits flag candidates β type a leading - to surface them.
The ravelact completion <shell> subcommand prints a setup snippet you can source from your shell rc:
| Shell | rc file | Snippet |
|---|---|---|
| bash | ~/.bashrc |
source <(COMPLETE=bash ravelact) |
| zsh | ~/.zshrc |
source <(COMPLETE=zsh ravelact) |
| fish | ~/.config/fish/config.fish |
COMPLETE=fish ravelact | source |
GitHub CLI extension support is not part of the current distribution path. A future extension would need a separate gh-ravelact wrapper because gh extensions must use gh-* repository and executable names.
Exit codes
| Group | Commands | Exit code |
|---|---|---|
| Inspect / Suggest / Export | trace, triggers, callers, impact, orphans, extract, dedup, dump, graph |
always 0 (informational) |
| Build & cache | build, completion |
always 0 |
| Check | permissions, secrets, wiring |
0 when clean, 1 when any finding is reported |
Annotations
YAML cannot express every dependency between workflows: a run: block can call gh workflow run X, a workflow_run trigger may chain off another workflow's completion, or an action's code can dispatch a sibling workflow. To make those implicit dependencies queryable, ravelact recognises structured comments of the form
# ravelact:dispatches .github/workflows/<target>.yaml
# ravelact:triggers .github/workflows/<target>.yaml
Place the comment on its own line, directly above the YAML node it describes:
- A comment immediately above a step attaches to that step.
- A comment between two top-level keys (e.g. between
on:andjobs:) attaches to the next job. - A comment at the very top of the file attaches to the workflow itself.
Annotated edges show up in trace, callers, impact, and the graph output (rendered as -. dispatches .-> / -. triggers .-> dotted edges).
What dispatches and triggers mean
dispatchesβ "this step / job / workflow imperatively starts the target workflow" (usually viagh workflow runor an equivalent dispatch API).triggersβ "the target workflow's completion fires this workflow" β the inverse direction ofworkflow_run.
Scope and limitations
<ref>must be a local workflow path under.github/workflows/. Paths with.., absolute paths, and external (owner/repo/...@ref) targets are rejected and surfaced asdanglingannotations bywiring.- Comments that fall inside a YAML block scalar (
run: |,run: >) are ignored β they are part of the shell command, not the YAML structure. action.yamlfiles do parse# ravelact:comments. Resolved local-action annotations are stored in the IR onLocalAction.annotations:callerscan report those annotation anchors directly,trace/impact/graphconsume reachable annotations when workflows call that local action, andwiringreports dangling local-action annotations.wiringonly matches literal targets ingh workflow run X. Variable expansion (gh workflow run "$VAR"), shell line continuation (\), and command substitution ($(...)) are out of scope and remain known false negatives.
CI Integration
ravelact works well as two kinds of CI step:
- Gates β
permissions,secrets, andwiringexit1when findings are reported, so they can fail the workflow directly. - Reports β
impact,trace,orphans,dedup, andgraphare useful for PR summaries, audit logs, optional policy gates, or artifacts. Use--format jsonwhen ajqpolicy decides whether the report should fail CI.
The examples below install ravelact with the repository setup action, then run the CLI from PATH. Replace vX.Y.Z with the first ravelact release that includes action.yaml, or any newer release. For security-sensitive workflows, pin action refs to full commit SHAs and set version to the intended ravelact release. To test unreleased changes in this repository, build from source first and replace ravelact with ./target/debug/ravelact.
Tip
Pipe git diff --name-only origin/main... into ravelact impact to surface affected entry-point workflows in PR summaries β this is the highest-signal report for the smallest configuration cost.
PR impact summary
Drop the following into .github/workflows/ravelact-impact.yaml to show which entry-point workflows are affected by the files changed in each PR.
name: PR Impact
on:
pull_request:
permissions:
contents: read
jobs:
impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Pin to a SHA in production
with:
fetch-depth: 0
- uses: wadackel/[email protected] # Pin to a SHA in production
- name: Report impacted workflows
run: |
git diff --name-only origin/${{ github.base_ref }}...HEAD \
| ravelact impact --format json \
| tee ravelact-impact.json
{
echo "## ravelact impact"
echo
echo '```json'
cat ravelact-impact.json
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
Required checks
permissions, secrets, and wiring are intended for direct CI gating. They print human-readable findings by default and exit non-zero when the report is not clean.
name: GitHub Actions Static Checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
ravelact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Pin to a SHA in production
- uses: wadackel/[email protected] # Pin to a SHA in production
- run: ravelact build --root .
- run: ravelact permissions --root .
- run: ravelact secrets --root .
- run: ravelact wiring --root .
actionlint still belongs in the same CI suite. It validates GitHub Actions syntax and caller-side with: usage; ravelact adds estate-wide graph, permissions, secrets, and wiring checks.
Optional hygiene gates
Informational commands exit 0 by design. Add a jq policy when you want to promote a report into a gate:
- name: Fail on near-duplicate workflows
run: ravelact dedup --root . --format json | jq -e 'length == 0'
- name: Fail on declared-but-unused workflow API
run: |
ravelact orphans --root . --format json \
| jq -e '
(.workflows | length) == 0 and
(.actions | length) == 0 and
(.unreferenced_inputs | length) == 0 and
(.unused_outputs | length) == 0
'
Use these gates selectively. orphans is strict about every discovered local workflow and local action under the scanned root, so fixture-heavy repositories may prefer to keep it as a scheduled report instead of a PR blocker β or use --exclude 'tests/fixtures/**' to scope the analysis.
Graph artifact
For larger estates, keep a rendered graph source as a build artifact:
- name: Export push graph
run: ravelact graph --root . --event push > ravelact-push.mmd
- uses: actions/upload-artifact@v4 # Pin to a SHA in production
with:
name: ravelact-graph
path: ravelact-push.mmd
Documentation
docs/development.mdβ build, test, lint, snapshot review, IR cache reference.CONTRIBUTING.mdβ contributor on-ramp.
License
Dependencies
~16β33MB
~417K SLoC