From 212c5a30809c479738c816bb51d7eaaa09d68cc8 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:29:40 +0300 Subject: [PATCH 01/21] Add workflow permissions and pinning check --- scripts/check-workflow-contract.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scripts/check-workflow-contract.mjs diff --git a/scripts/check-workflow-contract.mjs b/scripts/check-workflow-contract.mjs new file mode 100644 index 0000000..b93cb05 --- /dev/null +++ b/scripts/check-workflow-contract.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(".github/workflows"); +const files = (await readdir(root)).filter((file) => /\.ya?ml$/.test(file)); +if (files.length === 0) throw new Error("no workflow files found"); +for (const file of files) { + const source = await readFile(path.join(root, file), "utf8"); + if (!/^permissions\s*:/m.test(source)) throw new Error(`${file}: top-level permissions are required`); + for (const [index, line] of source.split("\n").entries()) { + const match = line.match(/^\s*-?\s*uses:\s*[^@]+@([^\s#]+)/); + if (match && !/^[0-9a-f]{40}$/i.test(match[1])) throw new Error(`${file}:${index + 1}: actions must be pinned to a full commit SHA`); + } +} +console.log(`Validated ${files.length} workflow files for permissions and immutable actions.`); From 99c1be72f979a59f3299a7ee6c9b01fed5ffc383 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:29:41 +0300 Subject: [PATCH 02/21] Add package index PR quality gate --- .github/workflows/quality.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..fc701fd --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,27 @@ +name: Package Index quality + +on: + pull_request: + paths: + - "scripts/**" + - ".github/workflows/**" + - "README.md" + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: package-index-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Check workflow contract + run: node scripts/check-workflow-contract.mjs + - name: Check publication script syntax + run: node --check scripts/build-source-packages.mjs From 4fd1745d7320e1c5cec3fcbde091fab71ad6dfd9 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:29:48 +0300 Subject: [PATCH 03/21] Document package index quality checks --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index bd1fc85..6d4cfd3 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,17 @@ The workflow builds the standalone crates in `windows-latest`. It packages each committed `manifest.json`, exact worker executable, and `icon.png`, then rejects any artifact that is not the expected Windows `source` package with a valid permissions and integration contract. + +## Pull-request checks + +The secret-free quality gate validates that every workflow declares explicit +permissions and pins actions to immutable commit SHAs. It also parses the +publication script without downloading source packages or using release keys: + +```powershell +node scripts/check-workflow-contract.mjs +node --check scripts/build-source-packages.mjs +``` + +Catalog construction and fixture-based dry-run tests remain follow-up work for +issue #1; production publication is never run by pull-request CI. From 05f8b0e1906a94cf44845887ecb4360351eb7584 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:33:25 +0300 Subject: [PATCH 04/21] Declare workflow permissions at top level --- .github/workflows/publish-package-v1.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish-package-v1.yml b/.github/workflows/publish-package-v1.yml index 9be3505..0f7d433 100644 --- a/.github/workflows/publish-package-v1.yml +++ b/.github/workflows/publish-package-v1.yml @@ -20,6 +20,9 @@ on: required: true type: string +permissions: + contents: read + jobs: publish: environment: production From c12ac69e2be55d61d307c2a2adb3b66bdd70a49f Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:33:26 +0300 Subject: [PATCH 05/21] Declare workflow permissions at top level --- .github/workflows/publish-shell.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish-shell.yml b/.github/workflows/publish-shell.yml index be72a0f..e95995a 100644 --- a/.github/workflows/publish-shell.yml +++ b/.github/workflows/publish-shell.yml @@ -20,6 +20,9 @@ on: required: true type: string +permissions: + contents: read + jobs: publish: environment: production From 3143236b23b7dd44a26a2da84525bcb0d657f5c6 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:45:28 +0300 Subject: [PATCH 06/21] Add secret-free catalog input validator --- scripts/validate-catalog-input.mjs | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 scripts/validate-catalog-input.mjs diff --git a/scripts/validate-catalog-input.mjs b/scripts/validate-catalog-input.mjs new file mode 100644 index 0000000..342f941 --- /dev/null +++ b/scripts/validate-catalog-input.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import crypto from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const SHA256 = /^[0-9a-f]{64}$/i; +const ISO_UTC = /^\d{4}-\d\d-\d\dT.*Z$/; + +function version(value) { + const match = String(value ?? "").match(SEMVER); + return match ? match.slice(1, 4).map(Number) : null; +} + +function compare(a, b) { + for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] - b[i]; + return 0; +} + +function satisfies(value, range) { + if (!range) return true; + const current = version(value); + if (!current) return false; + for (const part of String(range).trim().split(/\s+/)) { + const match = part.match(/^(>=|<=|>|<|=)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/); + if (!match) return false; + const expected = version(match[2]); + const result = compare(current, expected); + const operator = match[1] || "="; + if ((operator === "=" && result !== 0) || (operator === ">" && result <= 0) || + (operator === ">=" && result < 0) || (operator === "<" && result >= 0) || + (operator === "<=" && result > 0)) return false; + } + return true; +} + +export function validateCatalog(catalog, { + previousSequence = null, + engineApiVersion = null, +} = {}) { + if (!catalog || catalog.schema_version !== 1) throw new Error("catalog schema_version must be 1"); + if (!Number.isSafeInteger(catalog.sequence) || catalog.sequence < 1) throw new Error("catalog sequence must be a positive integer"); + if (previousSequence !== null && (!Number.isSafeInteger(previousSequence) || catalog.sequence <= previousSequence)) { + throw new Error("catalog sequence must be greater than the previous sequence"); + } + if (!ISO_UTC.test(catalog.issued_at || "") || !ISO_UTC.test(catalog.expires_at || "")) { + throw new Error("catalog timestamps must be ISO UTC"); + } + const issued = Date.parse(catalog.issued_at); + const expires = Date.parse(catalog.expires_at); + if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued || expires - issued > 366 * 86400000) { + throw new Error("catalog validity window is invalid"); + } + if (!Array.isArray(catalog.packages) || catalog.packages.length === 0) throw new Error("catalog packages must be non-empty"); + const ids = new Set(); + for (const [index, entry] of catalog.packages.entries()) { + const manifest = entry?.manifest; + const prefix = `packages[${index}]`; + if (!manifest || manifest.schema_version !== 2) throw new Error(`${prefix}: Manifest v2 is required`); + if (typeof manifest.id !== "string" || !manifest.id || ids.has(manifest.id)) throw new Error(`${prefix}: duplicate or missing manifest id`); + ids.add(manifest.id); + if (!version(manifest.version)) throw new Error(`${prefix}: invalid semver`); + if (!["app", "source"].includes(manifest.kind)) throw new Error(`${prefix}: invalid package kind`); + if (manifest.kind === "app" && (typeof manifest.entrypoint !== "string" || !manifest.entrypoint.startsWith("dist/"))) { + throw new Error(`${prefix}: app entrypoint must be under dist/`); + } + const engineRange = manifest.engine_api ?? manifest.engine_api_range; + if (engineApiVersion && !satisfies(engineApiVersion, engineRange)) throw new Error(`${prefix}: Engine API range is incompatible`); + if (typeof entry.archive_url !== "string" || !/^https:\/\//.test(entry.archive_url)) throw new Error(`${prefix}: archive_url must be HTTPS`); + if (!SHA256.test(entry.sha256 || "")) throw new Error(`${prefix}: archive sha256 is invalid`); + if (!Number.isSafeInteger(entry.size) || entry.size <= 0) throw new Error(`${prefix}: archive size is invalid`); + } + return true; +} + +export function verifyEnvelope(catalogBytes, envelope, publicKey) { + if (!envelope || envelope.schema_version !== 1 || !Number.isSafeInteger(envelope.sequence)) { + throw new Error("envelope metadata is invalid"); + } + if (envelope.payload_sha256 !== crypto.createHash("sha256").update(catalogBytes).digest("hex")) { + throw new Error("envelope payload hash mismatch"); + } + const signature = Buffer.from(envelope.signature || "", "base64"); + if (signature.length !== 64) throw new Error("envelope signature is invalid"); + const key = crypto.createPublicKey(publicKey); + if (!crypto.verify(null, catalogBytes, key, signature)) throw new Error("envelope signature verification failed"); + return true; +} + +async function main() { + const fixture = path.resolve(process.argv[2] || "fixtures/catalog-input.json"); + const catalog = JSON.parse(await readFile(fixture, "utf8")); + validateCatalog(catalog); + console.log(`Validated catalog sequence ${catalog.sequence} with ${catalog.packages.length} package entries.`); +} + +if (path.resolve(process.argv[1] || "") === fileURLToPath(import.meta.url)) main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); From 6464106c3fb51abe5e2f208e09f3f50033d150dc Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:45:36 +0300 Subject: [PATCH 07/21] Add deterministic catalog fixture --- fixtures/catalog-input.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 fixtures/catalog-input.json diff --git a/fixtures/catalog-input.json b/fixtures/catalog-input.json new file mode 100644 index 0000000..37a1025 --- /dev/null +++ b/fixtures/catalog-input.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "sequence": 7, + "issued_at": "2026-08-28T00:00:00Z", + "expires_at": "2026-09-28T00:00:00Z", + "packages": [ + { + "manifest": { + "schema_version": 2, + "id": "com.kosmos.fixture", + "version": "1.2.3", + "kind": "app", + "entrypoint": "dist/index.html", + "engine_api": ">=1.0.0 <2.0.0" + }, + "archive_url": "https://github.com/makekosmos/package-index/releases/download/catalog-7/com.kosmos.fixture-1.2.3.kspkg", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1024 + } + ] +} From 8d590ac86c0777c50997f80b6c2d76d11bbe5def Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:45:44 +0300 Subject: [PATCH 08/21] Add fixture catalog signing dry-run --- scripts/dry-run.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 scripts/dry-run.mjs diff --git a/scripts/dry-run.mjs b/scripts/dry-run.mjs new file mode 100644 index 0000000..31340a3 --- /dev/null +++ b/scripts/dry-run.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import crypto from "node:crypto"; +import { validateCatalog, verifyEnvelope } from "./validate-catalog-input.mjs"; + +const catalogBytes = await readFile(new URL("../fixtures/catalog-input.json", import.meta.url)); +const catalog = JSON.parse(catalogBytes); +validateCatalog(catalog, { previousSequence: catalog.sequence - 1, engineApiVersion: "1.5.0" }); + +const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); +const signature = crypto.sign(null, catalogBytes, privateKey); +const envelope = { + schema_version: 1, + sequence: catalog.sequence, + payload_sha256: crypto.createHash("sha256").update(catalogBytes).digest("hex"), + signature: signature.toString("base64"), +}; +verifyEnvelope(catalogBytes, envelope, publicKey); +console.log("Dry-run passed with fixture catalog and ephemeral Ed25519 key; no release or production secret was used."); From 1fee33a20b0db95e8305c26736f0f2be9615ecb7 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:45:58 +0300 Subject: [PATCH 09/21] Test catalog and envelope failure paths --- scripts/validate-catalog-input.test.mjs | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 scripts/validate-catalog-input.test.mjs diff --git a/scripts/validate-catalog-input.test.mjs b/scripts/validate-catalog-input.test.mjs new file mode 100644 index 0000000..3f99ee2 --- /dev/null +++ b/scripts/validate-catalog-input.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { validateCatalog, verifyEnvelope } from "./validate-catalog-input.mjs"; + +const catalog = JSON.parse(await readFile(new URL("../fixtures/catalog-input.json", import.meta.url), "utf8")); +const bytes = Buffer.from(JSON.stringify(catalog, null, 2) + "\n"); + +function copy() { + return structuredClone(catalog); +} + +test("accepts valid fixture and bounded engine API range", () => { + assert.equal(validateCatalog(catalog, { previousSequence: 6, engineApiVersion: "1.5.0" }), true); +}); + +for (const [name, mutate, expected] of [ + ["duplicate IDs", (c) => { c.packages.push(structuredClone(c.packages[0])); }, /duplicate/], + ["invalid manifest", (c) => { c.packages[0].manifest.schema_version = 1; }, /Manifest v2/], + ["bad hash", (c) => { c.packages[0].sha256 = "bad"; }, /sha256/], + ["incompatible Engine API", (c) => {}, /Engine API/], + ["non-monotonic sequence", (c) => {}, /greater/], + ["invalid timestamps", (c) => { c.issued_at = "not-a-date"; }, /timestamps/], +]) { + test(name, () => { + const c = copy(); + if (name === "incompatible Engine API") assert.throws(() => validateCatalog(c, { engineApiVersion: "2.0.0" }), expected); + else if (name === "non-monotonic sequence") assert.throws(() => validateCatalog(c, { previousSequence: c.sequence }), expected); + else { + mutate(c); + assert.throws(() => validateCatalog(c), expected); + } + }); +} + +test("rejects a tampered envelope signature", () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519"); + const envelope = { + schema_version: 1, + sequence: catalog.sequence, + payload_sha256: crypto.createHash("sha256").update(bytes).digest("hex"), + signature: crypto.sign(null, bytes, privateKey).toString("base64"), + }; + verifyEnvelope(bytes, envelope, publicKey); + const altered = Buffer.from(bytes); + altered[altered.length - 2] ^= 1; + assert.throws(() => verifyEnvelope(altered, envelope, publicKey), /hash mismatch|verification/); +}); From 1f2b5f9afb4172b3c82e9cb032eab2ed5a730268 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:46:10 +0300 Subject: [PATCH 10/21] Run catalog fixtures and actionlint in CI --- .github/workflows/quality.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fc701fd..79a7f3d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "scripts/**" + - "fixtures/**" - ".github/workflows/**" - "README.md" push: @@ -24,4 +25,13 @@ jobs: - name: Check workflow contract run: node scripts/check-workflow-contract.mjs - name: Check publication script syntax - run: node --check scripts/build-source-packages.mjs + run: | + node --check scripts/build-source-packages.mjs + node --check scripts/validate-catalog-input.mjs + node --check scripts/dry-run.mjs + - name: Run fixture validator tests + run: node --test scripts/validate-catalog-input.test.mjs + - name: Run secret-free dry-run + run: node scripts/dry-run.mjs + - name: Actionlint + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 From 986c455d233c53b60c62a6b6f3803aee64e8796f Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:46:26 +0300 Subject: [PATCH 11/21] Document fixture validation rollback and provenance --- README.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6d4cfd3..e72ade2 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ expires_at=2026-09-01T16:00:00Z The production environment must provide only these secret names: `KOSMOS_SOURCE_REPO_TOKEN`, `KOSMOS_RELEASE_REPO_TOKEN`, and -`KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY`. The workflow never puts the private key -in arguments or logs, publishes the package archives before the -`catalog-` release, refuses existing tags, and deletes its temporary -key file on every exit path. +`KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY`. The workflow verifies source identity, +the prior sequence, package manifests, Engine API compatibility, archive +contents, hashes, and existing-release guards before creating a temporary key +file. It publishes immutable releases and deletes the key file on every exit +path. The workflow builds the standalone crates in `packages/{bigfrontend,greatfrontend,leetcode,codewars,hevy,toggl}` on @@ -41,13 +42,27 @@ Windows `source` package with a valid permissions and integration contract. ## Pull-request checks The secret-free quality gate validates that every workflow declares explicit -permissions and pins actions to immutable commit SHAs. It also parses the -publication script without downloading source packages or using release keys: +permissions and pins actions to immutable commit SHAs. It runs fixture-based +schema, duplicate-ID, manifest, Engine API, hash, timestamp, sequence, and +signature/envelope tampering tests, plus a dry-run with an ephemeral Ed25519 key. +No GitHub token, release, or production signing secret is used: ```powershell node scripts/check-workflow-contract.mjs -node --check scripts/build-source-packages.mjs +node --test scripts/validate-catalog-input.test.mjs +node scripts/dry-run.mjs ``` -Catalog construction and fixture-based dry-run tests remain follow-up work for -issue #1; production publication is never run by pull-request CI. +The fixture validator is intentionally separate from production publication: +the PR contract proves deterministic validation and signing-input handling, +while the production workflow remains the only path allowed to use release +credentials. + +## Rollback and provenance + +Releases are append-only: never overwrite a `catalog-N` tag or reuse a +sequence. To roll back, point consumers at the last known-good immutable +catalog release and investigate the failed release; do not delete or replace +the tag. Verify provenance by checking the release asset SHA-256, the embedded +Manifest v2 identity/version, the source commit recorded by the operator, and +the detached Ed25519 signature against the published key allowlist. From e2536b78e8ecabaa8565203b11aa0adce405758e Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:52:23 +0300 Subject: [PATCH 12/21] Accept generated public keys in fixture verifier --- scripts/validate-catalog-input.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-catalog-input.mjs b/scripts/validate-catalog-input.mjs index 342f941..96e1827 100644 --- a/scripts/validate-catalog-input.mjs +++ b/scripts/validate-catalog-input.mjs @@ -83,7 +83,7 @@ export function verifyEnvelope(catalogBytes, envelope, publicKey) { } const signature = Buffer.from(envelope.signature || "", "base64"); if (signature.length !== 64) throw new Error("envelope signature is invalid"); - const key = crypto.createPublicKey(publicKey); + const key = publicKey?.type === "public" ? publicKey : crypto.createPublicKey(publicKey); if (!crypto.verify(null, catalogBytes, key, signature)) throw new Error("envelope signature verification failed"); return true; } From dd9450eab2203894f2e17d2a713ece3dd13996b6 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:53:18 +0300 Subject: [PATCH 13/21] Make immutable release guard shellcheck-clean --- .github/workflows/publish-package-v1.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-package-v1.yml b/.github/workflows/publish-package-v1.yml index 0f7d433..103df11 100644 --- a/.github/workflows/publish-package-v1.yml +++ b/.github/workflows/publish-package-v1.yml @@ -84,5 +84,8 @@ jobs: rm -f "$CATALOG_INPUT" node cortex/desktop/scripts/package-sign.mjs --input "$CATALOG" --output "$SIGNATURES" --signer "kosmos-release-2026=$KEY_PATH" node cortex/desktop/scripts/package-envelope.mjs --catalog "$CATALOG" --signatures "$SIGNATURES" --output "$ENVELOPE" - gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1 && exit 1 || true + if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then + echo "catalog-$CATALOG_SEQUENCE already exists; immutable releases cannot be replaced" >&2 + exit 1 + fi gh release create "catalog-$CATALOG_SEQUENCE" "$CATALOG" "$ENVELOPE" "$SIGNATURES" out/*.kspkg --repo makekosmos/package-index --title "Package catalog $CATALOG_SEQUENCE" --notes "Six provider integrations, Agenda, Memoria, Ordo, Arcadia, and Dictation replace retired Eden and Delphi." From 6cb7aa3ed2a676e9d9ec36607b3173f808073878 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:55:24 +0300 Subject: [PATCH 14/21] Add reviewed first-party release BOM --- release-bom.json | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 release-bom.json diff --git a/release-bom.json b/release-bom.json new file mode 100644 index 0000000..07a284a --- /dev/null +++ b/release-bom.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "policy": "reviewed-release-bom", + "packages": [ + { + "id": "com.kosmos.arcadia", + "repository": "makekosmos/arcadia", + "tag": "v0.1.8", + "archive_name": "com.kosmos.arcadia-0.1.8.kspkg" + }, + { + "id": "com.kosmos.dictation", + "repository": "makekosmos/dictation", + "tag": "v0.2.2", + "archive_name": "dictation-0.2.2.kspkg" + }, + { + "id": "com.kosmos.agenda", + "repository": "makekosmos/agenda", + "tag": "v0.2.4", + "archive_name": "agenda-0.2.4.kspkg" + }, + { + "id": "com.kosmos.memoria", + "repository": "makekosmos/memoria", + "tag": "v0.6.3", + "archive_name": "memoria-0.6.3.kspkg" + }, + { + "id": "com.kosmos.ordo", + "repository": "makekosmos/ordo", + "tag": "v0.1.3", + "archive_name": "ordo-0.1.3.kspkg" + } + ] +} From 41dd8767b26979d2d4e45652a6d99418c80d91b0 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:55:34 +0300 Subject: [PATCH 15/21] Validate reviewed release BOM inputs --- scripts/validate-release-bom.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scripts/validate-release-bom.mjs diff --git a/scripts/validate-release-bom.mjs b/scripts/validate-release-bom.mjs new file mode 100644 index 0000000..2ab0f9b --- /dev/null +++ b/scripts/validate-release-bom.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; + +const bom = JSON.parse(await readFile(new URL("../release-bom.json", import.meta.url), "utf8")); +if (bom.schema_version !== 1 || bom.policy !== "reviewed-release-bom" || !Array.isArray(bom.packages) || bom.packages.length === 0) { + throw new Error("release BOM schema or policy is invalid"); +} +const ids = new Set(); +for (const item of bom.packages) { + if (!item || typeof item.id !== "string" || ids.has(item.id)) throw new Error("release BOM contains duplicate or invalid IDs"); + ids.add(item.id); + if (!/^makekosmos\/[a-z0-9-]+$/.test(item.repository)) throw new Error(`${item.id}: repository must be an explicit makekosmos repo`); + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(item.tag)) throw new Error(`${item.id}: release tag must be immutable semver`); + if (typeof item.archive_name !== "string" || item.archive_name.includes("/") || item.archive_name.includes("\\") || !item.archive_name.endsWith(".kspkg")) { + throw new Error(`${item.id}: archive name must be a flat .kspkg file`); + } +} +console.log(`Validated reviewed release BOM with ${bom.packages.length} packages.`); From a2754447fff05126d8ba04b0ae44d3cd04c9dd5c Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:56:03 +0300 Subject: [PATCH 16/21] Drive first-party package publication from reviewed BOM --- .github/workflows/publish-package-v1.yml | 33 ++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-package-v1.yml b/.github/workflows/publish-package-v1.yml index 103df11..5ad976f 100644 --- a/.github/workflows/publish-package-v1.yml +++ b/.github/workflows/publish-package-v1.yml @@ -60,11 +60,34 @@ jobs: [[ "$previous_sequence" =~ ^[1-9][0-9]*$ ]] mkdir -p out/previous gh release download "catalog-$previous_sequence" --repo makekosmos/package-index --pattern catalog.json --dir out/previous - gh release download v0.1.8 --repo makekosmos/arcadia --pattern '*.kspkg' --dir out - gh release download v0.2.2 --repo makekosmos/dictation --pattern '*.kspkg' --dir out - gh release download v0.2.4 --repo makekosmos/agenda --pattern '*.kspkg' --dir out - gh release download v0.6.3 --repo makekosmos/memoria --pattern '*.kspkg' --dir out - gh release download v0.1.3 --repo makekosmos/ordo --pattern '*.kspkg' --dir out + node scripts/validate-release-bom.mjs + while IFS= if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then + echo "catalog-$CATALOG_SEQUENCE already exists; immutable releases cannot be replaced" >&2 + exit 1 + fi + KEY_PATH="$RUNNER_TEMP/kosmos-release-private.pem"; trap 'rm -f -- "$KEY_PATH"' EXIT + KEY_PATH="$KEY_PATH" node -e 'const fs=require("node:fs");fs.writeFileSync(process.env.KEY_PATH,process.env.KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY,{mode:0o600})' + unset KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY + export CATALOG="$GITHUB_WORKSPACE/out/catalog.json" CATALOG_INPUT="$GITHUB_WORKSPACE/out/catalog.input.json" SIGNATURES="$GITHUB_WORKSPACE/out/catalog.signatures.json" ENVELOPE="$GITHUB_WORKSPACE/out/catalog.envelope.json" + node --input-type=module - <<'NODE' + const crypto=await import("node:crypto"),fs=await import("node:fs"),path=await import("node:path"),{pathToFileURL}=await import("node:url"),out=path.join(process.env.GITHUB_WORKSPACE,"out"),{readZip}=await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE,"cortex/desktop/scripts/zip-utils.mjs")).href); + const prior=JSON.parse(fs.readFileSync(path.join(out,"previous","catalog.json"))),bom=JSON.parse(fs.readFileSync(path.join(process.env.GITHUB_WORKSPACE,"release-bom.json"))),sequence=Number(process.env.CATALOG_SEQUENCE),replaced=new Set(["com.kosmos.eden","com.kosmos.delphi","com.kosmos.arcadia","com.kosmos.dictation","com.kosmos.agenda","com.kosmos.memoria","com.kosmos.focus","com.kosmos.bigfrontend","com.kosmos.greatfrontend","com.kosmos.leetcode","com.kosmos.codewars","com.kosmos.hevy","com.kosmos.toggl"]); + const packageFrom=(file)=>{const archive=path.join(out,file),manifest=JSON.parse(readZip(archive).find(e=>e.name==="manifest.json"&&!e.isDir).data.toString("utf8")),bytes=fs.readFileSync(archive);if(manifest.schema_version!==2||manifest.kind!=="app"||manifest.entrypoint!=="dist/index.html"||manifest.icon!=="icon.png")throw new Error(`invalid package ${file}`);return {manifest,archive_url:`https://github.com/makekosmos/package-index/releases/download/catalog-${process.env.CATALOG_SEQUENCE}/${file}`,sha256:crypto.createHash("sha256").update(bytes).digest("hex"),size:bytes.length};}; + const apps=bom.packages.map((item)=>packageFrom(item.archive_name)); + const sourceCatalog=JSON.parse(fs.readFileSync(path.join(out,"source-packages.json"))),sources=sourceCatalog.packages; + if(sourceCatalog.schema_version!==1||sources.length!==6||sources.some((entry)=>entry.manifest?.kind!=="source"||entry.manifest?.version!=="0.1.0"))throw new Error("unexpected source package catalog"); + if(apps.some((app,index)=>app.manifest.id!==bom.packages[index].id))throw new Error("unexpected release manifests"); + const issued=Date.parse(process.env.CATALOG_ISSUED_AT),expires=Date.parse(process.env.CATALOG_EXPIRES_AT);if(!Number.isInteger(sequence)||sequence<=1||!Number.isFinite(issued)||!Number.isFinite(expires)||expires<=issued||expires-issued>366*86400000)throw new Error("invalid catalog metadata");if(prior.sequence!==sequence-1)throw new Error("prior catalog sequence mismatch"); + fs.writeFileSync(process.env.CATALOG_INPUT,JSON.stringify({schema_version:1,sequence,issued_at:process.env.CATALOG_ISSUED_AT,expires_at:process.env.CATALOG_EXPIRES_AT,packages:[...prior.packages.filter(p=>!replaced.has(p.manifest.id)),...apps,...sources]},null,2)); + NODE + node cortex/desktop/scripts/package-catalog.mjs --input "$CATALOG_INPUT" --output "$CATALOG" + rm -f "$CATALOG_INPUT" + node cortex/desktop/scripts/package-sign.mjs --input "$CATALOG" --output "$SIGNATURES" --signer "kosmos-release-2026=$KEY_PATH" + node cortex/desktop/scripts/package-envelope.mjs --catalog "$CATALOG" --signatures "$SIGNATURES" --output "$ENVELOPE" + gh release create "catalog-$CATALOG_SEQUENCE" "$CATALOG" "$ENVELOPE" "$SIGNATURES" out/*.kspkg --repo makekosmos/package-index --title "Package catalog $CATALOG_SEQUENCE" --notes "Six provider integrations, Agenda, Memoria, Ordo, Arcadia, and Dictation replace retired Eden and Delphi." +\\t' read -r repository tag archive; do + gh release download "$tag" --repo "$repository" --pattern "$archive" --dir out + done < <(node --input-type=module -e 'import fs from "node:fs"; for (const item of JSON.parse(fs.readFileSync("release-bom.json")).packages) console.log([item.repository, item.tag, item.archive_name].join("\\t"))') KEY_PATH="$RUNNER_TEMP/kosmos-release-private.pem"; trap 'rm -f -- "$KEY_PATH"' EXIT KEY_PATH="$KEY_PATH" node -e 'const fs=require("node:fs");fs.writeFileSync(process.env.KEY_PATH,process.env.KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY,{mode:0o600})' unset KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY From 3cb3a82a08c6674b32d2490d535c34df902c576f Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:56:52 +0300 Subject: [PATCH 17/21] Clean BOM-driven publication workflow --- .github/workflows/publish-package-v1.yml | 33 ++++-------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/.github/workflows/publish-package-v1.yml b/.github/workflows/publish-package-v1.yml index 5ad976f..f4681fe 100644 --- a/.github/workflows/publish-package-v1.yml +++ b/.github/workflows/publish-package-v1.yml @@ -61,7 +61,11 @@ jobs: mkdir -p out/previous gh release download "catalog-$previous_sequence" --repo makekosmos/package-index --pattern catalog.json --dir out/previous node scripts/validate-release-bom.mjs - while IFS= if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then + while IFS=$'\t' read -r repository tag archive; do + gh release download "$tag" --repo "$repository" --pattern "$archive" --dir out + done < <(node --input-type=module -e 'import fs from "node:fs"; for (const item of JSON.parse(fs.readFileSync("release-bom.json")).packages) console.log([item.repository, item.tag, item.archive_name].join("\\t")) +') + if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then echo "catalog-$CATALOG_SEQUENCE already exists; immutable releases cannot be replaced" >&2 exit 1 fi @@ -85,30 +89,3 @@ jobs: node cortex/desktop/scripts/package-sign.mjs --input "$CATALOG" --output "$SIGNATURES" --signer "kosmos-release-2026=$KEY_PATH" node cortex/desktop/scripts/package-envelope.mjs --catalog "$CATALOG" --signatures "$SIGNATURES" --output "$ENVELOPE" gh release create "catalog-$CATALOG_SEQUENCE" "$CATALOG" "$ENVELOPE" "$SIGNATURES" out/*.kspkg --repo makekosmos/package-index --title "Package catalog $CATALOG_SEQUENCE" --notes "Six provider integrations, Agenda, Memoria, Ordo, Arcadia, and Dictation replace retired Eden and Delphi." -\\t' read -r repository tag archive; do - gh release download "$tag" --repo "$repository" --pattern "$archive" --dir out - done < <(node --input-type=module -e 'import fs from "node:fs"; for (const item of JSON.parse(fs.readFileSync("release-bom.json")).packages) console.log([item.repository, item.tag, item.archive_name].join("\\t"))') - KEY_PATH="$RUNNER_TEMP/kosmos-release-private.pem"; trap 'rm -f -- "$KEY_PATH"' EXIT - KEY_PATH="$KEY_PATH" node -e 'const fs=require("node:fs");fs.writeFileSync(process.env.KEY_PATH,process.env.KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY,{mode:0o600})' - unset KOSMOS_PACKAGE_RELEASE_PRIVATE_KEY - export CATALOG="$GITHUB_WORKSPACE/out/catalog.json" CATALOG_INPUT="$GITHUB_WORKSPACE/out/catalog.input.json" SIGNATURES="$GITHUB_WORKSPACE/out/catalog.signatures.json" ENVELOPE="$GITHUB_WORKSPACE/out/catalog.envelope.json" - node --input-type=module - <<'NODE' - const crypto=await import("node:crypto"),fs=await import("node:fs"),path=await import("node:path"),{pathToFileURL}=await import("node:url"),out=path.join(process.env.GITHUB_WORKSPACE,"out"),{readZip}=await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE,"cortex/desktop/scripts/zip-utils.mjs")).href); - const prior=JSON.parse(fs.readFileSync(path.join(out,"previous","catalog.json"))),sequence=Number(process.env.CATALOG_SEQUENCE),replaced=new Set(["com.kosmos.eden","com.kosmos.delphi","com.kosmos.arcadia","com.kosmos.dictation","com.kosmos.agenda","com.kosmos.memoria","com.kosmos.focus","com.kosmos.bigfrontend","com.kosmos.greatfrontend","com.kosmos.leetcode","com.kosmos.codewars","com.kosmos.hevy","com.kosmos.toggl"]); - const packageFrom=(file)=>{const archive=path.join(out,file),manifest=JSON.parse(readZip(archive).find(e=>e.name==="manifest.json"&&!e.isDir).data.toString("utf8")),bytes=fs.readFileSync(archive);if(manifest.schema_version!==2||manifest.kind!=="app"||manifest.entrypoint!=="dist/index.html"||manifest.icon!=="icon.png")throw new Error(`invalid package ${file}`);return {manifest,archive_url:`https://github.com/makekosmos/package-index/releases/download/catalog-${process.env.CATALOG_SEQUENCE}/${file}`,sha256:crypto.createHash("sha256").update(bytes).digest("hex"),size:bytes.length};}; - const apps=[packageFrom("com.kosmos.arcadia-0.1.8.kspkg"),packageFrom("dictation-0.2.2.kspkg"),packageFrom("agenda-0.2.4.kspkg"),packageFrom("memoria-0.6.3.kspkg"),packageFrom("ordo-0.1.3.kspkg")]; - const sourceCatalog=JSON.parse(fs.readFileSync(path.join(out,"source-packages.json"))),sources=sourceCatalog.packages; - if(sourceCatalog.schema_version!==1||sources.length!==6||sources.some((entry)=>entry.manifest?.kind!=="source"||entry.manifest?.version!=="0.1.0"))throw new Error("unexpected source package catalog"); - if(apps.some((app,index)=>app.manifest.id!==["com.kosmos.arcadia","com.kosmos.dictation","com.kosmos.agenda","com.kosmos.memoria","com.kosmos.focus"][index]))throw new Error("unexpected release manifests"); - const issued=Date.parse(process.env.CATALOG_ISSUED_AT),expires=Date.parse(process.env.CATALOG_EXPIRES_AT);if(!Number.isInteger(sequence)||sequence<=1||!Number.isFinite(issued)||!Number.isFinite(expires)||expires<=issued||expires-issued>366*86400000)throw new Error("invalid catalog metadata");if(prior.sequence!==sequence-1)throw new Error("prior catalog sequence mismatch"); - fs.writeFileSync(process.env.CATALOG_INPUT,JSON.stringify({schema_version:1,sequence,issued_at:process.env.CATALOG_ISSUED_AT,expires_at:process.env.CATALOG_EXPIRES_AT,packages:[...prior.packages.filter(p=>!replaced.has(p.manifest.id)),...apps,...sources]},null,2)); - NODE - node cortex/desktop/scripts/package-catalog.mjs --input "$CATALOG_INPUT" --output "$CATALOG" - rm -f "$CATALOG_INPUT" - node cortex/desktop/scripts/package-sign.mjs --input "$CATALOG" --output "$SIGNATURES" --signer "kosmos-release-2026=$KEY_PATH" - node cortex/desktop/scripts/package-envelope.mjs --catalog "$CATALOG" --signatures "$SIGNATURES" --output "$ENVELOPE" - if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then - echo "catalog-$CATALOG_SEQUENCE already exists; immutable releases cannot be replaced" >&2 - exit 1 - fi - gh release create "catalog-$CATALOG_SEQUENCE" "$CATALOG" "$ENVELOPE" "$SIGNATURES" out/*.kspkg --repo makekosmos/package-index --title "Package catalog $CATALOG_SEQUENCE" --notes "Six provider integrations, Agenda, Memoria, Ordo, Arcadia, and Dictation replace retired Eden and Delphi." From 213816f884a508e3bbf3e9c1cf162fa79e3b0b42 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:57:12 +0300 Subject: [PATCH 18/21] Fix BOM download delimiter in publication shell From 9f63399ea584fe72d345b36bacd831c347ff0413 Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:57:24 +0300 Subject: [PATCH 19/21] Fix BOM download delimiter in publication shell --- .github/workflows/publish-package-v1.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/publish-package-v1.yml b/.github/workflows/publish-package-v1.yml index f4681fe..28dda19 100644 --- a/.github/workflows/publish-package-v1.yml +++ b/.github/workflows/publish-package-v1.yml @@ -63,8 +63,7 @@ jobs: node scripts/validate-release-bom.mjs while IFS=$'\t' read -r repository tag archive; do gh release download "$tag" --repo "$repository" --pattern "$archive" --dir out - done < <(node --input-type=module -e 'import fs from "node:fs"; for (const item of JSON.parse(fs.readFileSync("release-bom.json")).packages) console.log([item.repository, item.tag, item.archive_name].join("\\t")) -') + done < <(node --input-type=module -e 'import fs from "node:fs"; for (const item of JSON.parse(fs.readFileSync("release-bom.json")).packages) console.log([item.repository, item.tag, item.archive_name].join("\t"))') if gh release view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1; then echo "catalog-$CATALOG_SEQUENCE already exists; immutable releases cannot be replaced" >&2 exit 1 From 7a1a1e33148f1570ecdb554b65c9686e5cbe49ca Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:57:36 +0300 Subject: [PATCH 20/21] Validate reviewed release BOM in CI --- .github/workflows/quality.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 79a7f3d..0488010 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -5,6 +5,7 @@ on: paths: - "scripts/**" - "fixtures/**" + - "release-bom.json" - ".github/workflows/**" - "README.md" push: @@ -29,6 +30,9 @@ jobs: node --check scripts/build-source-packages.mjs node --check scripts/validate-catalog-input.mjs node --check scripts/dry-run.mjs + node --check scripts/validate-release-bom.mjs + - name: Validate reviewed release BOM + run: node scripts/validate-release-bom.mjs - name: Run fixture validator tests run: node --test scripts/validate-catalog-input.test.mjs - name: Run secret-free dry-run From 60b19bf8247d46cf6821ec6d2bc20b66bd7dcc8d Mon Sep 17 00:00:00 2001 From: Kirill Smirnov <135383551+ksanrse@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:57:44 +0300 Subject: [PATCH 21/21] Document reviewed release BOM source of truth --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index e72ade2..a42b445 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ contents, hashes, and existing-release guards before creating a temporary key file. It publishes immutable releases and deletes the key file on every exit path. +First-party application versions are maintained in the reviewed +`release-bom.json`; publication reads that file rather than workflow source +edits and validates each repository/tag/archive tuple before any signing key is +materialized. + The workflow builds the standalone crates in `packages/{bigfrontend,greatfrontend,leetcode,codewars,hevy,toggl}` on `windows-latest`. It packages each committed `manifest.json`, exact worker