Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/rp-catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions RP_CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions rp-catalog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": {
Expand All @@ -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": {
Expand All @@ -63,16 +76,19 @@
"required": [
"id",
"version",
"authority",
"schema_version",
"wasm_api_version",
"registry_revision",
"source_repository",
"source_revision",
"archive_url",
"archive_size",
"archive_sha256"
]
}
}
},
"package_index": { "type": "object" }
},
"additionalProperties": false
}
69 changes: 48 additions & 21 deletions src/generate-rp-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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`;
Expand Down
19 changes: 19 additions & 0 deletions src/lib/rp-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 3 additions & 1 deletion src/lib/rp-catalog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down