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.

g1t.sh
135 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, 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, "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(marketplace.plugins.map((p) => [p.name, p.source, p.version]), [["g1t", "./plugins/g1t", version]]);
53 // The plugin's files are the source's, byte for byte.
54 for (const f of files.filter((f) => f.startsWith(`${PUBLISHED}/`))) {
55 assert.equal(readFileSync(join(out, f), "utf8"), readFileSync(join(ROOT, SOURCE, f.slice(PUBLISHED.length + 1)), "utf8"), f);
56 }
57 const readme = readFileSync(join(out, "README.md"), "utf8");
58 for (const line of INSTALL) assert.ok(readme.includes(line), line);
59});
60
61test("manifests that disagree stop the build", () => {
62 const root = source();
63 editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version: "9.9.9" }] }));
64 assert.throws(() => build(join(root, "out"), root), /marketplace.json says version 9.9.9/);
65 const renamed = source();
66 editJson(renamed, ".claude-plugin/marketplace.json", (m) => ({ ...m, name: "g1t-plugins" }));
67 assert.throws(() => build(join(renamed, "out"), renamed), /installs are g1t@g1t/);
68});
69
70test("a file the script does not know about stops the build", () => {
71 const root = source();
72 writeFileSync(join(root, SOURCE, "notes.md"), "draft");
73 assert.throws(() => build(join(root, "out"), root), /has notes\.md, which .* neither publishes nor keeps back/);
74});
75
76test("anything that looks like a credential, or a credentials file, is never published", () => {
77 const root = source();
78 writeFileSync(join(root, SOURCE, "commands", "status.md"), `token: g1t_${"a1".repeat(20)}\n`);
79 assert.throws(() => build(join(root, "out"), root), /commands\/status\.md: looks like a g1t token/);
80 const dir = temp("plugins-scan-");
81 writeFileSync(join(dir, ".env"), "X=1");
82 writeFileSync(join(dir, "k.md"), "-----BEGIN OPENSSH PRIVATE KEY-----");
83 assert.deepEqual(scan(dir), [".env: a file that is never published", "k.md: looks like a private key"]);
84 // The plugin as it is holds none.
85 assert.deepEqual(scan(join(ROOT, SOURCE, "hooks")), []);
86});
87
88test("releases are tagged as claude plugin tag tags them", () => {
89 assert.equal(tagName("0.2.2"), "g1t--v0.2.2");
90});
91
92test("the token goes to git through its environment, never its arguments", () => {
93 const env = authEnv("g1t_secret");
94 assert.equal(env.GIT_CONFIG_KEY_0, "http.extraHeader");
95 assert.equal(env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${Buffer.from("g1t:g1t_secret").toString("base64")}`);
96 assert.deepEqual(authEnv(undefined), {});
97});
98
99test("publishing: the first commit and tag, nothing when unchanged, a new version for a changed plugin", () => {
100 const root = source();
101 const remote = join(temp("plugins-remote-"), "g1t-plugins.git");
102 git(dirname(remote), "init", "--quiet", "--bare", "--initial-branch=main", remote);
103 const run = (sourceCommit) => publish({ remote, token: "", source: sourceCommit, root, work: temp("plugins-work-") });
104 const { version } = manifests(root);
105
106 const first = run("a".repeat(40));
107 assert.equal(first.changed, true);
108 assert.equal(first.tagged, true);
109 assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
110 assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
111 assert.match(git(remote, "log", "-1", "--format=%an <%ae>%n%B", "main").stdout, /^g1t <g1t@users\.noreply\.g1t\.sh>\ng1t .*, from flagon-io\/g1t@aaaaaaaaaaaa/);
112 const listed = git(remote, "ls-tree", "-r", "--name-only", "main").stdout.trim().split("\n").sort();
113 assert.deepEqual(listed, expectedFiles(root));
114
115 const again = run("b".repeat(40));
116 assert.deepEqual([again.changed, again.tagged, again.commit], [false, false, first.commit]);
117
118 // The plugin changed with its version already out: refused.
119 writeFileSync(join(root, SOURCE, "commands", "status.md"), "changed\n");
120 assert.throws(() => run("c".repeat(40)), /already published.*Bump "version"/s);
121 assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
122
123 bump(root, "99.0.0");
124 const next = run("d".repeat(40));
125 assert.equal(next.changed, true);
126 assert.equal(next.tagged, true);
127 assert.equal(git(remote, "rev-parse", "g1t--v99.0.0^{commit}").stdout.trim(), next.commit);
128 assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
129 assert.equal(git(remote, "rev-parse", "main~1").stdout.trim(), first.commit);
130
131 // The marketplace's own text may change without a new version.
132 editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, description: "g1t's plugins for Claude Code, and more." }));
133 const text = run("e".repeat(40));
134 assert.deepEqual([text.changed, text.tagged], [true, false]);
135});