forked from libredb/libredb-studio
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdistribution-check.mjs
More file actions
923 lines (860 loc) · 35.6 KB
/
Copy pathdistribution-check.mjs
File metadata and controls
923 lines (860 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
#!/usr/bin/env node
/**
* Distribution visibility matrix checker.
*
* Reads the human-maintained channel inventory (distribution/channels.yaml),
* measures every live channel's pinned version against package.json, and
* prints a markdown drift table (also appended to GITHUB_STEP_SUMMARY when
* set). Warn-only by default so existing PaaS drift never breaks a release;
* `--strict` exits 1 only for owned local_file pins whose update SLA is
* every_release - remote catalogs are upstream-owned and never gate.
*
* `--matrix` is this file's second job: it regenerates the marker regions of
* docs/CHANNELS.md (the audience coverage matrix) from the same channel
* inventory. `--check` alongside it verifies those regions are up to date
* without writing, for use as a freshness gate.
*
* The checker only ever READS channels.yaml; version bumps in pin files and
* inventory edits are human work (see docs/DISTRIBUTION.md). Mirrors the
* style of scripts/sync-chart-version.mjs; the pure functions below are unit
* tested in tests/unit/distribution-check.test.ts.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const CHANNELS_YAML = "distribution/channels.yaml";
const STATUSES = ["live", "pending", "deprecated"];
const STRATEGIES = ["local_file", "remote_file", "probe", "none"];
const METHODS = ["ci_publish", "commit", "upstream_pr", "manual_ui"];
const SLAS = ["every_release", "minor_plus", "major_only", "on_demand"];
/** Business-facing buckets for docs/CHANNELS.md (not the maintainer tier axis). */
export const CHANNEL_CATEGORIES = [
"registries-releases",
"containers",
"kubernetes-operators",
"package-managers",
"os-desktop",
"paas-catalogs",
"deploy-recipes",
"cloud-marketplaces",
];
export const CATEGORY_LABELS = {
"registries-releases": "Registries & releases",
containers: "Containers",
"kubernetes-operators": "Kubernetes & operators",
"package-managers": "Package managers",
"os-desktop": "OS / desktop packages",
"paas-catalogs": "PaaS catalogs (listed)",
"deploy-recipes": "Deploy recipes",
"cloud-marketplaces": "Cloud marketplaces",
};
/**
* Technical shape of the artefact a channel actually is - independent of
* `category` (the audience-facing bucket several kinds can share). Neither
* axis determines the other: `kubernetes-operators` (category) spans
* `helm-chart`, `operator-catalog` and `partner-catalog` (kind), while
* `paas-template` (kind) spans both `paas-catalogs` and `deploy-recipes`
* (category). Exactly the 13 values in use across the inventory - a closed
* enum so a typo becomes a startup error instead of an unvalidated label
* that nothing ever reads.
*/
export const CHANNEL_KINDS = [
"release-assets",
"container-image",
"package-registry",
"helm-chart",
"package-manager",
"os-package",
"one-click-template",
"paas-template",
"deploy-button",
"operator-catalog",
"partner-catalog",
"curated-catalog",
"marketplace",
];
/**
* Platforms a channel serves. This order is canonical: the matrix renders and
* counts in it regardless of the order written in yaml, so output is stable.
* Not derivable from `kind` - `package-manager` alone spans Homebrew (macOS +
* Linux), Snap (Linux) and winget (Windows).
*/
export const PLATFORMS = ["linux", "macos", "windows", "container", "kubernetes", "cloud"];
/**
* Who provides the Node.js runtime the server actually runs on (issue #326).
* This is what decides whether raising `engines.node` is a breaking change a
* channel's users must act on, or an invisible detail of how we build:
*
* user_supplied the user's own `node` executes the payload, so the floor
* is theirs to meet. Exactly two channels: the npm package
* (`npx @libredb/studio`) and the standalone tarballs.
* channel_supplied the channel provides the runtime - bundled inside the
* artefact (container image, snap, deb/rpm, Windows zip,
* Flatpak, the Tauri sidecar), inherited from an image a
* template deploys, or installed as a declared package
* dependency (Homebrew's node@24). Raising the floor is
* transparent to these users.
*
* Not derivable from `kind`: `os-package` covers deb/rpm, which bundle a
* private Node, while `package-registry` covers npm, which does not.
*/
export const RUNTIME_OWNERS = ["user_supplied", "channel_supplied"];
export const PLATFORM_LABELS = {
linux: "Linux",
macos: "macOS",
windows: "Windows",
container: "Container",
kubernetes: "Kubernetes",
cloud: "Cloud",
};
const SLA_LABELS = {
every_release: "Every release",
minor_plus: "Minor+",
major_only: "Major only",
on_demand: "On demand",
};
const STATUS_ORDER = { live: 0, pending: 1, deprecated: 2 };
/**
* Channels whose release-CI publish step may be switched off from this file via
* `update.ci_enabled`, because they can be unavailable for reasons outside this
* repository (a package awaiting community moderation, a listing still under
* review) and a channel that cannot publish must not paint a good release run
* red.
*
* The set is deliberately closed and deliberately small. The core release path
* - github-release, docker-ghcr, npm, helm - and the assets publish-release
* requires (deb/rpm, AppImage, the win32 zip) are absent on purpose: a switch
* there would be a way to ship a release with no npm package or no image, and
* one mistyped `false` would do it silently. parseChannels rejects the flag
* anywhere else, so that invariant is enforced, not merely documented.
*/
export const SWITCHABLE_CHANNEL_IDS = new Set(["docker-hub-mirror", "homebrew", "snap", "winget", "chocolatey"]);
/**
* Probes measure channels whose served state is not one document a single
* regex can read: a registry that answers "which digest does this tag point
* at" rather than "which version", or a catalog that enumerates every
* published version with no floating "latest" entry.
*
* Each entry declares the pin.urls keys it needs (validated at parse time, so
* a typo in the inventory is a startup error and never a silent UNKNOWN) and
* a `run` that resolves the served version. Probe URLs live in channels.yaml
* rather than in this file on purpose: the unit tests point them at a local
* server, so the checker's own suite never touches the real network.
*/
const PROBES = {
"ghcr-tag-digest": {
requiredUrls: ["token", "manifest"],
async run({ pin, expected, timeoutMs }) {
// Public packages need no secret, only the anonymous pull token GHCR
// hands out to anyone who asks - so this probe works in a fork's CI too.
const token = await fetchJson(pin.urls.token, timeoutMs);
if (typeof token.body?.token !== "string") {
return { error: `ghcr token exchange failed (status ${token.status})` };
}
const headers = {
authorization: `Bearer ${token.body.token}`,
// Without these the registry answers with a single-platform manifest
// and the digests of two multi-arch tags would never compare equal.
accept: [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
].join(","),
};
const [latest, tagged] = await Promise.all([
fetchDigest(pin.urls.manifest.replace("{ref}", "latest"), timeoutMs, headers),
fetchDigest(pin.urls.manifest.replace("{ref}", expected), timeoutMs, headers),
]);
return digestVerdict({ latest, tagged, expected });
},
},
"dockerhub-tag-digest": {
requiredUrls: ["tag"],
async run({ pin, expected, timeoutMs }) {
// The per-tag endpoint carries the digest inline, so the mirror needs no
// pagination walk: a push that silently skipped leaves the version tag
// absent, which digestVerdict reports as drift rather than unknown.
const [latest, tagged] = await Promise.all([
fetchJson(pin.urls.tag.replace("{ref}", "latest"), timeoutMs),
fetchJson(pin.urls.tag.replace("{ref}", expected), timeoutMs),
]);
return digestVerdict({
latest: { status: latest.status, digest: latest.body?.digest ?? null },
tagged: { status: tagged.status, digest: tagged.body?.digest ?? null },
expected,
});
},
},
"snap-store-channel": {
requiredUrls: ["info"],
requiredArrays: ["architectures"],
async run({ pin, timeoutMs }) {
// The store rejects the request without a device series, whatever the
// snap name, so this header is part of the probe and not a nicety.
const info = await fetchJson(pin.urls.info, timeoutMs, { "snap-device-series": "16" });
if (info.status !== 200) {
return { error: `snap store unreachable (status ${info.status})` };
}
return parseSnapVersions(info.body, pin.architectures);
},
},
"winget-max-version": {
requiredUrls: ["versions"],
async run({ pin, timeoutMs }) {
// A directory listing is not paginated: the contents API returns the whole
// directory in one response and ignores per_page (verified against
// winget-pkgs, 785 entries in a single body). Its documented ceiling is
// 1,000 entries, above which GitHub directs callers to the Git Trees API -
// 1,000 published winget versions is not a reachable horizon here.
const listing = await fetchJson(pin.urls.versions, timeoutMs, githubAuthHeaders(pin.urls.versions));
if (!Array.isArray(listing.body)) {
return { error: `catalog listing unavailable (status ${listing.status})` };
}
return maxPublishedVersion(listing.body.map((entry) => entry.name));
},
},
};
/**
* api.github.com rate-limits anonymous callers hard enough to turn a weekly
* check into a coin flip; raw.githubusercontent.com needs no auth at all, so
* the token is attached there and nowhere else.
*/
export function githubAuthHeaders(url) {
if (url.startsWith("https://api.github.com/") && process.env.GITHUB_TOKEN) {
return { authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
}
return {};
}
async function fetchJson(url, timeoutMs, headers = {}) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers });
if (!response.ok) {
return { status: response.status, body: null };
}
return { status: response.status, body: await response.json() };
} catch {
return { status: null, body: null };
}
}
/** A registry reports which image a tag resolves to in a header, not a body. */
async function fetchDigest(url, timeoutMs, headers) {
try {
const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(timeoutMs), headers });
return { status: response.status, digest: response.headers.get("docker-content-digest") };
} catch {
return { status: null, digest: null };
}
}
/**
* A probe result is either `{ versions: [{ source, version }], detail? }` -
* fed through the same agree/compare path as a multi-file local pin - or
* `{ error }`, which degrades that row to UNKNOWN. The distinction is load
* bearing: "the registry did not answer" must never render as drift, and
* "the tag is genuinely absent" must never render as unknown.
*
* @typedef {{ source: string, version: string }} ProbedVersion
* @typedef {{ versions?: ProbedVersion[], detail?: string, error?: string }} ProbeResult
*/
/**
* Container registries answer "which digest does this tag point at", not
* "which version". Equal digests for `latest` and the expected version tag
* mean the channel serves that version; anything else yields a version string
* that cannot compare equal, so the caller reports drift and the digests land
* in the detail column.
*
* @returns {ProbeResult}
*/
export function digestVerdict({ latest, tagged, expected }) {
const drift = (detail) => ({ versions: [{ source: "latest", version: `!=${expected}` }], detail });
if (latest.status === 404) {
return drift("latest tag is missing from the registry");
}
if (tagged.status === 404) {
return drift(`no ${expected} tag published`);
}
if (latest.status !== 200 || tagged.status !== 200) {
return { error: `registry unreachable (latest=${latest.status}, ${expected}=${tagged.status})` };
}
if (latest.digest !== tagged.digest) {
return drift(`latest -> ${latest.digest}, ${expected} -> ${tagged.digest}`);
}
return { versions: [{ source: "latest", version: expected }] };
}
/**
* The Snap Store reports one version per channel and architecture. Only the
* stable risk of the default track is the served state - edge may legitimately
* run ahead or behind - and each architecture is measured separately so a
* single lagging build shows up as disagreeing sources rather than a pass.
*
* @returns {ProbeResult}
*/
export function parseSnapVersions(info, architectures) {
const map = info?.["channel-map"];
if (!Array.isArray(map)) {
return { error: "store response has no channel-map" };
}
const versions = [];
for (const architecture of architectures) {
const entry = map.find(
(item) =>
item?.channel?.track === "latest" &&
item?.channel?.risk === "stable" &&
item?.channel?.architecture === architecture,
);
if (!entry) {
return { error: `no stable channel entry for ${architecture}` };
}
versions.push({ source: architecture, version: entry.version });
}
return { versions };
}
/**
* Catalogs that publish every version as its own entry and no floating
* "latest" document (winget-pkgs) are measured by the highest version they
* list. Comparison is numeric per component: 0.9.64 is newer than 0.9.9,
* which a string sort gets backwards.
*
* @returns {ProbeResult}
*/
export function maxPublishedVersion(names) {
const parsed = names
.filter((name) => /^\d+\.\d+\.\d+$/.test(name))
.map((name) => ({ name, parts: name.split(".").map(Number) }));
if (parsed.length === 0) {
return { error: "no published version in the catalog listing" };
}
const newest = parsed.reduce((best, candidate) => {
for (let i = 0; i < 3; i++) {
if (candidate.parts[i] !== best.parts[i]) {
return candidate.parts[i] > best.parts[i] ? candidate : best;
}
}
return best;
});
return { versions: [{ source: "catalog", version: newest.name }] };
}
function countCaptureGroups(pattern) {
// Appending an empty alternative makes the regex match the empty string, so
// exec() always returns: array length - 1 == number of capture groups.
return new RegExp(`${pattern}|`).exec("").length - 1;
}
/**
* Parses and validates the inventory. Throws on structural problems (bad
* enum values, missing pin fields, duplicate ids) - a channel that cannot be
* measured on purpose must say so explicitly with `strategy: none`.
*/
export function parseChannels(yamlText) {
const doc = parseYaml(yamlText);
if (!doc || !Array.isArray(doc.channels)) {
throw new Error(`${CHANNELS_YAML}: top-level 'channels' list is missing`);
}
const seen = new Set();
for (const channel of doc.channels) {
const id = channel?.id;
if (typeof id !== "string" || id === "") {
throw new Error(`${CHANNELS_YAML}: every channel needs a non-empty string id`);
}
if (seen.has(id)) {
throw new Error(`${CHANNELS_YAML}: duplicate channel id '${id}'`);
}
seen.add(id);
if (!STATUSES.includes(channel.status)) {
throw new Error(`${CHANNELS_YAML}: ${id}: status must be one of ${STATUSES.join("|")}`);
}
if (!CHANNEL_CATEGORIES.includes(channel.category)) {
throw new Error(`${CHANNELS_YAML}: ${id}: category must be one of ${CHANNEL_CATEGORIES.join("|")}`);
}
if (!CHANNEL_KINDS.includes(channel.kind)) {
throw new Error(`${CHANNELS_YAML}: ${id}: kind must be one of ${CHANNEL_KINDS.join("|")}`);
}
if (!Array.isArray(channel.platforms) || channel.platforms.length === 0) {
throw new Error(`${CHANNELS_YAML}: ${id}: platforms must be a non-empty list`);
}
for (const platform of channel.platforms) {
if (!PLATFORMS.includes(platform)) {
throw new Error(`${CHANNELS_YAML}: ${id}: platforms entries must be one of ${PLATFORMS.join("|")}`);
}
}
if (!RUNTIME_OWNERS.includes(channel.runtime)) {
throw new Error(`${CHANNELS_YAML}: ${id}: runtime must be one of ${RUNTIME_OWNERS.join("|")}`);
}
if (!channel.update || !METHODS.includes(channel.update.method)) {
throw new Error(`${CHANNELS_YAML}: ${id}: update.method must be one of ${METHODS.join("|")}`);
}
if (!SLAS.includes(channel.update.sla)) {
throw new Error(`${CHANNELS_YAML}: ${id}: update.sla must be one of ${SLAS.join("|")}`);
}
const ciEnabled = channel.update.ci_enabled;
if (SWITCHABLE_CHANNEL_IDS.has(id)) {
// Required, not defaulted: the release CI's behaviour for these channels
// has to be a stated decision in this file, never an omission.
if (typeof ciEnabled !== "boolean") {
throw new Error(`${CHANNELS_YAML}: ${id}: update.ci_enabled must be true or false`);
}
} else if (ciEnabled !== undefined) {
throw new Error(
`${CHANNELS_YAML}: ${id}: update.ci_enabled is only allowed on ${[...SWITCHABLE_CHANNEL_IDS].join(", ")}`,
);
}
const pin = channel.pin;
if (!pin || !STRATEGIES.includes(pin.strategy)) {
throw new Error(`${CHANNELS_YAML}: ${id}: pin.strategy must be one of ${STRATEGIES.join("|")}`);
}
if (pin.strategy === "local_file" && (!Array.isArray(pin.files) || pin.files.length === 0)) {
throw new Error(`${CHANNELS_YAML}: ${id}: local_file pin needs a non-empty 'files' list`);
}
if (pin.strategy === "remote_file" && typeof pin.url !== "string") {
throw new Error(`${CHANNELS_YAML}: ${id}: remote_file pin needs a 'url'`);
}
if (pin.strategy === "probe") {
const probe = PROBES[pin.probe];
if (!probe) {
throw new Error(`${CHANNELS_YAML}: ${id}: pin.probe must be one of ${Object.keys(PROBES).join("|")}`);
}
for (const key of probe.requiredUrls) {
if (typeof pin.urls?.[key] !== "string") {
throw new Error(`${CHANNELS_YAML}: ${id}: probe '${pin.probe}' needs pin.urls.${key}`);
}
}
for (const key of probe.requiredArrays ?? []) {
if (!Array.isArray(pin[key]) || pin[key].length === 0) {
throw new Error(`${CHANNELS_YAML}: ${id}: probe '${pin.probe}' needs a non-empty pin.${key} list`);
}
}
}
// Probes resolve a version in code, so only the regex strategies carry an
// extract - and theirs must have exactly one capture group: extractPin
// reads m[1] only, so a second group would be measured or ignored
// silently. Use (?:...) for grouping.
if (pin.strategy === "local_file" || pin.strategy === "remote_file") {
if (typeof pin.extract !== "string" || countCaptureGroups(pin.extract) !== 1) {
throw new Error(`${CHANNELS_YAML}: ${id}: pin.extract must be a regex with exactly one capture group`);
}
}
}
return doc.channels;
}
/**
* `<name>=<bool>` lines for $GITHUB_OUTPUT, one per switchable channel present.
* Dashes become underscores because a GitHub expression cannot dot-access an
* output name that contains one.
*/
export function ciEnabledOutputs(channels) {
return channels
.filter((channel) => SWITCHABLE_CHANNEL_IDS.has(channel.id))
.map((channel) => `${channel.id.replaceAll("-", "_")}=${channel.update.ci_enabled}`);
}
/**
* Applies a channel's extract regex to one source. Multiple matches must
* agree - an ambiguous pin is reported, never silently resolved to the first
* occurrence (same rule as sync-chart-version's parseImageTag, #151).
*/
export function extractPin(content, extract, sourceLabel) {
const versions = [...content.matchAll(new RegExp(extract, "gm"))].map((m) => m[1]);
if (versions.length === 0) {
throw new Error(`${sourceLabel}: no version matched by the extract pattern`);
}
const unique = [...new Set(versions)];
if (unique.length > 1) {
throw new Error(`${sourceLabel}: extracted versions disagree (${unique.join(", ")})`);
}
return unique[0];
}
/**
* Builds one report row. `sources` maps every file path / url the channel
* pins to its text content, or null when unreadable (missing file, failed
* fetch) - fetching is the CLI's job so this stays pure and testable.
*
* Statuses: ok | drift | unknown (pin exists but is not measurable right
* now) | skip (pending/deprecated channel, or strategy none by design).
*/
export function evaluateChannel(channel, pkgVersion, sources) {
const row = {
id: channel.id,
name: channel.name ?? channel.id,
tier: channel.tier ?? "-",
channelStatus: channel.status,
strategy: channel.pin.strategy,
method: channel.update.method,
sla: channel.update.sla,
links: channel.links ?? {},
expected: pkgVersion,
observed: "-",
detail: channel.pin.note ?? "",
};
if (channel.status !== "live" || channel.pin.strategy === "none") {
return { ...row, status: "skip", expected: "-" };
}
const { problems, versions, detail } =
channel.pin.strategy === "probe" ? readProbe(channel, sources[probeKey(channel)]) : readPins(channel, sources);
if (problems.length > 0) {
return { ...row, status: "unknown", observed: "?", detail: problems.join("; ") };
}
const unique = [...new Set(versions.map((v) => v.version))];
if (unique.length > 1) {
const disagreement = versions.map((v) => `${v.key}=${v.version}`).join(", ");
return { ...row, status: "drift", observed: unique.join(", "), detail: `sources disagree: ${disagreement}` };
}
const observed = unique[0];
return { ...row, status: observed === pkgVersion ? "ok" : "drift", observed, detail: detail ?? row.detail };
}
/** Where the CLI parks a channel's probe result for evaluateChannel to read. */
export function probeKey(channel) {
return `probe:${channel.id}`;
}
function readPins(channel, sources) {
const keys = channel.pin.strategy === "local_file" ? channel.pin.files : [channel.pin.url];
const problems = [];
const versions = [];
for (const key of keys) {
const content = sources[key];
if (content === null || content === undefined) {
problems.push(`${key}: unreadable`);
continue;
}
try {
versions.push({ key, version: extractPin(content, channel.pin.extract, key) });
} catch (error) {
problems.push(error.message);
}
}
return { problems, versions };
}
/**
* Adapts a probe result onto the same shape a file pin produces, so a lagging
* Snap architecture reaches the caller as two disagreeing sources - exactly
* how two disagreeing pin files already do.
*/
function readProbe(channel, result) {
if (!result) {
return { problems: [`probe '${channel.pin.probe}' did not run`], versions: [] };
}
if (result.error) {
return { problems: [result.error], versions: [] };
}
return {
problems: [],
versions: result.versions.map((entry) => ({ key: entry.source, version: entry.version })),
detail: result.detail,
};
}
/**
* Strict mode gates only what this repo owns and promises to bump on every
* release: local_file pins with sla every_release. Anything else (remote
* catalogs, on_demand PaaS templates) stays warn-only in v1, so strict can
* actually be enabled without first paying off historical PaaS drift.
*/
export function strictFailures(rows) {
return rows.filter(
(row) =>
row.strategy === "local_file" &&
row.sla === "every_release" &&
(row.status === "drift" || row.status === "unknown"),
);
}
/** Short display text for a provenance link: #56, Dokploy/templates#931, or the word link. */
export function linkLabel(url) {
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/);
if (!match) {
return "link";
}
const [, owner, repo, number] = match;
return owner === "libredb" && repo === "libredb-studio" ? `#${number}` : `${owner}/${repo}#${number}`;
}
function linkCell(url) {
return url ? `[${linkLabel(url)}](${url})` : "-";
}
/** Markdown drift table. Plain-text statuses only (house rule: no emoji). */
export function renderTable(rows, pkgVersion) {
const lines = [
`## Distribution channels (expected version: ${pkgVersion})`,
"",
"| Status | Channel | Tier | Observed | Expected | SLA | Tracking | First PR | Last bump | Detail |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
];
for (const row of rows) {
lines.push(
`| ${row.status.toUpperCase()} | ${row.id} | ${row.tier} | ${row.observed} | ${row.expected} | ${row.sla} | ` +
`${linkCell(row.links.tracking_issue)} | ${linkCell(row.links.first_pr)} | ${linkCell(row.links.last_bump_pr)} | ` +
`${row.detail || "-"} |`,
);
}
return `${lines.join("\n")}\n`;
}
export function humanizeSla(sla) {
return SLA_LABELS[sla] ?? sla;
}
/** Resolve a channels.yaml docs path for links from docs/CHANNELS.md. */
export function docsHref(docsPath) {
if (!docsPath) {
return "DISTRIBUTION.md";
}
if (docsPath.startsWith("docs/")) {
return docsPath.slice("docs/".length);
}
return `../${docsPath}`;
}
/** Table display name: the short one when the inventory name is too long to scan. */
function displayName(channel) {
return channel.short_name ?? channel.name ?? channel.id;
}
/**
* Where a user actually obtains this channel. Undefined when the inventory has
* nothing to point at - deliberately not a heuristic, because a fallback chain
* invented here would be a rule nobody reading channels.yaml can see.
*
* A deprecated channel is never a place to get the product - a link there
* would tell a user to go install something that is not being shipped - so it
* renders as plain text even when the inventory still records where its
* submission went (e.g. Flathub's upstream_repo). That is a property of the
* status, not of any one channel, so it is a status guard here rather than a
* data deletion in channels.yaml.
*/
export function channelHref(channel) {
if (channel.status === "deprecated") {
return undefined;
}
return channel.links?.get ?? channel.links?.catalog ?? channel.links?.upstream_repo;
}
/** Platform labels in canonical order, whatever order the yaml used. */
export function platformCell(platforms) {
return PLATFORMS.filter((platform) => platforms.includes(platform))
.map((platform) => PLATFORM_LABELS[platform])
.join(", ");
}
/** Who bumps this channel and how fast. A dash for retired channels. */
export function updateSummary(channel) {
if (channel.status === "deprecated") {
return "—";
}
const automation =
channel.update.method === "ci_publish"
? channel.update.ci_enabled === false
? "Automated (paused)"
: "Automated"
: "Manual";
return `${automation}, ${humanizeSla(channel.update.sla).toLowerCase()}`;
}
/** Guide column label: the href without its relative prefix. */
export function guideLabel(href) {
return href.startsWith("../") ? href.slice("../".length) : href;
}
function sortedMatrixChannels(channels) {
return [...channels].sort((a, b) => {
const cat = CHANNEL_CATEGORIES.indexOf(a.category) - CHANNEL_CATEGORIES.indexOf(b.category);
if (cat !== 0) {
return cat;
}
const status = (STATUS_ORDER[a.status] ?? 99) - (STATUS_ORDER[b.status] ?? 99);
if (status !== 0) {
return status;
}
return a.id.localeCompare(b.id);
});
}
/** Live channels per platform, canonical order, zero-count platforms dropped. */
function livePlatformCounts(channels) {
const live = channels.filter((c) => c.status === "live");
return PLATFORMS.map((platform) => ({
label: PLATFORM_LABELS[platform],
count: live.filter((c) => c.platforms.includes(platform)).length,
})).filter((entry) => entry.count > 0);
}
/** Coverage scorecard for docs/CHANNELS.md: users, developers and buyers in one snapshot. */
export function renderScorecard(channels) {
const total = channels.length;
const live = channels.filter((c) => c.status === "live").length;
const pending = channels.filter((c) => c.status === "pending").length;
const deprecated = channels.filter((c) => c.status === "deprecated").length;
const lines = [
"## Coverage snapshot",
"",
`**${total} channels · ${live} live · ${pending} pending · ${deprecated} deprecated**`,
"",
];
// A multi-platform channel is counted once per platform, so these overlap and
// do not sum to the total. Live only: pending and declined are not coverage.
const platforms = livePlatformCounts(channels)
.map((entry) => `${entry.label} ${entry.count}`)
.join(" · ");
if (platforms) {
lines.push(`Live channels by platform: **${platforms}**`, "");
}
lines.push("| Category | Live | Pending | Deprecated |", "| --- | ---: | ---: | ---: |");
for (const category of CHANNEL_CATEGORIES) {
const inCat = channels.filter((c) => c.category === category);
if (inCat.length === 0) {
continue;
}
lines.push(
`| ${CATEGORY_LABELS[category]} | ${inCat.filter((c) => c.status === "live").length} | ` +
`${inCat.filter((c) => c.status === "pending").length} | ` +
`${inCat.filter((c) => c.status === "deprecated").length} |`,
);
}
lines.push("");
return `${lines.join("\n")}\n`;
}
/** Full channel inventory table for docs/CHANNELS.md. */
export function renderChannelMatrix(channels) {
const lines = [
"| Channel | Category | Platform | Status | Updates | Guide |",
"| --- | --- | --- | --- | --- | --- |",
];
for (const channel of sortedMatrixChannels(channels)) {
const href = channelHref(channel);
const name = displayName(channel);
const guide = docsHref(channel.links?.docs);
lines.push(
`| ${href ? `[${name}](${href})` : name} | ${CATEGORY_LABELS[channel.category]} | ` +
`${platformCell(channel.platforms)} | ${channel.status} | ${updateSummary(channel)} | ` +
`[${guideLabel(guide)}](${guide}) |`,
);
}
return `${lines.join("\n")}\n`;
}
const SCORECARD_BEGIN = "<!-- BEGIN:CHANNEL-SCORECARD -->";
const SCORECARD_END = "<!-- END:CHANNEL-SCORECARD -->";
const TABLE_BEGIN = "<!-- BEGIN:CHANNEL-TABLE -->";
const TABLE_END = "<!-- END:CHANNEL-TABLE -->";
function replaceMarkerRegion(doc, begin, end, body) {
const start = doc.indexOf(begin);
const stop = doc.indexOf(end);
if (start === -1 || stop === -1 || stop < start) {
throw new Error(`docs/CHANNELS.md is missing markers ${begin} / ${end}`);
}
const before = doc.slice(0, start + begin.length);
const after = doc.slice(stop);
const trimmed = body.replace(/^\n+/, "").replace(/\n+$/, "");
return `${before}\n\n${trimmed}\n\n${after}`;
}
/** Rewrite scorecard + table marker regions; leave narrative untouched. */
export function applyMatrixMarkers(doc, scorecard, table) {
let next = replaceMarkerRegion(doc, SCORECARD_BEGIN, SCORECARD_END, scorecard);
next = replaceMarkerRegion(next, TABLE_BEGIN, TABLE_END, table);
return next;
}
export function buildChannelsDoc(doc, channels) {
return applyMatrixMarkers(doc, renderScorecard(channels), renderChannelMatrix(channels));
}
async function fetchText(url, timeoutMs) {
const headers = githubAuthHeaders(url);
try {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers });
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
}
}
async function main(argv) {
const strict = argv.includes("--strict");
const json = argv.includes("--json");
const matrix = argv.includes("--matrix");
const checkOnly = argv.includes("--check");
const rootIdx = argv.indexOf("--root");
const rootArg = rootIdx === -1 ? undefined : argv[rootIdx + 1];
if (rootIdx !== -1 && (rootArg === undefined || rootArg.startsWith("--"))) {
console.error("ERROR: --root requires a directory path");
process.exit(2);
}
const root = rootIdx === -1 ? process.cwd() : path.resolve(rootArg);
const timeoutMs = Number(process.env.DISTRIBUTION_CHECK_TIMEOUT_MS ?? 10_000);
if (checkOnly && !matrix) {
console.error("ERROR: --check requires --matrix (use: bun run distribution:matrix --check)");
process.exit(2);
}
if (matrix) {
const docPath = path.join(root, "docs/CHANNELS.md");
const channels = parseChannels(fs.readFileSync(path.join(root, CHANNELS_YAML), "utf8"));
const current = fs.readFileSync(docPath, "utf8");
const next = buildChannelsDoc(current, channels);
if (checkOnly) {
if (current !== next) {
console.error("ERROR: docs/CHANNELS.md is stale; run: bun run distribution:matrix");
process.exit(1);
}
console.log("docs/CHANNELS.md matrix regions are up to date");
return;
}
fs.writeFileSync(docPath, next);
console.log("Updated docs/CHANNELS.md matrix regions");
return;
}
// The release workflow's automation gates. They answer from the inventory
// alone - no package.json read, no network - so a gate check can never fail
// for an unrelated reason and take a working channel down with it.
const ciOutputs = argv.includes("--ci-outputs");
const ciIdx = argv.indexOf("--ci-enabled");
const ciId = ciIdx === -1 ? undefined : argv[ciIdx + 1];
if (ciIdx !== -1 && (ciId === undefined || ciId.startsWith("--"))) {
console.error("ERROR: --ci-enabled requires a channel id");
process.exit(2);
}
if (ciOutputs || ciIdx !== -1) {
const inventory = parseChannels(fs.readFileSync(path.join(root, CHANNELS_YAML), "utf8"));
if (ciOutputs) {
for (const line of ciEnabledOutputs(inventory)) console.log(line);
return;
}
if (!SWITCHABLE_CHANNEL_IDS.has(ciId)) {
console.error(`ERROR: '${ciId}' is not a switchable channel (${[...SWITCHABLE_CHANNEL_IDS].join(", ")})`);
process.exit(2);
}
const channel = inventory.find((entry) => entry.id === ciId);
if (!channel) {
console.error(`ERROR: channel '${ciId}' is not in ${CHANNELS_YAML}`);
process.exit(2);
}
console.log(String(channel.update.ci_enabled));
return;
}
const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
const channels = parseChannels(fs.readFileSync(path.join(root, CHANNELS_YAML), "utf8"));
const sources = {};
const fetches = [];
for (const channel of channels) {
if (channel.status !== "live") {
continue;
}
if (channel.pin.strategy === "local_file") {
for (const file of channel.pin.files) {
try {
sources[file] = fs.readFileSync(path.join(root, file), "utf8");
} catch {
sources[file] = null;
}
}
} else if (channel.pin.strategy === "remote_file") {
const { url } = channel.pin;
fetches.push(fetchText(url, timeoutMs).then((content) => (sources[url] = content)));
} else if (channel.pin.strategy === "probe") {
const run = PROBES[channel.pin.probe].run({ pin: channel.pin, expected: pkgVersion, timeoutMs });
fetches.push(run.then((result) => (sources[probeKey(channel)] = result)));
}
}
await Promise.all(fetches);
const rows = channels.map((channel) => evaluateChannel(channel, pkgVersion, sources));
const table = renderTable(rows, pkgVersion);
if (json) {
console.log(JSON.stringify({ expected: pkgVersion, rows }, null, 2));
} else {
console.log(table);
}
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${table}\n`);
}
const failures = strictFailures(rows);
if (strict && failures.length > 0) {
for (const row of failures) {
console.error(`ERROR: strict: ${row.id} is ${row.status} (observed ${row.observed}, expected ${pkgVersion})`);
}
process.exit(1);
}
}
// CLI entry only when executed directly (the unit test imports this module).
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main(process.argv.slice(2));
}