Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

166 linesCodeBlameRaw
1// node --test scripts/plugins-release.test.mjs
2import assert from "node:assert/strict";
3import { spawnSync } from "node:child_process";
4import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5import { tmpdir } from "node:os";
6import { dirname, join } from "node:path";
7import { after, test } from "node:test";
8import { fileURLToPath } from "node:url";
9
10import { INSTALL, PUBLISHED, SOURCE, authEnv, tagName, build, expectedFiles, manifests, minimumCli, publish, scan, walk } from "./plugins-release.mjs";
11
12const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
13const temps = [];
14const temp = (prefix) => {
15 const dir = mkdtempSync(join(tmpdir(), prefix));
16 temps.push(dir);
17 return dir;
18};
19after(() => temps.forEach((d) => rmSync(d, { recursive: true, force: true })));
20
21/** A copy of what the build reads from this repository, to change freely. */
22function source() {
23 const root = temp("plugins-src-");
24 for (const path of [".claude-plugin", SOURCE, "plugins/codex", "LICENSE"]) cpSync(join(ROOT, path), join(root, path), { recursive: true });
25 return root;
26}
27const editJson = (root, file, fn) => {
28 const path = join(root, file);
29 writeFileSync(path, JSON.stringify(fn(JSON.parse(readFileSync(path, "utf8"))), null, 2));
30};
31const bump = (root, version) => {
32 editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version }] }));
33 editJson(root, `${SOURCE}/.claude-plugin/plugin.json`, (p) => ({ ...p, version }));
34};
35const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" });
36
37test("this repository's manifests agree: g1t@g1t, one version", () => {
38 const { marketplace, plugin, version } = manifests();
39 assert.equal(marketplace.plugins[0].version, version);
40 assert.equal(plugin.version, version);
41});
42
43test("the build is the marketplace, the plugin without its tests, a README and the license", () => {
44 const out = join(temp("plugins-out-"), "tree");
45 const { version } = build(out);
46 const files = walk(out);
47 assert.deepEqual(files, expectedFiles());
48 assert.ok(files.includes(`${PUBLISHED}/hooks/g1t.mjs`));
49 assert.ok(!files.some((f) => f.includes("test/") || f.endsWith(".test.mjs")), files.join(" "));
50 const marketplace = JSON.parse(readFileSync(join(out, ".claude-plugin/marketplace.json"), "utf8"));
51 assert.equal(marketplace.name, "g1t");
52 assert.deepEqual(
53 marketplace.plugins.map((p) => [p.name, p.source, p.version]),
54 [["g1t", "./plugins/g1t", version]],
55 );
56 // The plugin's files are the source's, byte for byte.
57 for (const f of files.filter((f) => f.startsWith(`${PUBLISHED}/`))) {
58 assert.equal(readFileSync(join(out, f), "utf8"), readFileSync(join(ROOT, SOURCE, f.slice(PUBLISHED.length + 1)), "utf8"), f);
59 }
60 const readme = readFileSync(join(out, "README.md"), "utf8");
61 for (const line of INSTALL) assert.ok(readme.includes(line), line);
62 // The CLI the README asks for is the one the hooks need.
63 const hooks = readFileSync(join(ROOT, SOURCE, "hooks/g1t.mjs"), "utf8");
64 assert.equal(minimumCli(), hooks.match(/export const MIN_CLI = "([^"]+)"/)?.[1]);
65 assert.ok(readme.includes(`${minimumCli()} or later and Node`), readme);
66});
67
68test("manifests that disagree stop the build", () => {
69 const root = source();
70 editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version: "9.9.9" }] }));
71 assert.throws(() => build(join(root, "out"), root), /marketplace.json says version 9.9.9/);
72 const renamed = source();
73 editJson(renamed, ".claude-plugin/marketplace.json", (m) => ({ ...m, name: "g1t-plugins" }));
74 assert.throws(() => build(join(renamed, "out"), renamed), /installs are g1t@g1t/);
75});
76
77test("a file the script does not know about stops the build", () => {
78 const root = source();
79 writeFileSync(join(root, SOURCE, "notes.md"), "draft");
80 assert.throws(() => build(join(root, "out"), root), /has notes\.md, which .* neither publishes nor keeps back/);
81});
82
83test("anything that looks like a credential, or a credentials file, is never published", () => {
84 const root = source();
85 writeFileSync(join(root, SOURCE, "commands", "status.md"), `token: g1t_${"a1".repeat(20)}\n`);
86 assert.throws(() => build(join(root, "out"), root), /commands\/status\.md: looks like a g1t token/);
87 const dir = temp("plugins-scan-");
88 writeFileSync(join(dir, ".env"), "X=1");
89 writeFileSync(join(dir, "k.md"), "-----BEGIN OPENSSH PRIVATE KEY-----");
90 assert.deepEqual(scan(dir), [".env: a file that is never published", "k.md: looks like a private key"]);
91 // The plugin as it is holds none.
92 assert.deepEqual(scan(join(ROOT, SOURCE, "hooks")), []);
93});
94
95test("releases are tagged as claude plugin tag tags them", () => {
96 assert.equal(tagName("0.2.2"), "g1t--v0.2.2");
97});
98
99test("the token goes to git through its environment, never its arguments", () => {
100 const env = authEnv("g1t_secret");
101 assert.equal(env.GIT_CONFIG_KEY_0, "http.extraHeader");
102 assert.equal(env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${Buffer.from("g1t:g1t_secret").toString("base64")}`);
103 assert.deepEqual(authEnv(undefined), {});
104});
105
106test("publishing: the first commit and tag, nothing when unchanged, a new version for a changed plugin", () => {
107 const root = source();
108 const remote = join(temp("plugins-remote-"), "g1t-plugins.git");
109 git(dirname(remote), "init", "--quiet", "--bare", "--initial-branch=main", remote);
110 const run = (sourceCommit) => publish({ remote, token: "", source: sourceCommit, root, work: temp("plugins-work-") });
111 const { version } = manifests(root);
112
113 const first = run("a".repeat(40));
114 assert.equal(first.changed, true);
115 assert.equal(first.tagged, true);
116 assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
117 assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
118 assert.match(
119 git(remote, "log", "-1", "--format=%an <%ae>%n%B", "main").stdout,
120 /^g1t <g1t@users\.noreply\.g1t\.sh>\ng1t .*, from flagon-io\/g1t@aaaaaaaaaaaa/,
121 );
122 const listed = git(remote, "ls-tree", "-r", "--name-only", "main").stdout.trim().split("\n").sort();
123 assert.deepEqual(listed, expectedFiles(root));
124
125 const again = run("b".repeat(40));
126 assert.deepEqual([again.changed, again.tagged, again.commit], [false, false, first.commit]);
127
128 // The plugin changed with its version already out: refused.
129 writeFileSync(join(root, SOURCE, "commands", "status.md"), "changed\n");
130 assert.throws(() => run("c".repeat(40)), /already published.*Bump "version"/s);
131 assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
132
133 bump(root, "99.0.0");
134 const next = run("d".repeat(40));
135 assert.equal(next.changed, true);
136 assert.equal(next.tagged, true);
137 assert.equal(git(remote, "rev-parse", "g1t--v99.0.0^{commit}").stdout.trim(), next.commit);
138 assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
139 assert.equal(git(remote, "rev-parse", "main~1").stdout.trim(), first.commit);
140
141 // Codex releases have their own version gate; a Claude bump is not enough.
142 writeFileSync(join(root, "plugins/codex/skills/work-on-g1t/SKILL.md"), "changed Codex skill\n");
143 assert.throws(() => run("f".repeat(40)), /Codex plugin files changed without a version bump/);
144 editJson(root, "plugins/codex/plugin.json", (p) => ({ ...p, version: "0.1.1" }));
145 assert.equal(run("1".repeat(40)).changed, true);
146
147 // The marketplace's own text may change without a new version.
148 editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, description: "g1t's plugins for Claude Code, and more." }));
149 const text = run("e".repeat(40));
150 assert.deepEqual([text.changed, text.tagged], [true, false]);
151});
152
153test("Codex catalog resolves its packaged skill and OAuth MCP server", () => {
154 const out = join(temp("plugins-codex-"), "tree");
155 build(out);
156 const catalog = JSON.parse(readFileSync(join(out, ".agents/plugins/marketplace.json"), "utf8"));
157 const entry = catalog.plugins[0];
158 assert.equal(entry.source.source, "local");
159 assert.equal(entry.policy.authentication, "ON_INSTALL");
160 const plugin = join(out, entry.source.path);
161 assert.equal(JSON.parse(readFileSync(join(plugin, "plugin.json"), "utf8")).name, entry.name);
162 const mcp = JSON.parse(readFileSync(join(plugin, "mcp.json"), "utf8"));
163 assert.equal(mcp.mcpServers.g1t.type, "streamable-http");
164 assert.equal(mcp.mcpServers.g1t.extensions["com.openai"].auth.client.mode, "dcr");
165 assert.match(readFileSync(join(plugin, "skills/work-on-g1t/SKILL.md"), "utf8"), /harness: codex/);
166});