What has shipped in Ichava's open-source packages, newest first, from each package's own changelog.
The newest releases of each open-source package. The full history is in each package's
CHANGELOG.md on GitHub.
Versioning re-based to 0.1.0. This release contains everything previously tagged v0.1.0 through v0.3.4 (2026-08-31 to 2026-09-26) plus the changes below, and requires ichava/core ^0.1, the re-based core. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
Versioning re-based to 0.1.0. This release contains everything previously tagged v0.1.0 through v0.3.4 (2026-08-31 to 2026-09-26) plus the changes below, and requires ichava/core ^0.1, the re-based core. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
Versioning re-based to 0.1.0. This release contains everything previously tagged v0.1.0 through v0.3.4 (2026-08-31 to 2026-09-26) plus the changes below, and requires ichava/core ^0.1, the re-based core. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
Versioning re-based to 0.1.0. This release contains everything previously tagged v0.1.0 through v0.1.3 (2026-09-21 to 2026-09-26) plus the changes below, and the stub now requires ichava/core ^0.1, the re-based core. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
Versioning re-based to 0.1.0. This release contains everything previously tagged v0.1.0 through v0.1.2 (2026-08-31 to 2026-09-22) plus the changes below. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
Versioning re-based to 0.1.0. This is the first release of the Go implementation and the first with release binaries. It contains everything previously tagged v0.1.0 through v0.1.3 (2026-08-31 to 2026-09-22, the Python ichava/maintainer-toolkit) plus the changes below. Those tags and their GitHub releases were removed; every commit they pointed at is on main, and their sections are kept under Archived pre-reset history.
-
Release binaries, signed. A vX.Y.Z tag now runs GoReleaser (src/.goreleaser.yaml): bare binaries for icon-sets-sync and imt across Linux, macOS (plus a universal binary), Windows and FreeBSD, .tar.gz/.zip archives, .deb/.rpm/.apk, a source tarball, an SPDX SBOM per archive and checksums.txt. Every artifact gets a build-provenance attestation, and checksums.txt a keyless Sigstore signature (checksums.txt.sigstore.json). The workflow also pushes the src/vX.Y.Z tag Go needs to resolve the module. The Tests workflow builds the same set as a snapshot on every pull request.
-
Pack configs are validated against a JSON Schema, config/schema/pack.schema.json (2020-12),
on every load. Every violation is reported at once with its JSON pointer. The binary embeds a
copy, and a test fails if the two differ.
-
Pack configs may be YAML (<slug>.yaml or .yml), as may the registry. Both formats decode
to one value model; a JSON and a YAML file for the same slug is an error.
-
Layered settings: flags > ICHAVA_* environment > global config > a pack's settings block >
defaults, for dry_run, force, log_level, concurrency, workspace, config_dir, the
git.* identity, branch, base, commit text and open_pr, and the emoji recipe's Unicode and
OpenMoji versions. An optional global config lives at
$XDG_CONFIG_HOME/icon-sets-sync/config.{json,yaml}. An unknown key in any layer is an error.
--set key=value sets any of them from the command line. The old ICHAVA_DEV_CONFIG_DIR and
ICHAVA_DEV_LOG_LEVEL are still read.
-
Sources, transforms and sinks are configurable per pack. The generic recipe builds a pack's
pipeline from its config: an npm or github-archive/github-release/github-tag source, a
declared transforms list (subset, sanitise, slugify, categorise, indexer,
flatten-variants), and sink options (incremental, min_retention, commit_subject,
commit_body, base, open_pr). A pack may name its recipe explicitly. With no transforms
declared, a pack behaves exactly as before.
-
sync-all, with --concurrency; sync --all honours it too. --commit overrides a dry_run
set anywhere below the flags.
-
config validate and config print [--pack] [--origins] [--format json|yaml].
-
The interactive menu can sync one pack (after a y/N confirmation) and sync every pack as a dry
run, as the Python menu could; the sync runs after the menu closes, through the same code as
sync.
-
A Docker image job in the Tests workflow builds the image on every pull request and runs it the
way the pack workflows do.
-
The lxml verdicts behind the SVG corpus differential are frozen in
src/internal/core/svg/testdata/lxml-oracle.json (1,274 icons), and a second test holds the Go
filter to them, so the gate keeps working once there is no Python to run.
-
Tests for gitx, the CLI, the TUI menu, the settings precedence at every layer, the schema, and
the sync orchestration, none of which needs the network.
-
CI runs the Go port. A Go job in tests.yml checks gofmt, go vet and go test
against the module in src/, and fails if fewer than 10 packages are found, because a
run pointed at the wrong directory compiles nothing and passes.
-
Rename rules, so a flat set vendored from a multi-variant upstream can be refreshed. Three
sets commit several upstream directories as one flat directory, distinguishing the variants by
an affix on the filename, and a manifest path cannot express that. The failure would not have
been a partial refresh but a destructive one: the sink wipes the destination first, so pointing
path at heroicons' 24/solid refreshes 324 icons and deletes the other 964.
A set may now declare variants instead, each with its own path and an optional prefix or
suffix, plus an optional separator when the pack and its upstream spell word breaks
differently. The rules were recovered by measurement rather than guessed, and the shipped Go
transform was run over the real packages to confirm it:
| Set |
Upstream |
Produced |
Committed |
Byte-identical |
New |
Dropped |
heroicons |
24/outline 24/solid 20/solid 16/solid as o- s- m- c- |
1,288 |
1,288 |
1,288 |
0 |
0 |
teeny-icons |
solid bare + outline as -o |
1,200 |
1,200 |
0 |
0 |
0 |
google-material-design-icons |
five variants as bare -o -r -s -tt |
10,610 |
10,751 |
0 |
0 |
141 |
heroicons reproduces every committed icon byte for byte, which is as strong as evidence for
a recovered rule gets. teeny-icons matches every name but no bytes -- the pack reformatted
them onto fill="currentColor" -- and gmdi drops 141 icons upstream has retired, at 98%
retention. None of the three produces an icon the pack does not already have, which is what
rules out an affix rule that happens to cover the set while meaning something else.
google-material-design-icons also needs "separator": "-": upstream ships 18_up_rating
against the pack's 18-up-rating, and without the rewrite only 607 of 2,168 names per variant
line up -- which is exactly why the audit had been reporting it as a partial match against the
right package.
Two ways this loses an icon quietly are refused rather than tolerated. A collision -- an
upstream icon whose own name ends in another variant's suffix, so outline/foo.svg and
solid/foo-o.svg both flatten onto foo-o.svg -- fails the run naming both sources, because
the second copy would replace the first at an unchanged file count and the retention guard
would see a clean swap. There are none in this pack today, which is a fact about today's
upstreams and not a property of the scheme. A variant that resolves to a missing or empty
directory likewise fails, rather than refreshing a fraction of the set over a wipe.
The manifest now requires exactly one of path and variants: both is ambiguous about which
the refresh honours, and neither leaves the entry pointed at the package root, which for most
upstreams is a README and a licence rather than icons.
-
The audit reports a tie it cannot break instead of returning one. A package that ships
sibling variant directories -- cryptocurrency-icons has svg/{black,white,color,icon}, each
with the same 483 filenames -- defeats name scoring completely: all four cover the set equally,
and when the pack vendored an older release none of them is byte-identical either. The scorer
was then returning whichever directory WalkDir reached last and grading it package.
It reached svg/white. The committed icons are svg/black with fill="currentColor" added to
the root, so a refresh would have replaced every themeable icon with a hard-coded #FFF one,
invisible on a light background -- at an identical file count, so the retention guard would
have waved it straight through. Re-auditing the 28 entries the manifest had accepted found
five such ties, and three of the five had resolved to the wrong directory.
Ties are now settled where they can be measured and reported where they cannot:
- A rival holding the same bytes under a second path is a packaging duplicate, not a choice,
and is excluded.
ionicons ships its icons at dist/ionicons/svg and again under
dist/collection/components/icon/svg -- byte-identical to each other.
- A rival that genuinely differs is compared by resemblance to the committed icons, and the
tie is settled when one is clearly closest. That fixes
fontawesome (svgs, not FA7's
640x640 svgs-full against a committed 448x512) and pepicons (svg/pop, not svg/print).
- Anything closer than that grades
ambiguous: the right package, an undecidable variant.
It is a separate grade from partial because the remaining work differs in kind -- a partial
result needs a better candidate package, an ambiguous one needs a human to name the variant.
The resemblance metric has a stated limit rather than a tuned threshold. Trigrams over a whole
file are dominated by the path data, so two variants drawing the identical shape and differing
only in a colour attribute score nearly the same -- exactly cryptocurrency-icons. Those stay
ambiguous. Tuning the margin until it happened to pick black would fit the constant to two
cases and settle the next one wrongly and silently.
-
The manifest's acceptance floor is the refresh's retention floor, one constant rather than
two literals. A set was admitted at 95% name coverage while the refresh refuses to replace a
directory below 90% retention, so the audit rejected entries a refresh would have accepted and
admitted none it would refuse. Measured, 95% excluded @mapbox/maki for maki-icons at 198 of
211 -- unmistakably the right project, rejected over thirteen retired icons. recipes now reads
bundled.PackageCoverageFloor directly, and a test pins the two together: two independent
literals would drift, and the drift would surface as a Monday cron refusing sets the audit
approved on Friday, with an error about upstream reorganising that would be the wrong
explanation entirely.
-
The aggregator pack's set manifest carries 35 sets covering 49% of the pack's icons, against
the 21 sets and 38% the first audit established. Two are settled by inspection rather than by
the scorer and say so in their notes, with the evidence: cryptocurrency-icons to svg/black
and ionicons to dist/svg, whose sibling expresses the same drawings as
class="ionicon-fill-none ionicon-stroke-width" and renders as nothing without ionicons' own
stylesheet. teeny-icons was withdrawn -- its 1,200 committed icons merge upstream's solid/
and outline/ with an -o suffix, which no single path can express; it now grades partial
at 600 of 1,200 and needs a rename rule, as heroicons and google-material-design-icons do.
-
The audit recorded paths in a frame the recipe could not read. It reported them relative to
the extraction root (package/icons) while NpmTarball hands the inside of the npm wrapper
downstream, so every SubsetTo failed with "subdir not found". The audit now resolves inside
package/, which is the frame the pipeline uses.
-
A set tracking latest stamped the literal string "latest" as its version. NpmTarball
now records what npm resolved -- read from the tarball filename -- so the version in the logs,
the metrics and any stamp is a version rather than a word that compares as one.
-
The HTTP transport keeps its proxy, and SSL_CERT_FILE is loaded explicitly.
The transport is cloned from http.DefaultTransport rather than built fresh, and that is the
load-bearing half. A hand-rolled &http.Transport{TLSClientConfig: …} silently drops
Proxy: http.ProxyFromEnvironment: every request then dials directly and fails as
no such host, which reads like DNS rather than like the configuration mistake it is. Pinned
by a test that asserts a bare &http.Transport{} has no Proxy and a clone does. Two sessions
confirmed this independently.
The certificate half is defensive rather than established, and the code says so. Go is
documented to honour SSL_CERT_FILE, and on Linux -- production, since the image is Linux --
it does. On the macOS machine this was developed on it demonstrably did not: with the variable
visible to the process, x509.SystemCertPool() returned zero subjects and every TLS dial
failed, while gh on the same toolchain in the same shell succeeded. A second session measured
the opposite and neither could reproduce the other, so the bundle is loaded explicitly, which
is correct under either reading -- a no-op where Go already honours the variable, and a fix
where it does not.
With it, check runs against the live registries for the first time: tabler 3.46.0 behind
3.47.0, flag 7.0.0 behind 7.5.0, bundled 2.2.0 behind 2.2.531, emoji current, exit 1. The
release URLs, the staleness comparison and the CI exit contract are now verified against real
registry responses rather than stubs.
-
imt bundled audit -- identifies, by measurement, where each vendored set in the aggregator
pack came from. It fetches candidate npm packages, scores every directory in them against the
committed icons, and believes nothing on the strength of a name: a candidate is accepted only
when the bytes it ships are the bytes already committed.
It was written instead of the iconify recipe the config asks for, because that recipe would
have been destructive. Measured across the pack, the 121,314 committed icons carry 26 distinct
<svg> attribute signatures -- bootstrap-icons keeps class="bi bi-alarm", heroicons
keeps data-slot, fontawesome keeps its licence comment, akar-icons is stroke-based -- where
anything rendered from @iconify-json/* would have exactly one. Each set is its own upstream's
native distribution, so update_command.type: "iconify" describes an approach this pack was
never built with, and implementing it would have rewritten every file into a uniform dialect,
discarding a licence attribution among much else.
Refreshing the pack therefore needs a per-set manifest that does not exist. This builds one.
Run against the real pack it resolves 21 of 72 sets, two of them exactly:
bootstrap-icons@1.13.1 reproduces all 2,078 of its committed icons byte for byte, and
feather-icons@4.29.2 all of its.
Confidence is graded on two signals kept deliberately apart, because the audit showed why:
ionicons matched 1,356 of 1,356 names with zero identical bytes. The committed files
expand what upstream now writes as CSS classes into explicit fill/stroke attributes and use
comma-separated path data -- an older release of the right project, not a wrong project. So byte
identity grades the version and name coverage grades the source, and package means
"upstream found, pin a version".
For a set the name patterns cannot reach, supply the package and let the tool answer:
imt bundled audit --set phosphor-icons --package @phosphor-icons/core
which reports partial 1512/9072 package/assets/bold -- the project is right, and the pack has
flattened several weight variants into one directory, so no single upstream directory covers it.
-
The aggregator pack's config describes a pack that does not exist. Its _status points
at a recipe path that was never written, and the pack's own update_command._note claims the
sets listed under metadata.data.categories.*.sets "define what's vendored". They do not: 42
sets are declared and 72 are vendored, 13 declared sets have no directory at all -- including
lucide and material-design-icons -- and 43 vendored sets are undeclared. Recorded here
rather than silently corrected, because which list is meant to be authoritative is a decision
for the pack's owner.
-
The sources, transforms, sinks and recipes. The engine is complete: sync and recipe
now run real pipelines rather than reporting that they cannot.
Verified end to end against a real upstream, not fixtures. A recipe icon-sets-flag --version 7.5.0 --dry-run fetches from npm, narrows to flags/, applies the policy to all
542 icons and writes them into the pack -- and the result is byte-for-byte identical to
what a previous Python run committed, all 542 files.
That comparison found the one defect the corpus differential could not: lxml models the text
after an element as that element's .tail, so el.remove(child) takes both, while a tree
built from Go's decoder holds it as a separate sibling. Removing only the element left its
indentation behind, and 4 of the 542 icons differed by exactly one orphaned newline. The
corpus test compares surviving structure, and whitespace is not structure -- so it took a
byte diff against a committed artefact to see it.
Deliberate departures from the Python, each with a test:
icon-sets-emoji was never version-stamped. Its recipe ends with a commit-only
pipeline whose source was a no-op, and every pipeline gets its own context -- so
VersionStamp found no fetched_version, logged "skipping" and did nothing. The pack's
vendored version never moved and its sync could not converge, which is exactly the V49
failure the vendored-version design exists to prevent, still live in the one recipe that
composes several pipelines. The commit pipeline now carries the version forward.
- Archive extraction refuses a traversing entry. Python got that free from
tarfile's
filter="data"; Go's archive/tar and archive/zip do nothing about it, and this fetches
archives from the internet inside a container with the pack tree mounted.
gh pr list is filtered to open pull requests. Unfiltered, it matched the merged one
once the first sync landed, so every later run printed "already open", skipped creation and
exited 0 while the branch it had just pushed had nothing tracking it.
- The sync fetches the remote tracking ref before pushing.
actions/checkout fetches only
the default branch, so refs/remotes/origin/<branch> does not exist on a runner and
--force-with-lease has nothing to compare -- git then rejects the push as "stale info",
which reads like a race and is not one.
Categorise refuses a taxonomy that parsed to zero records rather than filing every
icon under "missing" and silently emptying the pack.
-
internal/core/svg -- the SVG policy layer, the riskiest part of the port. lxml is a real
DOM and Go's encoding/xml is a streaming decoder, so the two had to be made to agree rather
than assumed to. It is verified by running both implementations over all 17,812 vendored
icons in the four pack checkouts and requiring identical answers for what the policy removed
and what structure survived: zero mismatches, in ten seconds.
That gate earned itself immediately. A fixture suite would have passed while two real defects
sat in the port:
- 261 flag icons in one pack declare
encoding="iso-8859-1". Go's decoder refuses any encoding it
has no table for; lxml has them all. There is now a charset reader for Latin-1 -- the only
non-UTF-8 encoding the corpus contains, measured -- and an explicit error for anything else,
because silently decoding an unknown encoding as Latin-1 mangles multi-byte characters into
pairs of accented ones and the damage is invisible until someone renders a title.
styleAttribute in the policy is an object, not a boolean. Python reads it with a
truthiness test. Typing it as a Go bool made the whole policy fail to load -- and had that
failure been swallowed, style would have dropped off the merged allow-list, removing the
sole paint source from over half of one pack's icons. That is the exact trap the policy file's
own comments warn about.
One Go-specific hazard has no Python counterpart and is pinned by its own test: lxml keeps
namespace declarations out of el.attrib entirely, while Go's decoder hands them over as
ordinary attributes -- and xmlns is not on the allow-list, so filtering them would have
stripped the SVG namespace from every icon in the estate.
The vendored policy is registered in .scripts/sync-svg-policy.mjs as a fifth consumer and
pinned by digest here, so the Go copy is covered by the same cross-repo gate as the other four.
Worth a separate decision, not taken here: the policy allows all 22 fe* filter tags and none
of their attributes, so <feGaussianBlur stdDeviation="2"/> survives as <feGaussianBlur/>
and blurs by zero. Both implementations agree, so it is the policy's behaviour rather than a
port defect -- but the canonical file lives in core and four runtimes read it.
-
The CLI and the TUI, over one engine. cmd/imt builds a cobra tree with all six commands
the Python declared -- list-packs, check, sync, recipe, menu, version -- and the
exit codes they are consumed by: 1 when check finds anything stale, 1 when a sync
recipe fails, 2 for a usage error. sync and recipe are wired but report that the
recipes are not ported yet; Python is still the entrypoint and still does the real work.
menu is now a Bubble Tea TUI -- menu, pack list, pack detail and a live check view sharing
one spinner and one table. In a non-TTY it prints help and exits 0. The Python called
questionary.select() unconditionally, and menu is the Docker image's default CMD, so on a
runner it blocked on a stdin that never answered until the job hit its 30-minute timeout.
The parity rule is structural rather than aspirational: internal/app holds the orchestration
and internal/ui the rendering, and both front-ends call them. The first attempt kept those
helpers in internal/cli, which deadlocked into an import cycle the moment the CLI needed to
launch the TUI -- a useful accident, since it forced the shared layer to be named instead of
living wherever it happened to be written first.
-
check reported success for a run that checked nothing. The summary counted stale packs
only, so when every registry was unreachable it found zero stale and printed
All packs up to date -- observed here with all four upstreams failing. It now separates
unresolved packs from up-to-date ones and says no pack could be checked: 4 of 4 upstreams were unreachable. Inherited from core/reporters/tty.py, which has the same defect.
-
list-packs printed the wrong version. It read current_version from this repository's
config, which is only the fallback, so a pack that had already been synced still displayed a
stale number here while check correctly reported it up to date. It now prints the resolved
version, the same one every other command compares against.
-
internal/core/pipeline, internal/core/checker and internal/core/httpx. The engine is
complete and has no third-party dependencies.
pipeline makes Python's Source/Transform/Sink real types rather than the empty marker
subclasses they were, so passing a sink where a source belongs no longer compiles. Sink order
stays load-bearing and is documented at the method: filesystem, then version stamp, then git
branch, so the version that gets committed is the version that shipped.
checker settles an ordering question the Python had wrong. core/checker.py opens by
declaring it "Mirrors PHP IconPackUpdateChecker exactly. The two implementations MUST agree" --
and they do not. PHP calls version_compare; Python builds a tuple of mixed ints and strings.
For 3.46.0 against 3.46.0-rc1 PHP reports not-stale and Python reports stale, so a
scheduled sync would have proposed replacing a stable release with a pre-release of itself;
Python's version also raises TypeError outright on a mixed int/string part. This port
follows PHP, which is the declared reference and the correct one, and pins it by running the
real php -r 'version_compare(...)' over 31 real and adversarial pairs -- they agree on every
one. TrimVersion keeps the character-set trim both implementations share rather than
correcting it here, because that would leave this port out of step with both.
httpx keeps tenacity's four attempts and exponential bounds, and narrows which failures
retry: Python retried every non-2xx, so a 404 cost four attempts and three backoffs to report
what the first response already said. Downloads stay idempotent by existence, and a failed one
now leaves no truncated file for the next run to mistake for a cache hit.