diff --git a/.github/workflows/rp-catalog.yml b/.github/workflows/rp-catalog.yml index 4abd709ac1..05ceff153e 100644 --- a/.github/workflows/rp-catalog.yml +++ b/.github/workflows/rp-catalog.yml @@ -119,6 +119,7 @@ jobs: env: CURRENT_CATALOG_URL: https://jonathonrp.github.io/extensions/rp-catalog/v1/catalog.json RP_CATALOG_CONCURRENCY: "8" + SNAPSHOT_REVISION: ${{ github.run_id }} - name: Configure Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 diff --git a/RP_CATALOG.md b/RP_CATALOG.md index 90c446602a..0bfea9aa51 100644 --- a/RP_CATALOG.md +++ b/RP_CATALOG.md @@ -19,6 +19,19 @@ installable current package in `packages`. Each package binds its ID, version, schema/Wasm compatibility, immutable source repository and Git revision, archive byte size, archive SHA-256, and HTTPS download URL. +Each package declares `authority` as `upstream` or `rp` and carries the +corresponding registry revision. The catalog owns metadata and RP additions; +unchanged upstream archives remain served by Zed's official package authority. +The `integrity.authorities` object gives separate initial and final host +allowlists. `package_index` provides deterministic `id@version` lookup. + +Each publication carries a decimal, monotonic `snapshot_revision` derived from +the GitHub Actions run ID, a `snapshot_taken_at` timestamp, exact source and +installable counts, and `entries_sha256` over the deterministically sorted +`source_entries`. RP clients persist the highest accepted revision and reject +lower revisions. `revocations` and `yanks` are explicit empty arrays until +needed rather than implicit unsupported state. + An upstream source entry that has no matching package in the official service remains visible in `source_entries` and is identified in `unavailable_source_entries`; it is never presented as installable and no diff --git a/rp-catalog.schema.json b/rp-catalog.schema.json index adcda30502..484982589b 100644 --- a/rp-catalog.schema.json +++ b/rp-catalog.schema.json @@ -8,23 +8,32 @@ "channel", "label", "generated_at", + "snapshot_revision", + "snapshot_taken_at", "source", "integrity", "entry_count", + "installable_entry_count", "upstream_entry_count", "published_upstream_entry_count", + "entries_sha256", "additions", "source_entries", "unavailable_source_entries", + "revocations", + "yanks", "data", "versions", - "packages" + "packages", + "package_index" ], "properties": { "schema_version": { "const": 1 }, "channel": { "const": "rp-stable" }, "label": { "const": "RP Extensions" }, "generated_at": { "type": "string", "format": "date-time" }, + "snapshot_revision": { "type": "string", "pattern": "^[0-9]+$" }, + "snapshot_taken_at": { "type": "string", "format": "date-time" }, "source": { "type": "object", "required": [ @@ -39,12 +48,14 @@ "required": [ "catalog_digest_algorithm", "catalog_digest_url", - "allowed_archive_hosts" + "authorities" ] }, "entry_count": { "type": "integer", "minimum": 1 }, + "installable_entry_count": { "type": "integer", "minimum": 1 }, "upstream_entry_count": { "type": "integer", "minimum": 1 }, "published_upstream_entry_count": { "type": "integer", "minimum": 0 }, + "entries_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "additions": { "type": "array", "items": { @@ -54,6 +65,8 @@ }, "source_entries": { "type": "array" }, "unavailable_source_entries": { "type": "array" }, + "revocations": { "type": "array" }, + "yanks": { "type": "array" }, "data": { "type": "array" }, "versions": { "type": "object" }, "packages": { @@ -63,8 +76,10 @@ "required": [ "id", "version", + "authority", "schema_version", "wasm_api_version", + "registry_revision", "source_repository", "source_revision", "archive_url", @@ -72,7 +87,8 @@ "archive_sha256" ] } - } + }, + "package_index": { "type": "object" } }, "additionalProperties": false } diff --git a/src/generate-rp-catalog.js b/src/generate-rp-catalog.js index 27c53b52eb..f3821ed691 100644 --- a/src/generate-rp-catalog.js +++ b/src/generate-rp-catalog.js @@ -43,6 +43,10 @@ import { const upstreamRevision = requiredEnv("UPSTREAM_REVISION"); const forkRevision = requiredEnv("FORK_REVISION"); +const snapshotRevision = requiredEnv("SNAPSHOT_REVISION"); +if (!/^[0-9]+$/.test(snapshotRevision)) { + throw new Error("SNAPSHOT_REVISION must contain only decimal digits."); +} const outputRoot = process.env["RP_CATALOG_OUTPUT"] ?? "public/rp-catalog/v1"; const pigmentsArchive = process.env["PIGMENTS_ARCHIVE"] ?? "output/archive.tar.gz"; @@ -170,8 +174,12 @@ const results = await mapConcurrent(extensionIds, concurrency, async (id) => { package: { id, version: registryEntry.version, + authority: /** @type {"upstream" | "rp"} */ ( + addition ? "rp" : "upstream" + ), schema_version: metadata.schema_version ?? 0, wasm_api_version: metadata.wasm_api_version ?? null, + registry_revision: addition ? forkRevision : upstreamRevision, source_repository: source.url, source_revision: source.revision, archive_url: archiveUrl, @@ -239,11 +247,31 @@ for (const [index, id] of extensionIds.entries()) { versions[id] = result.versions; } +const sourceEntries = extensionIds.map((id) => { + const registryEntry = current[id]; + const source = registryEntry ? sources[registryEntry.submodule] : undefined; + if (!registryEntry || !source) { + throw new Error(`Missing source entry for ${id}.`); + } + return { + id, + version: registryEntry.version, + source_repository: source.url, + source_revision: source.revision, + available: !unavailableSourceEntries.some((entry) => entry.id === id), + }; +}); +const generatedAt = new Date().toISOString(); +const packageIndex = Object.fromEntries( + packages.map((record) => [`${record.id}@${record.version}`, record]), +); const catalog = { schema_version: config.schema_version, channel: config.channel, label: config.label, - generated_at: new Date().toISOString(), + generated_at: generatedAt, + snapshot_revision: snapshotRevision, + snapshot_taken_at: generatedAt, source: { fork_repository: config.fork_repository, fork_revision: forkRevision, @@ -253,40 +281,39 @@ const catalog = { integrity: { catalog_digest_algorithm: "sha256", catalog_digest_url: `${config.base_url}/catalog.json.sha256`, - allowed_archive_hosts: [ - "api.zed.dev", - config.upstream_archive_host, - "jonathonrp.github.io", - ], + authorities: { + upstream: { + initial_hosts: ["api.zed.dev"], + final_hosts: [config.upstream_archive_host], + owner: "Zed Industries", + }, + rp: { + initial_hosts: ["jonathonrp.github.io"], + final_hosts: ["jonathonrp.github.io"], + owner: "JonathonRP", + }, + }, }, - entry_count: extensionIds.length, + entry_count: sourceEntries.length, + installable_entry_count: packages.length, upstream_entry_count: Object.keys(upstream).length, published_upstream_entry_count: Object.keys(upstream).length - unavailableSourceEntries.length, + entries_sha256: sha256(`${JSON.stringify(sourceEntries)}\n`), additions: Object.entries(config.additions).map(([id, addition]) => ({ id, version: addition.version, source_repository: addition.source_repository, source_revision: addition.source_revision, })), - source_entries: extensionIds.map((id) => { - const registryEntry = current[id]; - const source = registryEntry ? sources[registryEntry.submodule] : undefined; - if (!registryEntry || !source) { - throw new Error(`Missing source entry for ${id}.`); - } - return { - id, - version: registryEntry.version, - source_repository: source.url, - source_revision: source.revision, - available: !unavailableSourceEntries.some((entry) => entry.id === id), - }; - }), + source_entries: sourceEntries, unavailable_source_entries: unavailableSourceEntries, + revocations: [], + yanks: [], data, versions, packages, + package_index: packageIndex, }; const catalogJson = `${JSON.stringify(catalog, null, 2)}\n`; diff --git a/src/lib/rp-catalog.js b/src/lib/rp-catalog.js index 740b4d3552..7ddbfe184e 100644 --- a/src/lib/rp-catalog.js +++ b/src/lib/rp-catalog.js @@ -19,8 +19,10 @@ import { createHash } from "node:crypto"; * @typedef {{ * id: string, * version: string, + * authority: "upstream" | "rp", * schema_version: number, * wasm_api_version: string | null, + * registry_revision: string, * source_repository: string, * source_revision: string, * archive_url: string, @@ -163,6 +165,23 @@ export function validatePackageRecords(packages, extensionIds) { if (new URL(record.archive_url).protocol !== "https:") { throw new Error(`Package "${record.id}" does not use HTTPS.`); } + const archiveHost = new URL(record.archive_url).hostname; + const expectedHost = + record.authority === "upstream" + ? "api.zed.dev" + : record.authority === "rp" + ? "jonathonrp.github.io" + : null; + if (!expectedHost || archiveHost !== expectedHost) { + throw new Error( + `Package "${record.id}" has an invalid ${record.authority} authority URL.`, + ); + } + if (!/^[a-f0-9]{40}$/.test(record.registry_revision)) { + throw new Error( + `Package "${record.id}" has an invalid registry revision.`, + ); + } } const missing = extensionIds.filter((id) => !seen.has(id)); diff --git a/src/lib/rp-catalog.test.js b/src/lib/rp-catalog.test.js index c8bd49853b..4834c82581 100644 --- a/src/lib/rp-catalog.test.js +++ b/src/lib/rp-catalog.test.js @@ -130,11 +130,13 @@ describe("validatePackageRecords", () => { const record = { id: "rust", version: "1.0.0", + authority: /** @type {const} */ ("upstream"), schema_version: 1, wasm_api_version: null, + registry_revision: "c".repeat(40), source_repository: "https://github.com/example/rust.git", source_revision: "a".repeat(40), - archive_url: "https://example.com/rust/1.0.0/archive.tar.gz", + archive_url: "https://api.zed.dev/extensions/rust/1.0.0/download", archive_size: 42, archive_sha256: "b".repeat(64), };