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
151 changes: 151 additions & 0 deletions .github/workflows/catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
name: Plugin catalog

on:
pull_request:
paths:
- "registry/**"
- "scripts/catalog*"
- "scripts/safe-extract*"
- "scripts/validate-build.sh"
- ".github/workflows/catalog.yml"
push:
branches: [main]
paths:
- "registry/**"
- "scripts/catalog*"
- "scripts/safe-extract*"
- "scripts/validate-build.sh"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Validate metadata in bounded container
run: |
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \
oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun test scripts/catalog.test.ts scripts/catalog-build.test.ts scripts/safe-extract.test.ts
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \
oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --check
- name: Compile plugin sources in isolated containers
run: |
set -euo pipefail
image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895'
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \
"$image" bun scripts/catalog.ts --plan > "$scratch/plan.json"
jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do
repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")"
commit="$(jq -r '.commit' <<< "$item")"
project="$scratch/project"
mkdir -p "$project"
curl --fail --location --silent --show-error --max-time 60 --max-filesize 20971520 \
"https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz"
test "$(wc -c < "$scratch/source.tar.gz")" -le 20971520
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work \
--mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \
--mount type=bind,src="$PWD/scripts",dst=/scripts,readonly --workdir /work \
"$image" bun /scripts/safe-extract.ts /source.tar.gz /work
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work --workdir /work \
"$image" bun install --frozen-lockfile --ignore-scripts
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --network=none --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c '
entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")"
bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check
'
echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)"
rm -rf "$project" "$scratch/source.tar.gz"
done

refresh:
if: >-
github.event_name != 'pull_request' &&
(github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(catalog): refresh'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Compile plugin sources in isolated containers
run: bash scripts/validate-build.sh
- name: Regenerate catalog in isolated container
run: |
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD/registry",dst=/work/registry \
--mount type=bind,src="$PWD/.catalog-build-plan.json",dst=/work/.catalog-build-plan.json,readonly \
--mount type=bind,src="$PWD/scripts",dst=/work/scripts,readonly --workdir /work \
-e CATALOG_BUILD_VERIFIED=1 \
oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --write
- uses: actions/upload-artifact@v4
with:
name: verified-plugin-catalog
path: registry/catalog.json
if-no-files-found: error
retention-days: 1

publish:
needs: refresh
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: verified-plugin-catalog
path: verified-catalog
- name: Validate artifact shape and copy data only
run: |
test -f verified-catalog/catalog.json
test ! -L verified-catalog/catalog.json
test "$(wc -c < verified-catalog/catalog.json)" -le 5242880
jq -e '
.schema_version == 1 and
(.generated_at | type == "string") and
(.plugins | type == "array" and all(.[];
(.name | type == "string") and
(.commit | test("^[a-f0-9]{40}$")) and
(.repository | startswith("https://github.com/")) and
(.validation.metadata == "passed") and
(.validation.build == "passed")
))
' verified-catalog/catalog.json
cp verified-catalog/catalog.json registry/catalog.json
- name: Commit generated data only
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add registry/catalog.json
if ! git diff --cached --quiet; then
git commit -m 'chore(catalog): refresh public plugin metadata'
git -c http.extraheader="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)" push origin HEAD:main
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ dist/

# Logs
*.log
.catalog-build-plan.json
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"private": true,
"scripts": {
"catalog:check": "bun scripts/catalog.ts --check",
"catalog:generate": "bun scripts/catalog.ts --write",
"test": "bun test"
}
}
36 changes: 36 additions & 0 deletions registry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Submit a plugin

Open a pull request adding `registry/<plugin-name>.json`:

```json
{
"repo": "your-org/your-plugin",
"categories": ["observability"]
}
```

The filename must match `temps.name` in the repository's root `package.json`.
Only public GitHub repositories with a root `package.json` and nonempty root
`README.md` are supported. Monorepo paths are deliberately not supported yet;
the installer must understand paths before listings may specify them. The
generator reads metadata from a pinned commit SHA, not a mutable branch URL.

Allowed categories: `analytics`, `automation`, `databases`, `developer-tools`,
`observability`, `seo`, `security`, `other`. The first is the primary category.

The `temps` manifest may supply `title`, `summary`, `description`, `platforms`,
`docsUrl`, `logo`, and `screenshots` (objects with relative `path`, `alt`, and
optional `caption`). Logo and screenshot paths are relative to repository root.
Other fields derive from `package.json` and GitHub repository metadata.

Pull-request validation checks metadata and README availability, then installs
dependencies with lifecycle scripts disabled and compiles the root manifest's
entrypoint in a capped, offline container. It does **not** execute the plugin or
audit its security. The generated catalog records `validation.build: "passed"`
only when the tested commit matches the catalog commit. Consumers must not
present build validation as a security guarantee.

The dependency-install phase has network access to download locked packages;
only the subsequent compile phase is offline. Lifecycle scripts are disabled
during installation. This is build validation, not full network isolation or
a malware review.
33 changes: 33 additions & 0 deletions registry/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"generated_at": "2026-09-14T12:33:31.951Z",
"plugins": [
{
"name": "my-plugin",
"title": "My plugin",
"summary": "A hello-world native Temps plugin",
"description": "A hello-world native Temps plugin",
"author": "Your team",
"category": "Development",
"repository": "https://github.com/gotempsh/temps-plugin-template",
"docsUrl": null,
"logoUrl": null,
"screenshots": [],
"latestVersion": "0.1.1",
"platforms": [
"linux-amd64-gnu",
"linux-arm64-gnu",
"linux-amd64-musl",
"linux-arm64-musl",
"darwin-amd64",
"darwin-arm64"
],
"commit": "7506685388e6fbfa73e49ba4cc0293345965f99f",
"readmeUrl": "https://raw.githubusercontent.com/gotempsh/temps-plugin-template/7506685388e6fbfa73e49ba4cc0293345965f99f/README.md",
"validation": {
"metadata": "passed",
"build": "passed"
}
}
]
}
4 changes: 4 additions & 0 deletions registry/my-plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"repo": "gotempsh/temps-plugin-template",
"categories": ["developer-tools"]
}
17 changes: 17 additions & 0 deletions scripts/catalog-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";

for (const path of ["../.github/workflows/catalog.yml", "./validate-build.sh"]) {
test(`${path}: catalog containers use the bind-mount owner's identity`, async () => {
const source = await Bun.file(new URL(path, import.meta.url)).text();
const commands = source.split("\n").filter(line => line.includes("docker run --rm"));
expect(commands.length).toBeGreaterThan(0);
for (const command of commands) {
expect(command).toContain('--user "$(id -u):$(id -g)"');
expect(command).toContain('-e HOME=/tmp');
expect(command).toContain('--cap-drop=ALL');
expect(command).toContain('--security-opt=no-new-privileges');
}
expect(source).toContain('--network=none');
expect(source).toContain('--ignore-scripts');
});
}
41 changes: 41 additions & 0 deletions scripts/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { parseListing, resolvePlugin } from "./catalog";

const sha = "a".repeat(40);
const listing = { repo: "gotempsh/temps-plugin-template", categories: ["developer-tools"] };
const pkg = { name: "@your-scope/my-plugin", version: "0.1.0", description: "A hello-world native Temps plugin", author: "Your team", temps: { name: "my-plugin", title: "My plugin", category: "Development", platforms: ["linux-amd64-gnu"] } };

function fixture(packageJson: object = pkg, readme = "# My plugin\n") {
const fetcher = (async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.endsWith("/repos/gotempsh/temps-plugin-template")
? { full_name: listing.repo, private: false, default_branch: "main", owner: { login: "gotempsh" } }
: url.endsWith("/commits/main") ? { sha }
: url.endsWith("/package.json") ? packageJson : readme;
return new Response(typeof body === "string" ? body : JSON.stringify(body), { status: 200 });
}) as typeof fetch;
return fetcher;
}

describe("catalog submissions", () => {
test("accepts minimal exact listing", () => expect(parseListing("my-plugin", listing)).toEqual(listing));
test("rejects path overrides and traversal", () => {
expect(() => parseListing("my-plugin", { ...listing, path: "subdir" })).toThrow("only repo and categories");
expect(() => parseListing("my-plugin", { ...listing, repo: "gotempsh/../evil" })).toThrow("invalid GitHub");
});
test("resolves real template-shaped manifest at immutable commit", async () => {
const result = await resolvePlugin("my-plugin", listing, fixture());
expect(result.commit).toBe(sha);
expect(result.readmeUrl).toContain(`/${sha}/README.md`);
expect(result.validation).toEqual({ metadata: "passed", build: "not_run" });
});
test("rejects mismatched manifest identity", async () => {
await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, name: "other" } }))).rejects.toThrow("temps.name must match");
});
test("rejects absent README", async () => {
await expect(resolvePlugin("my-plugin", listing, fixture(pkg, ""))).rejects.toThrow("README.md is empty");
});
test("rejects asset traversal", async () => {
await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, logo: "../secret" } }))).rejects.toThrow("invalid asset path");
});
});
Loading
Loading