diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..7d582a5 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,29 @@ +name: Catalog quality + +on: + pull_request: + paths: + - "catalog.json" + - "scripts/**" + - ".github/workflows/**" + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: extensions-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Validate compatibility catalog + run: node scripts/validate-catalog.mjs + - name: Run replacement and integrity tests + run: node --test scripts/validate-catalog.test.mjs + - name: Actionlint + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 diff --git a/README.md b/README.md index d675993..6455419 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # Kosmos Extensions -Binary marketplace for Kosmos .kext extension packages. +This repository is a frozen, compatibility-only catalog for legacy `.kext` +clients. It is not the source of truth for current product discovery or +installation. + +Legacy entries in `catalog.json` carry explicit deprecation and +`replacementId` metadata: + +- Arrancador and the legacy Arcadia entry migrate to `com.kosmos.arcadia`; +- Eden migrates to `com.kosmos.memoria`; +- Delphi migrates to `com.kosmos.agenda`. + +A compatibility client may resolve these aliases for upgrade, but no entry in +this feed is an active current product. New first-party discovery belongs in +the signed Store catalog, and installation authority belongs in the signed +Package Index. Do not publish new first-party artifacts here. + +## Validation + +Catalog CI rejects duplicate identities, malformed metadata, non-HTTPS or +invalid artifact integrity values, missing deprecation reasons/replacements, and +replacement cycles: + +```powershell +node scripts/validate-catalog.mjs +node --test scripts/validate-catalog.test.mjs +``` + +The final migration of existing `.kext` data, settings, and grants remains a +consumer/client release gate; this repository cannot prove that external +upgrade path by itself. diff --git a/catalog.json b/catalog.json index 8736aa8..2225d84 100644 --- a/catalog.json +++ b/catalog.json @@ -12,7 +12,8 @@ "iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/akasha/icon.svg", "downloadUrl": "https://github.com/makekosmos/extensions/releases/download/akasha-v0.1.2/akasha-0.1.2.kext", "sha256": "a180b15143ab1778fde7ee004482649521277dbbf2085ad2a0fdc77a2ad7d246", - "size": 1244170 + "size": 1244170, + "status": "legacy" }, { "id": "arcadia", @@ -24,7 +25,10 @@ "iconUrl": "https://github.com/makekosmos/extensions/releases/download/arcadia-v0.1.5/arcadia-0.1.5.icon.png", "downloadUrl": "https://github.com/makekosmos/extensions/releases/download/arcadia-v0.1.5/arcadia-0.1.5.kext", "sha256": "c890b4ad91019418ebf1e42524690237f44b68ebe2c56e1bfa9ec4fe802817e9", - "size": 1201728 + "size": 1201728, + "status": "deprecated", + "replacementId": "com.kosmos.arcadia", + "deprecationReason": "Renamed package; retained only for compatibility migration." }, { "id": "arrancador", @@ -36,7 +40,10 @@ "iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/arrancador/icon.png", "downloadUrl": "https://github.com/makekosmos/extensions/releases/download/arrancador-v0.1.5/arrancador-0.1.5.kext", "sha256": "3443f338952b5c8272b88328fa4639077e5cc078f0a44f05bd5a58cfd97bb20e", - "size": 1439639 + "size": 1439639, + "status": "deprecated", + "replacementId": "com.kosmos.arcadia", + "deprecationReason": "Renamed package; retained only for compatibility migration." }, { "id": "delphi", @@ -48,7 +55,10 @@ "iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/delphi/icon.png", "downloadUrl": "https://github.com/makekosmos/extensions/releases/download/delphi-v0.1.8/delphi-0.1.8.kext", "sha256": "9f3e24debcdaf770ddbcc3283b977d201813bcfb7968a3ec3ece1dcddd195d6c", - "size": 1601813 + "size": 1601813, + "status": "deprecated", + "replacementId": "com.kosmos.agenda", + "deprecationReason": "Renamed package; retained only for compatibility migration." }, { "id": "eden", @@ -60,7 +70,13 @@ "iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/eden/icon.png", "downloadUrl": "https://github.com/makekosmos/extensions/releases/download/eden-v0.5.4/eden-0.5.4.kext", "sha256": "6c0476e2263f0aa45ecbdb713cbf940aef72d0aa4ce81417bc9610ddb1fe7f70", - "size": 12013737 + "size": 12013737, + "status": "deprecated", + "replacementId": "com.kosmos.memoria", + "deprecationReason": "Renamed package; retained only for compatibility migration." } - ] + ], + "status": "compatibility-only", + "supportedClientMax": "legacy", + "replacementPolicy": "Existing clients may resolve replacementId; new releases belong in Store and Package Index." } diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs new file mode 100644 index 0000000..a22f3a2 --- /dev/null +++ b/scripts/validate-catalog.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function validateCatalog(catalog) { + const entries = catalog?.extensions; + if (catalog?.schemaVersion !== 1 || catalog.status !== "compatibility-only" || catalog.supportedClientMax !== "legacy") { + throw new Error("catalog must declare schemaVersion 1 and compatibility-only status"); + } + if (!Array.isArray(entries) || entries.length === 0) throw new Error("extensions must be a non-empty array"); + + const ids = new Set(); + const edges = new Map(); + for (const entry of entries) { + if (!entry || typeof entry.id !== "string" || ids.has(entry.id)) throw new Error("duplicate or invalid extension id"); + ids.add(entry.id); + if (typeof entry.name !== "string" || typeof entry.description !== "string") throw new Error(`${entry.id}: name/description required`); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(entry.version)) throw new Error(`${entry.id}: invalid semver`); + if (!/^https:\/\//.test(entry.iconUrl) || !/^https:\/\//.test(entry.downloadUrl)) throw new Error(`${entry.id}: HTTPS URLs required`); + if (!/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size <= 0) throw new Error(`${entry.id}: invalid artifact integrity`); + if (entry.status === "deprecated") { + if (typeof entry.replacementId !== "string" || !entry.replacementId || entry.replacementId === entry.id) throw new Error(`${entry.id}: deprecated entries require a distinct replacementId`); + if (typeof entry.deprecationReason !== "string" || !entry.deprecationReason.trim()) throw new Error(`${entry.id}: deprecationReason is required`); + edges.set(entry.id, entry.replacementId); + } else if (entry.status !== "legacy") { + throw new Error(`${entry.id}: compatibility entries must be legacy or deprecated`); + } + } + for (const [id, replacement] of edges) { + if (edges.has(replacement)) throw new Error(`${id}: replacement chain must terminate outside the legacy catalog`); + } + return true; +} + +async function main() { + const catalog = JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8")); + validateCatalog(catalog); + console.log(`Validated ${catalog.extensions.length} compatibility entries.`); +} + +if (path.resolve(process.argv[1] || "") === fileURLToPath(import.meta.url)) main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs new file mode 100644 index 0000000..e7e9db0 --- /dev/null +++ b/scripts/validate-catalog.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { validateCatalog } from "./validate-catalog.mjs"; + +const source = JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8")); +const copy = () => structuredClone(source); + +test("accepts frozen compatibility catalog", () => assert.equal(validateCatalog(source), true)); + +for (const [name, mutate, pattern] of [ + ["duplicate identities", (c) => c.extensions.push(structuredClone(c.extensions[0])), /duplicate/], + ["bad artifact URL", (c) => { c.extensions[0].downloadUrl = "http://example.invalid/a.kext"; }, /HTTPS/], + ["bad artifact hash", (c) => { c.extensions[0].sha256 = "bad"; }, /integrity/], + ["missing replacement metadata", (c) => { c.extensions[1].replacementId = ""; }, /replacementId/], + ["missing deprecation reason", (c) => { c.extensions[1].deprecationReason = ""; }, /deprecationReason/], + ["replacement cycle", (c) => { + c.extensions[1].replacementId = "arrancador"; + c.extensions[2].replacementId = "arcadia"; + }, /replacement chain/], + ["active entry in compatibility feed", (c) => { c.extensions[0].status = "active"; }, /legacy or deprecated/], +]) { + test(name, () => assert.throws(() => { + const c = copy(); + mutate(c); + validateCatalog(c); + }, pattern)); +}