Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ All notable changes to `ichava/core` follow [Keep a Changelog](https://keepachan

### Fixed

- **The nightly catalogue sync no longer puts Twemoji's pre-split values back.** `bin/sync-icon-sets.php` reads each pack at its latest tag, and `icon-sets-twemoji`'s `v0.1.0` still holds OpenMoji, so #176 proposed 10,567 "Emoji Sets" over the recorded 3,435 "Twemoji". A set in `icon-sets.json` may now carry a `hold` (`tag`, `commit`, `reason`): while that tag is the latest release at that commit the sync keeps the set's snapshot, and the hold drops itself once a newer tag is cut or the tag moves. The twemoji set is held at `v0.1.0`. `tests/Unit/SyncIconSetsHoldTest.php` covers it (proven red by ignoring the commit).
- **A pack listed with the application boots again.** `PackConfigCache` resolved the cache store when it was built, and a pack's constants class builds it while the providers are still registering, before the store exists, so every pack failed to boot with `Target class [cache] does not exist` (found running the packs against #172). The store is now resolved when it is used. Core's own pack tests registered their fixture after boot, which is why none saw it; `tests/Feature/PackBootsInProviderListTest.php` lists the fixture with the application.
- **`InformationService::discoverPackages()` finds the installed packs.** It globbed `vendor/*/ichava-*`, which matches no pack (packs install as `vendor/ichava/icon-sets-*`), and reported `registered` through `IconRegistry::has()`, which looks up an icon, not a package, so `ichava::ichava-core.info discover` listed nothing. It now asks `Composer\InstalledVersions` for every package of type `ichava-icon-set` and reports `registered` through `IconRegistry::isRegistered()`. The signature and return shape are unchanged. A pack is identified by its composer type and nothing else (owner, 2026-11-05): the `vendor/*/ichava-*` directory glob is removed, since no pack in the estate matched it, and the `platform/ichava` scan now keeps only a directory whose composer.json declares `ichava-icon-set`, keyed by that file's `name` instead of `ichava/<directory>`. A checkout there without the type is no longer listed. `tests/Feature/InstalledPackDiscoveryTest.php`.
- **The installation guide no longer says core is on Packagist.** Nothing in the family is (`repo.packagist.org` answers 404 for core, every pack and the four `laranail/*` packages). `docs/installation.md` step 1 is now the one place that lists the repositories an application adds, `ichava/core` included; the README and every pack link to it.
Expand Down
53 changes: 51 additions & 2 deletions bin/sync-icon-sets.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
* Usage:
* php bin/sync-icon-sets.php
*
* A set may carry a `hold`: {"tag", "commit", "reason"}. While the pack's latest
* release tag is still that tag at that commit, the set keeps its recorded
* snapshot instead of being re-read, because the recorded values describe work
* on the pack's default branch that the tag does not hold yet. The hold lifts by
* itself once a newer tag is cut or the tag points elsewhere, and is then dropped.
*
* Env:
* GITHUB_TOKEN optional, raises the API rate limit (workflow provides it)
*/
Expand Down Expand Up @@ -66,7 +72,11 @@ function main(string $manifestPath): int
continue;
}

$manifest['sets'][$index] = array_merge($set, $snapshot);
$manifest['sets'][$index] = array_filter(
array_merge($set, $snapshot),
static fn (mixed $value, string $field): bool => $field !== 'hold' || $value !== null,
ARRAY_FILTER_USE_BOTH,
);

printf(
"[%s] %s | %d icons (%s) | latest %s\n",
Expand Down Expand Up @@ -127,7 +137,14 @@ function withoutStamp(array $manifest): array
*/
function snapshot(string $repository, string $configPath, array $previous): ?array
{
$tag = latestTag($repository);
$tags = getJson("https://api.github.com/repos/{$repository}/tags?per_page=100");
$tag = is_array($tags) ? highestVersionTag($tags) : null;

if (is_array($tags) && isHeld($previous, $tags)) {
fwrite(STDERR, "[{$repository}] held at {$tag}: " . ($previous['hold']['reason'] ?? 'no reason given') . "\n");

return $previous;
}

$refs = array_filter([$tag, CONFIG_FALLBACK_BRANCH]);
$config = null;
Expand Down Expand Up @@ -159,9 +176,41 @@ function snapshot(string $repository, string $configPath, array $previous): ?arr
'variants' => is_array($variants) ? array_keys($variants) : ($previous['variants'] ?? null),
'latest_version' => $tag !== null ? ltrim($tag, 'vV') : ($previous['latest_version'] ?? null),
'visibility' => $visibility,
'hold' => null,
];
}

/**
* Whether a set's `hold` still applies: its tag is the highest release tag in the
* tags API response and still points at the recorded commit. A hold without both
* a tag and a commit never applies, so a malformed entry cannot freeze a set.
*
* @param array<string, mixed> $set the set as recorded in icon-sets.json
* @param array<mixed> $tags the tags API response
*/
function isHeld(array $set, array $tags): bool
{
$hold = $set['hold'] ?? null;
$tag = is_array($hold) ? ($hold['tag'] ?? null) : null;
$commit = is_array($hold) ? ($hold['commit'] ?? null) : null;

if (! is_string($tag) || $tag === '' || ! is_string($commit) || $commit === '') {
return false;
}

if (highestVersionTag($tags) !== $tag) {
return false;
}

foreach ($tags as $entry) {
if (is_array($entry) && ($entry['name'] ?? null) === $tag) {
return (is_array($entry['commit'] ?? null) ? ($entry['commit']['sha'] ?? null) : null) === $commit;
}
}

return false;
}

/**
* The repository's GitHub visibility, `public` or `private`, from the repos
* API response. A response that does not say (a failed request, or a private
Expand Down
7 changes: 6 additions & 1 deletion icon-sets.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@
"icon": 3435,
"illustration": 0
},
"visibility": "public"
"visibility": "public",
"hold": {
"tag": "v0.1.0",
"commit": "d7af0a20af8c5501ff5857c144573b1cf9329d16",
"reason": "v0.1.0 still carries the OpenMoji sets; the recorded values follow the default branch (Twemoji only) until the next release."
}
},
{
"key": "openmoji",
Expand Down
79 changes: 79 additions & 0 deletions tests/Unit/SyncIconSetsHoldTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

// bin/sync-icon-sets.php reads each pack at its latest release tag. When a pack's
// default branch has moved past that tag (icon-sets-twemoji dropped OpenMoji after
// v0.1.0), the catalogue records the branch's values by hand and the nightly sync
// would put the tag's old values back (core#176 proposed 10,567 "Emoji Sets" over
// 3,435 "Twemoji"). A set's `hold` keeps its snapshot while the tag it names is
// still the latest and still points at the recorded commit.

require_once dirname(__DIR__, 2) . '/bin/sync-icon-sets.php';

/**
* @param array<string, string> $tags name => commit sha
*
* @return list<array{name: string, commit: array{sha: string}}>
*/
function holdTagsResponse(array $tags): array
{
$response = [];

foreach ($tags as $name => $sha) {
$response[] = ['name' => $name, 'commit' => ['sha' => $sha]];
}

return $response;
}

/**
* @return array<string, mixed>
*/
function heldSet(?array $hold): array
{
return ['key' => 'twemoji', 'icon_count' => 3435] + ($hold === null ? [] : ['hold' => $hold]);
}

it('holds a set while its tag is the latest release at the recorded commit', function (): void {
$set = heldSet(['tag' => 'v0.1.0', 'commit' => 'aaa', 'reason' => 'branch ahead of tag']);

expect(isHeld($set, holdTagsResponse(['src/v0.1.0' => 'aaa', 'v0.1.0' => 'aaa'])))->toBeTrue();
});

it('lifts the hold once a newer release is tagged', function (): void {
$set = heldSet(['tag' => 'v0.1.0', 'commit' => 'aaa']);

expect(isHeld($set, holdTagsResponse(['v0.1.0' => 'aaa', 'v0.2.0' => 'bbb'])))->toBeFalse();
});

it('lifts the hold once the tag points at a different commit', function (): void {
$set = heldSet(['tag' => 'v0.1.0', 'commit' => 'aaa']);

expect(isHeld($set, holdTagsResponse(['v0.1.0' => 'ccc'])))->toBeFalse();
});

it('never holds a set without a complete hold', function (?array $hold): void {
expect(isHeld(heldSet($hold), holdTagsResponse(['v0.1.0' => 'aaa'])))->toBeFalse();
})->with([
'no hold' => [null],
'no commit' => [['tag' => 'v0.1.0']],
'no tag' => [['commit' => 'aaa']],
'an empty value' => [['tag' => '', 'commit' => 'aaa']],
]);

it('records a complete hold for every held set in icon-sets.json', function (): void {
$catalog = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/icon-sets.json'), true, flags: JSON_THROW_ON_ERROR);

expect($catalog['sets'])->not->toBeEmpty();

foreach ($catalog['sets'] as $set) {
if (! array_key_exists('hold', $set)) {
continue;
}

expect($set['hold']['tag'] ?? null)->toMatch('/^v\d+\.\d+\.\d+$/', "{$set['key']} hold has no release tag")
->and($set['hold']['commit'] ?? null)->toMatch('/^[0-9a-f]{40}$/', "{$set['key']} hold has no commit")
->and($set['hold']['reason'] ?? '')->not->toBe('', "{$set['key']} hold has no reason");
}
});
Loading