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
5 changes: 3 additions & 2 deletions BOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ the PR reviewer decides which screenshots the plugin needs under REVIEW.md.

npm package files come from one cached tarball per version, verified against
npm's SHA-512 before reading. No package code runs, and files are read to stdout
without extracting paths or links onto disk. Published asset URLs and the
provenance-pinned repository source of author overviews are unchanged.
without extracting paths or links onto disk. npm manifests and overviews come
from that tarball; GitHub files come from the pinned commit and plugin directory.
Published asset URLs still use jsDelivr for npm and raw GitHub URLs for Git.

## Tokens and repository setup

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ HTTPS `media`). The bot writes artifact pins and review dates. The published
index combines records with metadata from their pinned manifests. See
[plugin metadata](#plugin-metadata) for fields and override precedence.

Authors must keep `OVERVIEW.md` beside `paseo-plugin.json` in the repository at the
pinned source commit. Git monorepos use `artifact.pluginPath`; npm monorepos use
the pinned package's `repository.directory` and proven `repository.commit`.
The author owns this overview.
Authors must ship `OVERVIEW.md` beside `paseo-plugin.json` in the submitted artifact.
For npm, include both files at the published package root. The registry reads them
from the verified tarball at the pinned version; repository metadata and provenance
are not needed to read package files. For GitHub, include both files at the pinned
commit, under `artifact.pluginPath` for a monorepo. The author owns this overview.

Approved imports can temporarily use `plugins/<owner>/<slug>.md` in the registry.
An unchanged imported artifact keeps this exception while its stopgap exists.
Expand Down
7 changes: 4 additions & 3 deletions REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ overview is read inside Paseo, where the install command already sits at the top
page, by someone deciding whether to install.

`OVERVIEW.md` next to `paseo-plugin.json` is required. A submission or a bump whose
repository has no `OVERVIEW.md` at the pinned commit fails validation and gets changes
requested naming the file. Records imported from paseo.cafe are the exception: they carry
one written at import at `plugins/<owner>/<slug>.md`, ending with the line
pinned artifact has no `OVERVIEW.md` fails validation and gets changes requested
naming the file. npm reads the verified tarball at the pinned version; GitHub reads
the plugin directory at the pinned commit. Records imported from paseo.cafe are
the exception: they carry one written at import at `plugins/<owner>/<slug>.md`, ending with the line
`*This plugin entry was imported from [paseo.cafe](https://paseo.cafe/plugins/<slug>).*`.
The author may replace that file by pull request, and the author's own `OVERVIEW.md` takes
over on the first bump, which removes the registry copy.
Expand Down
30 changes: 30 additions & 0 deletions scripts/lib/artifact-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readOptional, withGitArtifact } from "./git-artifact.ts";
import { type NpmClient, resolveVersion } from "./npm.ts";
import type { PluginRecord } from "./record.ts";

export interface ArtifactFiles {
manifest: string | null;
overview: string | null;
}

/** Read plugin files from the verified install pin, independently of repository metadata. */
export async function readArtifactFiles(
client: NpmClient,
artifact: PluginRecord["artifact"],
): Promise<ArtifactFiles> {
if (artifact.kind === "git") {
return withGitArtifact({ artifact }, (directory) => ({
manifest: readOptional(directory, "paseo-plugin.json"),
overview: readOptional(directory, "OVERVIEW.md"),
}));
}
const doc = resolveVersion(await client.packument(artifact.package), artifact.version);
if (doc.dist.integrity !== artifact.integrity || doc.dist.tarball !== artifact.resolved) {
throw new Error(`${artifact.package}@${artifact.version} integrity on npm differs from the pinned record`);
}
const [manifest, overview] = await Promise.all([
client.file(artifact.package, artifact.version, "paseo-plugin.json"),
client.file(artifact.package, artifact.version, "OVERVIEW.md"),
]);
return { manifest, overview };
}
2 changes: 1 addition & 1 deletion scripts/lib/bump-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function commitBumpForReview(input: {
git(["add", recordPath(next.id, directory)], options);
const drop = author !== null && existsSync(overviewPath);
const note = author === null
? "This version has no OVERVIEW.md. The registry requires one to update a listing; the bump cannot merge until the repository adds it.\n\n"
? "This version has no OVERVIEW.md. The registry requires one to update a listing; the bump cannot merge until a release includes it in the submitted artifact.\n\n"
: drop
? "This version ships OVERVIEW.md, so the registry's copy is removed in this bump.\n\n"
: "";
Expand Down
13 changes: 1 addition & 12 deletions scripts/lib/git-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,15 @@ import { run } from "./shell.ts";
import type { PluginRecord } from "./record.ts";
import { parseArtifact } from "./record.ts";
import { deriveId } from "./id.ts";
import type { RepositorySource } from "./repository.ts";
import { parseSubmissionSource } from "./submission-source.ts";
import { isoDate } from "./dates.ts";

export function withGitArtifact<T>(record: PluginRecord, consume: (directory: string) => T): T {
export function withGitArtifact<T>(record: Pick<PluginRecord, "artifact">, consume: (directory: string) => T): T {
const artifact = record.artifact;
if (artifact.kind !== "git") throw new Error("Expected git artifact");
return withCheckout(artifact, consume);
}

/** Read a source repository at an exact reviewed commit, without running its code. */
export function withRepositoryCommit<T>(
source: RepositorySource,
commit: string,
consume: (directory: string) => T,
): T {
if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error("Expected a full repository commit");
return withCheckout({ remote: source.url, commit, pluginPath: source.directory }, consume);
}

function withCheckout<T>(
artifact: { remote: string; commit: string; pluginPath?: string; tag?: string },
consume: (directory: string) => T,
Expand Down
2 changes: 1 addition & 1 deletion scripts/lib/listing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const record: PluginRecord = {
test("publication preserves author, overview, and review dates", async () => {
const client: NpmClient = {
async packument() { return { name: doc.name, "dist-tags": { latest: doc.version }, versions: { [doc.version]: doc }, time: {} }; },
async file() { return '{"name":"Dracula"}'; },
async file(_name, _version, path) { return path === "paseo-plugin.json" ? '{"name":"Dracula"}' : null; },
async provenance() { return null; },
async tarball() { throw new Error("Not needed"); },
};
Expand Down
8 changes: 5 additions & 3 deletions scripts/lib/listing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readAuthorOverview, requireOverview } from "./overview.ts";
import { readArtifactFiles } from "./artifact-files.ts";
import { validateOverview, requireOverview } from "./overview.ts";
import type { Category } from "./categories.ts";
import { authorOf, type NpmClient, resolveVersion } from "./npm.ts";
import { resolveMetadata } from "./metadata.ts";
Expand Down Expand Up @@ -42,8 +43,9 @@ export async function resolvePlugin(
record: PluginRecord,
overview: string | null = null,
): Promise<PublishedPluginDetail> {
const readme = requireOverview(await readAuthorOverview(client, record), overview, record.id);
const metadata = await resolveMetadata(client, record);
const files = await readArtifactFiles(client, record.artifact);
const readme = requireOverview(validateOverview(files.overview, `${record.id}/OVERVIEW.md`), overview, record.id);
const metadata = resolveMetadata(files, record);
const artifact = record.artifact;
const doc = artifact.kind === "npm"
? resolveVersion(await client.packument(artifact.package), artifact.version)
Expand Down
28 changes: 27 additions & 1 deletion scripts/lib/manifest-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { readArtifactFiles } from "./artifact-files.ts";
import { resolvePlugin } from "./listing.ts";
import { createNpmClient } from "./npm.ts";
import type { PluginRecord } from "./record.ts";
Expand All @@ -25,6 +26,10 @@ function fixture(t: TestContext, kind: "npm" | "git", manifest: unknown, listing
const remote = "https://github.com/acme/manifest.git";
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
git("init", "-q");
if (kind === "npm") {
writeFileSync(join(plugin, "paseo-plugin.json"), '{"name":"Repository name"}');
writeFileSync(join(plugin, "OVERVIEW.md"), "Repository overview.");
}
git("add", ".");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "published");
const commit = git("rev-parse", "HEAD");
Expand All @@ -41,6 +46,7 @@ function fixture(t: TestContext, kind: "npm" | "git", manifest: unknown, listing
if (previous[i] === undefined) delete process.env[key]; else process.env[key] = previous[i];
}));
writeFileSync(join(plugin, "paseo-plugin.json"), JSON.stringify(manifest));
writeFileSync(join(plugin, "OVERVIEW.md"), "A theme for focused work.");
const archive = execFileSync("tar", ["-czf", "-", "-C", root, pluginPath]);
const doc = { name: "@acme/example", version: "1.0.0", description: "Package description", dist: {
tarball: "https://registry.npmjs.org/example/-/example-1.0.0.tgz",
Expand All @@ -66,7 +72,7 @@ function fixture(t: TestContext, kind: "npm" | "git", manifest: unknown, listing
: `https://github.com/acme/manifest/raw/${commit}/${pluginPath}/`;
const detail = () => resolvePlugin(client, record, "Imported overview");
const validate = () => validateArtifact(client, record, { previous: record, registryOverview: "Imported overview" });
return { record, detail, validate, base };
return { record, client, detail, validate, base };
}

for (const kind of ["npm", "git"] as const) {
Expand All @@ -76,6 +82,7 @@ for (const kind of ["npm", "git"] as const) {
media: ["assets/demo.mp4", "assets/screen shot.PNG"], futureField: true,
});
const detail = await f.detail();
assert.equal(detail.readme, "A theme for focused work.");
assert.equal(detail.name, "Author name");
assert.equal(detail.icon, `${f.base}assets/icon.png`);
assert.deepEqual(detail.media, [`${f.base}assets/demo.mp4`, `${f.base}assets/screen%20shot.PNG`]);
Expand Down Expand Up @@ -142,3 +149,22 @@ for (const kind of ["npm", "git"] as const) {
});
}
}

test("npm file reads need only the artifact pin, without repository metadata or provenance", async (t) => {
const { client, record } = fixture(t, "npm", { name: "Package name" });
const files = await readArtifactFiles(client, record.artifact);
assert.deepEqual(files, {
manifest: '{"name":"Package name"}',
overview: "A theme for focused work.",
});
});

for (const field of ["integrity", "resolved"] as const) {
test(`npm rejects a changed ${field} pin before returning package files`, async (t) => {
const { client, record } = fixture(t, "npm", { name: "Package name" });
assert.equal(record.artifact.kind, "npm");
await assert.rejects(readArtifactFiles(client, {
...record.artifact, [field]: "changed",
}), /differs from the pinned record/);
});
}
22 changes: 6 additions & 16 deletions scripts/lib/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { readOptional, withGitArtifact } from "./git-artifact.ts";
import type { ArtifactFiles } from "./artifact-files.ts";
import { mediaKind } from "./media.ts";
import { type NpmClient, resolveVersion } from "./npm.ts";
import { AuthorError } from "./problems.ts";
import type { PluginRecord } from "./record.ts";

Expand All @@ -14,21 +13,12 @@ export interface PluginMetadata {
/** Read metadata at the artifact pin and apply the registry's per-field overrides.
* Categories do not determine a plugin's type or its content requirements.
*/
export async function resolveMetadata(client: NpmClient, record: PluginRecord): Promise<PluginMetadata> {
export function resolveMetadata(files: ArtifactFiles, record: PluginRecord): PluginMetadata {
const artifact = record.artifact;
if (artifact.kind === "git") {
return withGitArtifact(record, (directory) => {
const base = `${artifact.remote.replace(/\.git$/, "")}/raw/${artifact.commit}/${artifact.pluginPath ? `${encodePath(artifact.pluginPath)}/` : ""}`;
return readMetadata(readOptional(directory, "paseo-plugin.json"), record, base);
});
}
const doc = resolveVersion(await client.packument(artifact.package), artifact.version);
if (doc.dist.integrity !== artifact.integrity || doc.dist.tarball !== artifact.resolved) {
throw new Error(`${artifact.package}@${artifact.version} integrity on npm differs from the pinned record`);
}
const manifest = await client.file(artifact.package, artifact.version, "paseo-plugin.json");
const base = `https://cdn.jsdelivr.net/npm/${artifact.package}@${artifact.version}/`;
return readMetadata(manifest, record, base);
const base = artifact.kind === "git"
? `${artifact.remote.replace(/\.git$/, "")}/raw/${artifact.commit}/${artifact.pluginPath ? `${encodePath(artifact.pluginPath)}/` : ""}`
: `https://cdn.jsdelivr.net/npm/${artifact.package}@${artifact.version}/`;
return readMetadata(files.manifest, record, base);
}

function readMetadata(text: string | null, record: PluginRecord, base: string): PluginMetadata {
Expand Down
29 changes: 20 additions & 9 deletions scripts/lib/overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { run } from "./shell.ts";
import { validateArtifact } from "./validate-artifact.ts";

for (const kind of ["npm", "git"] as const) {
test(`${kind}: pinned repository overview is required except for unchanged or approved new imports`, async () => {
test(`${kind}: pinned artifact overview is required except for unchanged or approved new imports`, async () => {
const directory = mkdtempSync(join(tmpdir(), "registry-required-overview-"));
const pluginPath = "packages/example";
const plugin = join(directory, pluginPath);
Expand Down Expand Up @@ -45,16 +45,18 @@ for (const kind of ["npm", "git"] as const) {
dist: { integrity: "sha512-YWJj", tarball: "https://registry.npmjs.org/example.tgz" },
};
let provenanceCommit = commit;
const overviews = new Map<string, string | null>([[doc.version, "Tarball overview"]]);
const expectedOverview = kind === "npm" ? "Tarball overview" : "Author overview";
const client: NpmClient = {
async packument() {
return { name: doc.name, "dist-tags": { latest: "9.0.0" }, time: { [doc.version]: "2026-10-06" }, versions: { [doc.version]: doc } };
return { name: doc.name, "dist-tags": { latest: "9.0.0" }, time: { [doc.version]: "2026-10-06" }, versions: Object.fromEntries([...overviews.keys()].map(version => [version, { ...doc, version }])) };
},
async file(_name, version, path) {
assert.equal(version, doc.version);
assert.ok(overviews.has(version));
// Published README/OVERVIEW content is deliberately different from the repository.
return path === "paseo-plugin.json" ? '{"id":"example"}'
: path === "paseo-listing.json" ? '{"readme":"README.md"}'
: "Wrong tarball content";
: overviews.get(version)!;
},
async provenance() { return { repositoryUrl: remote.replace(/\.git$/, ""), commit: provenanceCommit }; },
async tarball() { throw new Error("Plugin artifacts must not execute"); },
Expand All @@ -69,9 +71,9 @@ for (const kind of ["npm", "git"] as const) {
artifact: kind === "git" ? { kind, remote, commit, tag: "v1", pluginPath }
: { kind, package: doc.name, version: doc.version, resolved: doc.dist.tarball, integrity: doc.dist.integrity },
};
assert.equal(await readAuthorOverview(client, record), "Author overview");
assert.equal(await readAuthorOverview(client, record), expectedOverview);
const published = await resolvePlugin(client, record, "Registry stopgap");
assert.equal(published.readme, "Author overview");
assert.equal(published.readme, expectedOverview);
assert.equal(published.icon, record.listing!.icon);
assert.deepEqual(published.media, record.listing!.media);
assert.equal(published.publishedAt, "2026-09-20T00:00:00.000Z");
Expand All @@ -81,17 +83,25 @@ for (const kind of ["npm", "git"] as const) {
assert.deepEqual(withMedia.media, media);
assert.equal("screenshots" in withMedia, false);
assert.deepEqual(await validateArtifact(client, record), []);
if (record.artifact.kind === "npm") {
overviews.set("2.0.0", null);
// A missing package file must not fall back to the repository, which still has it.
assert.equal(await readAuthorOverview(client, {
...record, artifact: { ...record.artifact, version: "2.0.0" },
}), null);
}
rmSync(join(plugin, "OVERVIEW.md"));
const absentCommit = pin("v2");
overviews.set("2.0.0", null);
provenanceCommit = absentCommit;
const absent: PluginRecord = {
...record, repository: { ...record.repository, commit: absentCommit },
artifact: kind === "git" ? { ...record.artifact, kind, remote, commit: absentCommit, tag: "v2", pluginPath }
: { kind, package: doc.name, version: doc.version, resolved: doc.dist.tarball, integrity: doc.dist.integrity },
: { kind, package: doc.name, version: "2.0.0", resolved: doc.dist.tarball, integrity: doc.dist.integrity },
};
// The old pin must still read its own overview after HEAD loses it.
assert.equal(await readAuthorOverview(client, record), "Author overview");
assert.equal((await resolvePlugin(client, record, "Registry stopgap")).readme, "Author overview");
assert.equal(await readAuthorOverview(client, record), expectedOverview);
assert.equal((await resolvePlugin(client, record, "Registry stopgap")).readme, expectedOverview);
assert.equal((await resolvePlugin(client, absent, "Registry stopgap")).readme, "Registry stopgap");
assert.equal(await readAuthorOverview(client, absent), null);
await assert.rejects(resolvePlugin(client, absent), /OVERVIEW.md.*required/);
Expand All @@ -109,6 +119,7 @@ for (const kind of ["npm", "git"] as const) {
writeFileSync(join(plugin, "OVERVIEW.md"), command);
const invalidCommit = pin(`invalid-${index}`);
provenanceCommit = invalidCommit;
overviews.set("2.0.0", command);
const invalid = { ...absent, repository: { ...absent.repository, commit: invalidCommit }, artifact: kind === "git" ? { ...absent.artifact, kind, remote, commit: invalidCommit, tag: `invalid-${index}`, pluginPath } : absent.artifact };
await assert.rejects(resolvePlugin(client, invalid, "Registry stopgap"), /OVERVIEW.md.*install commands/);
await assert.rejects(validateArtifact(client, invalid, { previous: invalid, registryOverview: "Registry stopgap" }), /OVERVIEW.md.*install commands/);
Expand Down
Loading
Loading