Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
23 changes: 14 additions & 9 deletions .github/workflows/publish-package-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ on:
required: true
type: string

permissions:
contents: read

jobs:
publish:
environment: production
Expand Down Expand Up @@ -57,29 +60,31 @@ 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=$'\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
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 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=[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 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!==["com.kosmos.arcadia","com.kosmos.dictation","com.kosmos.agenda","com.kosmos.memoria","com.kosmos.focus"][index]))throw new Error("unexpected release manifests");
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 view "catalog-$CATALOG_SEQUENCE" --repo makekosmos/package-index >/dev/null 2>&1 && exit 1 || true
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."
3 changes: 3 additions & 0 deletions .github/workflows/publish-shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ on:
required: true
type: string

permissions:
contents: read

jobs:
publish:
environment: production
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Package Index quality

on:
pull_request:
paths:
- "scripts/**"
- "fixtures/**"
- "release-bom.json"
- ".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
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
run: node scripts/dry-run.mjs
- name: Actionlint
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,47 @@ 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-<sequence>` 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.

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
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 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 --test scripts/validate-catalog-input.test.mjs
node scripts/dry-run.mjs
```

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.
21 changes: 21 additions & 0 deletions fixtures/catalog-input.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
36 changes: 36 additions & 0 deletions release-bom.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
16 changes: 16 additions & 0 deletions scripts/check-workflow-contract.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
19 changes: 19 additions & 0 deletions scripts/dry-run.mjs
Original file line number Diff line number Diff line change
@@ -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.");
101 changes: 101 additions & 0 deletions scripts/validate-catalog-input.mjs
Original file line number Diff line number Diff line change
@@ -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 = publicKey?.type === "public" ? publicKey : 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;
});
Loading
Loading