Usage¶
Install¶
Helm plugin (recommended)¶
Needs Helm 3.18+ or Helm 4. Works on macOS, Linux and Windows (amd64/arm64).
The install hook needs curl or wget and sha256sum or shasum (macOS and
Linux), or PowerShell (Windows).
1. Install, pinned to a release tag:
Helm clones the repository at that tag, then runs the install hook
(scripts/install-plugin.sh, or install-plugin.ps1 on Windows). The hook:
- reads
versionfromplugin.yaml; - downloads
forge-<os>-<arch>andchecksums.txtfrom that GitHub release; - checks the binary's sha256 and aborts the install on a mismatch;
- places it in
$HELM_PLUGIN_DIR/bin/and printsforge: installed ….
2. Verify:
helm plugin list # NAME VERSION DESCRIPTION → forge 2.1.0 …
helm forge --version
helm forge cache path # $HELM_CACHE_HOME/forge
3. Use helm forge wherever you used helm dependency:
helm forge dep update ./my-chart
helm forge dep build ./my-chart --timeout 2m
helm template my-release ./my-chart
Notes:
helm forge …takes the same commands and flags asforge ….- Helm 4 verifies plugin signatures by default and can't verify a git source,
so it needs
--verify=false. Helm 3 has no such flag. - Always pass
--version: without it Helm installs frommain, whoseplugin.yamlmay name a release that isn't published yet. Plugin installs are available from v2.0.0. - Helm passes its settings to the plugin:
--registry-config/HELM_REGISTRY_CONFIGand--repository-config/HELM_REPOSITORY_CONFIGwork as with Helm itself, sohelm registry loginandhelm repo addcredentials and@namerepositories just work. The cache defaults to$HELM_CACHE_HOME/forge(see Environment). - Set
HELM_FORGE_PLUGIN_URLto download from a mirror of the GitHub releases (it replaceshttps://github.com/mmpyro/forge/releases/download; the hook appends/v<version>/<asset>). Useful behind a proxy or air-gapped:
HELM_FORGE_PLUGIN_URL=https://mirror.example.com/forge \
helm plugin install https://github.com/mmpyro/forge --version v2.1.0
Upgrade or remove. The plugin is pinned to the tag it was installed from, so move to another version by reinstalling:
helm plugin uninstall forge
helm plugin install https://github.com/mmpyro/forge --version vX.Y.Z # + --verify=false on Helm 4
The cache in $HELM_CACHE_HOME/forge survives reinstalls; remove it with
helm forge cache clean before uninstalling if you want it gone.
Install troubleshooting:
| Symptom | Cause | Fix |
|---|---|---|
| Signature or verification error (Helm 4) | Helm 4 verifies signatures by default | Add --verify=false |
forge: download failed: …/v<version>/forge-… |
Release not published, or no network access to GitHub | Pass --version of a published release; or set HELM_FORGE_PLUGIN_URL to a mirror |
forge: checksum mismatch for … |
Corrupted download or a proxy rewriting it | Retry; check proxies. Nothing was installed |
forge: unsupported OS / architecture |
Not macOS/Linux/Windows on amd64/arm64 | Build from source (below) |
plugin already exists |
An earlier install is still there | helm plugin uninstall forge first |
| Hook not run (Helm < 3.18) | platformHooks needs Helm 3.18+ |
Upgrade Helm, or use the standalone binary |
Moving from Homebrew. The Homebrew tap is discontinued. Remove it and install the plugin:
Prebuilt binaries¶
Prebuilt binaries and checksums.txt are attached to every
GitHub release. The links
below point at v2.1.0
(checksums.txt).
| Platform | Asset | Download |
|---|---|---|
| macOS (Apple Silicon) | forge-darwin-arm64 |
Download |
| macOS (Intel) | forge-darwin-amd64 |
Download |
| Linux (x86_64) | forge-linux-amd64 |
Download |
| Linux (ARM64) | forge-linux-arm64 |
Download |
| Windows (x86_64) | forge-windows-amd64.exe |
Download |
| Windows (ARM64) | forge-windows-arm64.exe |
Download |
Download the .exe, rename it to forge.exe and put it in a
directory on your PATH.
From source¶
Build from source (Go 1.25+):
Or without the Makefile:
Put bin/forge on your PATH. Helm itself is still needed for everything
other than fetching dependencies.
Commands¶
forge dep update [CHART]¶
Resolves the constraints in Chart.yaml, downloads the charts into
charts/ and writes Chart.lock.
CHARTdefaults to..- Exact versions (
1.2.0) are locked as written, without asking the registry — same as Helm. - Ranges (
^1.0.0,>=0.1.0 <1.0.0) pick the highest matching tag. Each repository's tag list is fetched once, even if several dependencies use it. Chart.lockis rewritten only when its digest changes, so re-runningupdatewith nothing new leaves the file (and itsgenerated:time) alone.
forge dep build [CHART]¶
Downloads exactly the versions pinned in Chart.lock.
- With no
Chart.lock, behaves likedep update(Helm does the same). - If
Chart.lockno longer matchesChart.yaml, fails with Helm's message: "the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies". Fix: runforge dep update.
forge dep tree [CHART]¶
Shows every dependency in Chart.yaml: its constraint, the version
Chart.lock pins, where it comes from, and whether dep build would serve it
from the cache. Reads Chart.yaml, Chart.lock, repositories.yaml and the
cache only — no network requests.
my-chart 1.0.0
├── redis (cache) ^18.0.0 -> 18.1.0 oci://ghcr.io/acme/charts 104 KiB cached
├── nginx >=1 <2 -> 1.27.3 https://charts.example.com - not cached
└── common ~0.3.0 -> 0.3.0 file://../common - local
States: cached (dep build makes no request for it), not cached
(dep build downloads it), local (file://, packaged on every run) and
no lock (no Chart.lock entry: run forge dep update). A Chart.lock that
is out of sync with Chart.yaml is reported below the tree; entries are then
matched by name and repository. Only the chart's own dependencies are shown,
not theirs.
forge dep why NAME [CHART]¶
Explains one dependency, by chart name or alias (every alias of a chart if you
give its name): the Chart.yaml entry (constraint, repository, condition,
tags), the locked version, the cached archive (digest, size, when it was
fetched and last used, path) and whether charts/ has it. No network
requests.
redis (cache)
Chart.yaml: ^18.0.0 from oci://ghcr.io/acme/charts
condition: cache.enabled
Chart.lock: 18.1.0
cache: cached, sha256:…, 104 KiB, fetched 2026-10-01T09:12:44Z, last used 3h ago
/home/me/.cache/helm-forge/blobs/sha256/…
charts/: redis-18.1.0.tgz (present)
An unknown name is a usage error (exit code 2) that lists the chart's
dependencies.
forge dep outdated [CHART]¶
Compares Chart.lock with what registries and chart repositories offer now:
one tags/list per OCI repository and one index.yaml per chart repository
(like dep update), nothing is downloaded and nothing is written.
NAME CONSTRAINT LOCKED WANTED LATEST STATE
redis (cache) ^18.0.0 18.1.0 18.4.2 19.0.1 update in constraint
nginx 1.27.3 1.27.3 1.27.3 1.28.0 newer outside constraint
common ~0.3.0 0.3.0 0.3.0 0.3.0 up to date
2 of 3 dependencies are not up to date
WANTEDis whatforge dep updatewould lock now;LATESTthe highest version available.- States:
up to date;update in constraint(dep updatewould pickWANTED);newer outside constraint(widen the constraint inChart.yamlto getLATEST);no match(nothing satisfies the constraint any more);no lock(noChart.lockentry). - Prereleases count as
LATESTonly with--include-prerelease, or when the constraint itself selects one.
| Flag | Default | Meaning |
|---|---|---|
--include-prerelease |
false |
Count prerelease versions as the latest |
--fail-if-outdated |
false |
Exit with code 1 if any dependency is not up to date (JSON: success: false, error code outdated) |
It also takes --concurrency, --per-host, --plain-http, --timeout,
--registry-config and --repository-config as dep update does. If a
repository can't be listed, the whole command fails with the same classified
error dep update would report.
JSON output of tree, why and outdated¶
With -o json all three print one document: schemaVersion, command,
chart (the argument), success, chartName, chartVersion, hasLock,
lockInSync and dependencies. Each dependency has name, alias,
constraint, repository, condition, tags, locked, state (cached,
not_cached, local, no_lock), digest, sizeBytes, fetchedAt,
lastUsed, blobPath, fileName and inCharts. dep why lists only the
matching dependencies. dep outdated adds durationMs,
summary (total, outdated), registries (as for dep build) and, per
dependency, wanted, latest and freshness (up_to_date,
update_in_constraint, newer_outside_constraint, no_match, no_lock).
To gate CI on it:
forge dep outdated ./my-chart -o json > outdated.json || true
jq -r '.dependencies[] | select(.freshness != "up_to_date") | "\(.name): \(.locked) → \(.latest) (\(.freshness))"' outdated.json
dependency is an alias of dep, so forge dependency build also works.
forge cache path¶
Prints the cache directory. Useful for CI cache steps.
forge cache clean¶
Deletes the cache directory.
forge cache ls¶
Lists every cached chart: name, version, size, when it was last used and where it came from. Makes no requests, and does not create the cache if it does not exist yet.
CHART VERSION SIZE LAST-USED SOURCE
dep-a 1.0.0 2.0 KiB 3h ago oci://ghcr.io/acme/charts
nginx 1.27.3 41 KiB 12d ago https://charts.example.com
2 charts, 43 KiB in /home/me/.cache/helm-forge
| Flag | Default | Meaning |
|---|---|---|
--sort |
name |
name, size (largest first) or age (least recently used first) |
-o, --output |
text |
text or json |
A chart whose archive is gone shows missing as its size. The total counts
an archive shared by several charts (e.g. the same chart in two registries)
once.
forge cache gc¶
Removes charts from the cache, least recently used first:
forge cache gc --older-than 30d # not used for 30 days
forge cache gc --max-size 2GiB # keep at most 2 GiB
forge cache gc --older-than 30d --max-size 2GiB --dry-run
- Charts not used within
--older-thanare removed. - Then, while the cache holds more than
--max-size, the least recently used archives (and every chart pointing at them) are removed. - Entries that point at a missing archive, unreadable entries and archives nothing points at are always removed.
"Used" means fetched or served from the cache: every cache hit in
dep build / dep update marks the chart as used, so charts a CI job needs
every day stay, however long ago they were downloaded.
| Flag | Default | Meaning |
|---|---|---|
--older-than |
— | Remove charts not used for this long: 30d, 2w, 12h, 90m |
--max-size |
— | Keep at most this much: bytes, 500MB (powers of 1000) or 2GiB (powers of 1024) |
--grace |
1h |
Never remove anything used or written this recently, so a dep build running at the same time keeps its charts |
--dry-run |
false |
Print what would be removed; remove nothing |
-v, --verbose |
false |
List removed charts |
-o, --output |
text |
text or json |
At least one of --older-than and --max-size is required (exit code 2
otherwise); use forge cache clean to remove everything. gc takes no lock
and is safe to run while other forge processes use the cache: a run that
loses an archive to gc just downloads it again.
The space freed is the size of the removed archives. An archive that is
hardlinked into some chart's charts/ keeps using disk until that copy is
deleted too.
With -o json, both commands print one document with schemaVersion,
command (cache ls / cache gc), cache (the directory) and success.
cache ls adds summary (charts, sizeBytes) and entries; cache gc
adds dryRun, summary (chartsRemoved, blobsRemoved, bytesFreed,
blobsKept, bytesKept) and removed. Each entry has name, version,
repository, source (oci or repo), state (ok, missing or
invalid), digest, sizeBytes, lastUsed and fetchedAt.
Flags (dep build and dep update)¶
| Flag | Default | Meaning |
|---|---|---|
--concurrency |
16 |
Max requests in flight in total |
--per-host |
8 |
Max requests in flight per host |
--refresh |
false |
Re-check versions with registries and repositories instead of trusting the cached version→digest mapping |
--plain-http |
false |
Use plain HTTP for OCI registries (e.g. a local registry:2). Chart repositories use the scheme in their URL |
--timeout |
5m |
Time limit for the whole run |
--registry-config |
Helm's path | Helm's registry credentials file |
--repository-config |
Helm's path | Helm's repositories.yaml (@name repositories, credentials, TLS) |
-o, --output |
text |
text or json (see JSON output) |
Per-request timeout is 60 s; failed requests are retried (see Errors and retries).
Environment¶
| Variable | Effect |
|---|---|
HELM_FORGE_CACHE |
Cache root. Default ~/.cache/helm-forge, or $HELM_CACHE_HOME/forge when run as helm forge |
HELM_REGISTRY_CONFIG |
Credentials file, same as Helm. Default: Helm's registry/config.json |
HELM_REPOSITORY_CONFIG |
Chart repositories file, same as Helm. Default: Helm's repositories.yaml |
HELM_FORGE_PLUGIN_URL |
Plugin install hook only: base URL for release downloads (mirror) |
Authentication¶
forge reads credentials only from Helm's own files, so log in the usual way.
OCI registries use Helm's registry config:
Chart repositories use repositories.yaml. Its username/password, CA file,
client certificate and insecure_skip_tls_verify apply to any dependency whose
URL matches the repository, and to @name references:
helm repo add acme https://charts.acme.example --username me --password-stdin
forge dep build ./my-chart
As in Helm, credentials go only to the repository's own scheme and host. If
index.yaml points archives at another host, add --pass-credentials to
helm repo add to send them there too.
Unlike helm dependency build, forge does not require helm repo add for a
chart repository URL; without an entry, access is anonymous. A missing file
means anonymous access everywhere.
Output¶
On success forge prints one line:
Aliases of one chart count separately in Saved, but the archive is
downloaded only once. Charts packaged from file:// directories add
, N local to the counts.
JSON output¶
With -o json, dep build and dep update print one JSON document on
stdout instead of the Saved … line. They print it on success and on failure. Errors still go to
stderr as text, so forge dep build -o json | jq always gets valid JSON.
Exit codes do not change.
Interactive
On the docs site, click the markers to see what each field means.
{
"schemaVersion": 1, // (1)!
"command": "dep build",
"chart": "./my-chart",
"success": false, // (2)!
"durationMs": 412,
"summary": { "saved": 2, "cached": 1, "downloaded": 0, "local": 1, "failed": 1, "skipped": 0 }, // (3)!
"lockWritten": false, // (4)!
"dependencies": [ // (5)!
{
"name": "redis",
"alias": "cache",
"version": "18.1.0",
"constraint": "^18.0.0", // (6)!
"repository": "oci://ghcr.io/acme/charts",
"status": "cached", // (7)!
"digest": "sha256:…",
"sizeBytes": 104857,
"durationMs": 3
},
{
"name": "common",
"version": "0.3.0",
"constraint": "~0.3.0",
"repository": "file://../common",
"status": "local", // (8)!
"sizeBytes": 2048,
"durationMs": 4
},
{
"name": "nginx",
"version": "9.9.9",
"constraint": "9.9.9",
"repository": "oci://ghcr.io/acme/charts",
"status": "failed",
"durationMs": 41,
"error": { // (9)!
"code": "not_found",
"httpStatus": 404,
"message": "404 not found",
"hint": "Fix the version, or run 'forge dep update'"
}
}
],
"registries": [ // (10)!
{ "host": "ghcr.io", "requests": 3, "retries": 0, "authRounds": 1 }
]
}
- Raised only for changes that could break a consumer. New fields can be added without raising it.
trueexactly when the exit code is0. Usejq -e .successas the CI gate.- Counts of
dependenciesby status.saved=cached+downloaded+local.charts/is updated only whensuccessistrue. truewhenChart.lockwas written — bydep update, or bydep buildwithout a lock.- One entry per
Chart.yamldependency, in order. Empty when the run stopped before resolving, e.g.Chart.lockout of sync. - The version exactly as written in
Chart.yaml.versionis what got locked. cached: served from the cache without any request.downloaded: needed at least one request. See the status table.- Packaged from a
file://directory on every run — no cache, no request.file://dependencies havesizeBytesbut nodigest. - Present when
statusisfailed. Match oncode, never onmessage. See the error codes. - One per host contacted, sorted by host.
requestscounts every HTTP request (retries included),retriesrepeats after 408/429/5xx/network errors,authRounds401challenges. A warm run has none.
| Field | Meaning |
|---|---|
schemaVersion |
1. It is raised only for changes that could break a consumer. New fields can be added without raising it |
command |
dep build or dep update |
chart |
The CHART argument as given (default .) |
success |
true exactly when the exit code is 0 |
durationMs |
Wall time of the run |
summary |
Counts of dependencies by status. saved = cached + downloaded + local. charts/ is updated only when success is true |
lockWritten |
true when Chart.lock was written (by dep update, or by dep build without a lock) |
dependencies[] |
One per Chart.yaml dependency, in order. Empty when the run stopped before resolving, e.g. Chart.lock out of sync |
dependencies[].alias, .constraint |
From Chart.yaml. constraint is the version as written |
dependencies[].version |
The locked version. Missing when resolving failed |
dependencies[].digest, .sizeBytes |
The chart archive's digest and size. file:// dependencies have only sizeBytes |
dependencies[].placement |
How the archive got into charts/: reflink, hardlink or copy. Present only when charts/ was updated |
dependencies[].durationMs |
Time spent fetching (or, for file://, packaging) this chart (aliases of one chart share it). Missing when no fetch was attempted |
dependencies[].error |
Present when status is failed |
registries[] |
One per host contacted (OCI registries and chart repositories), sorted by host. requests counts every HTTP request, retries included. retries counts repeats after 408/429/5xx/network errors. authRounds counts 401 challenges. Token servers and blob-redirect hosts appear as hosts of their own. A warm run has none |
error |
Present for failures that no single dependency explains, e.g. a lock out of sync, a timeout, or an unreadable Chart.yaml |
Status values¶
status is one of:
| Status | Meaning |
|---|---|
cached |
Served from the cache without any request |
downloaded |
Needed at least one request |
local |
Packaged from a file:// directory (every run; no cache, no request) |
failed |
See error |
skipped |
Not attempted because the run stopped first (another dependency had no matching version or an unsupported repository) |
Error codes¶
Every error object has a stable code, a message, and optionally httpStatus
and hint. Match on code, not on message:
| Code | Meaning |
|---|---|
unauthorized |
401: no or expired credentials |
forbidden |
403: credentials lack pull access |
not_found |
404: the version tag or archive does not exist, or the chart repository index has no such chart/version |
not_a_chart |
The tag is a container image or another artifact |
digest_mismatch |
Downloaded content did not match its digest twice |
no_matching_version |
No tag, index entry or local file:// chart satisfies the constraint. hint lists the nearest versions |
unsupported_repository |
Not an oci://, http(s)://, file:// or @alias repository |
timeout |
--timeout reached, or a request timed out |
lock_out_of_sync |
Chart.lock does not match Chart.yaml (top-level error only) |
interrupted |
SIGINT/SIGTERM (top-level error only) |
outdated |
dep outdated --fail-if-outdated found a dependency that is not up to date (top-level error only) |
usage |
Usage error, exit code 2 (see below) |
unknown |
Anything else. Read message |
A usage error with -o json prints a smaller document:
{
"schemaVersion": 1,
"command": "dep build",
"success": false,
"error": { "code": "usage", "message": "accepts at most 1 arg(s), received 2", "hint": "run 'forge dep build --help'" }
}
To turn failures into GitHub Actions annotations:
forge dep build -o json ./my-chart > forge.json || true
jq -r '.dependencies[] | select(.status == "failed")
| "::error title=forge: \(.name)::\(.error.code): \(.error.message). \(.error.hint // "")"' forge.json
jq -e .success forge.json > /dev/null
What ends up in charts/¶
- One
<name>-<version>.tgzper dependency — byte-identical to what Helm downloads. Chart repository archives keep the file name of their URL inindex.yaml, as in Helm. file://dependencies are packaged from their directory on every run, with Helm's own packaging code (no cache, no network); the archive's contents match Helm's.dep buildfails if the directory's chart version no longer matchesChart.lock.- Stale chart archives (no longer a dependency) are removed, as
helm dep builddoes. Non-chart files and unpacked subchart directories are kept. - Archives are reflinked, hardlinked or copied from the cache (cheapest first).
A hardlinked archive is read-only (
0444); that is expected.
A run that fails leaves charts/ exactly as it was.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Something failed — each failed dependency is listed; or dep outdated --fail-if-outdated found outdated dependencies |
2 |
Usage error (bad flag, too many arguments, --concurrency 0, unknown name for dep why, cache gc without limits, …) |
Errors and retries¶
forge retries 408, 429, 5xx and network errors up to 3 times, backing off
200 ms × 2ⁿ with jitter, or honouring Retry-After (max 30 s).
When dependencies still fail, all of them are reported together:
Error: 2 dependencies failed:
✗ redis 18.1.0 (oci://ghcr.io/acme/charts): 401 unauthorized; run 'helm registry login ghcr.io'
✗ nginx 9.9.9 (oci://ghcr.io/acme/charts): 404 not found
| Message | Cause | Fix |
|---|---|---|
401 unauthorized; run 'helm registry login <host>' |
No or expired credentials | helm registry login <host> |
403 forbidden |
Credentials lack pull access | Check repository permissions |
404 not found |
Version tag does not exist | Fix the version, or forge dep update |
… is not a Helm chart (layer media types: …) |
The tag is a container image or other artifact | Point the dependency at a chart |
downloaded content did not match its digest twice |
Corruption or a proxy rewriting responses | Check proxies; retry |
can't get a valid version for N subchart(s) |
No tag satisfies the range | forge lists up to 5 available versions; adjust the constraint |
401 unauthorized; add credentials with 'helm repo add …' |
Chart repository needs credentials | helm repo add <name> <url> --username … |
404 not found: <url> |
Archive listed in index.yaml is missing |
Fix the repository, or forge dep update |
<chart> chart not found in repo <url> |
index.yaml has no such chart |
Fix the name or repository |
no repository definition for @name |
@name / alias:name not in repositories.yaml |
helm repo add name <url> |
directory … not found |
file:// path does not exist (relative to the chart) |
Fix the path |
can't get a valid version for dependency <name> |
file:// chart's version changed since Chart.lock |
forge dep update |
forge supports oci://, http(s)://, file:// and @alias repositories |
No repository, or another scheme |
Use helm dependency build for that chart |
timed out after 5m0s |
--timeout reached |
Raise --timeout or check the network |
Caching in CI¶
The cache is safe to share between concurrent forge processes and to restore
from a CI cache. Example for GitHub Actions with the Helm plugin (its cache
lives under Helm's cache dir, so ask helm forge cache path):
- uses: azure/setup-helm@v4
- run: helm plugin install https://github.com/mmpyro/forge --version v2.1.0 --verify=false
- id: forge-cache
run: echo "dir=$(helm forge cache path)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.forge-cache.outputs.dir }}
key: forge-${{ hashFiles('**/Chart.lock') }}
restore-keys: forge-
- run: helm forge dep build ./charts/my-app -o json > forge.json
# Keep the saved cache bounded: drop what this and earlier jobs stopped using.
- run: helm forge cache gc --older-than 30d --max-size 2GiB
Drop --verify=false if the runner has Helm 3. With the standalone binary, use
forge cache path and forge dep build instead. To turn a failed run into
annotations, see JSON output.
With a warm cache, forge dep build makes no requests at all (file://
dependencies are packaged locally, which needs no network).
Cached tag→digest mappings are trusted; pass --refresh if tags in your
registry are overwritten (e.g. re-pushed 1.0.0).