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.

Commit

The g1t CLI installs with one line, curl -fsSL https://g1t.sh/install | sh on Linux and macOS or irm https://g1t.sh/install.ps1 | iex on Windows, and is released by its own workflow. Each script picks the build for the machine (an Intel shell on Apple silicon gets the native one), downloads it from g1t.sh/downloads/cli/, checks it against the release's SHA256SUMS and installs nothing when it does not match, and puts it in ~/.local/bin, or %LOCALAPPDATA%\g1t\bin added to the user's PATH; G1T_INSTALL_DIR and G1T_VERSION change where and which. The site serves both as plain text, and install is now a reserved name.

.g1t/workflows/cli-release.yml, on a cli-v<version> tag or by hand, builds the five binaries (Linux and macOS on x64 and arm64, Windows on x64) in the rust-cross/cargo-zigbuild image on g1t's own machines, since the macOS builds now link the keychain's Security framework and that image carries the SDK; then signs latest.json with the runner's release key, checks the signature and every file, and publishes. scripts/cli-release.mjs gains manifest, sign and verify, and publishes latest.json.sig beside each release and the latest; its tests run in CI with the other release scripts'. CONTRIBUTING says how a CLI release is made and how the generated reference and operations table are kept current. Nothing is released by this change.

syntaqxcommitted Parent15462a1Browse files
11 files+396−220/11 viewed
+1−1
145145 - name: Check and test
146146 run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet
147147 - name: The release scripts' tests
148− run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs
148+ run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs scripts/cli-release.test.mjs
+87−0
1+# Releases the g1t CLI (crates/g1t): builds it for Linux and macOS on x64
2+# and arm64 and for Windows on x64, signs the release, and publishes it to
3+# g1t.sh/downloads/cli/ (the g1t-downloads R2 bucket), where g1t.sh/install
4+# and g1t.sh/install.ps1 find it.
5+#
6+# A release is a tag `cli-v<version>`, where the version is the one in
7+# crates/g1t/Cargo.toml; or run it by hand. scripts/cli-release.mjs does the
8+# work, and its header says what a release holds. It is signed with the
9+# runner's release key (RUNNER_RELEASE_KEY, RUNNER_RELEASE_PUBLIC_KEY).
10+#
11+# Every platform builds in one job, in the rust-cross/cargo-zigbuild image:
12+# the macOS builds link Apple's Security framework (the keychain), and that
13+# image carries its SDK. It runs on g1t's own machines (g1t-4core), whose
14+# jobs have a Docker Engine of their own.
15+name: CLI release
16+
17+on:
18+ push:
19+ tags: ["cli-v*"]
20+ workflow_dispatch:
21+
22+permissions:
23+ contents: read
24+
25+concurrency:
26+ group: cli-release
27+ cancel-in-progress: false
28+
29+env:
30+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
31+ WRANGLER_SEND_METRICS: "false"
32+
33+jobs:
34+ build:
35+ name: Build every platform
36+ runs-on: g1t-4core
37+ timeout-minutes: 60
38+ steps:
39+ - uses: actions/checkout@v5
40+ - name: The tag names this version
41+ if: startsWith(github.ref, 'refs/tags/cli-v')
42+ run: |
43+ version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/g1t/Cargo.toml | head -1)"
44+ [ "cli-v$version" = "${GITHUB_REF_NAME}" ] || { echo "::error::The tag is ${GITHUB_REF_NAME}, but crates/g1t is $version"; exit 1; }
45+ - name: Build
46+ run: node scripts/cli-release.mjs build
47+ - uses: actions/upload-artifact@v4
48+ with:
49+ name: cli
50+ path: target/cli-release/
51+ overwrite: true
52+
53+ publish:
54+ name: Sign and publish
55+ needs: build
56+ runs-on: ubuntu-latest
57+ # Production's secrets, without a deployment: a release is not a deploy
58+ # of g1t.sh.
59+ environment:
60+ name: production
61+ deployment: false
62+ timeout-minutes: 30
63+ steps:
64+ - uses: actions/checkout@v5
65+ - uses: actions/download-artifact@v4
66+ with:
67+ name: cli
68+ path: target/cli-release
69+ - name: Sign
70+ env:
71+ RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
72+ G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
73+ run: |
74+ node scripts/cli-release.mjs sign
75+ node scripts/cli-release.mjs verify
76+ - name: Cache npm's downloads
77+ uses: actions/cache@v4
78+ with:
79+ path: ~/.npm
80+ key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
81+ restore-keys: npm-${{ runner.os }}-
82+ - name: Install Wrangler
83+ run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
84+ - name: Publish to g1t.sh/downloads/cli
85+ env:
86+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
87+ run: node scripts/cli-release.mjs publish
+11−0
128128 `.g1t/workflows/runner-release.yml` builds, signs and publishes it, and
129129 runners update themselves to it. `scripts/runner-release.mjs` does the
130130 same by hand.
131+- **The g1t CLI** is released the same way: bump `version` in
132+ `crates/g1t/Cargo.toml`, merge, and push a `cli-v<version>` tag.
133+ `.g1t/workflows/cli-release.yml` builds it for every platform, signs
134+ the release with the runner's release key and publishes it to
135+ g1t.sh/downloads/cli/, where g1t.sh/install and g1t.sh/install.ps1
136+ find it. `scripts/cli-release.mjs` does the same by hand. The command
137+ reference in the docs is generated from the CLI
138+ (`G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference`), and its table of
139+ API operations from the OpenAPI document
140+ (`G1T_WRITE_OPS=1 cargo test -p g1t ops_table`); tests fail when either
141+ falls behind. The guide is [The g1t CLI](https://docs.g1t.sh/guides/cli/).
131142 - **The desktop app** is released the same way: bump `version` in
132143 `apps/desktop/src-tauri/Cargo.toml`, merge, and push a
133144 `desktop-v<version>` tag. `.g1t/workflows/desktop-release.yml` builds
+57−0
1+# Installs the g1t CLI on Windows:
2+#
3+# irm https://g1t.sh/install.ps1 | iex
4+#
5+# It downloads the build from g1t.sh/downloads/cli, checks it against the
6+# release's SHA256SUMS, puts it in %LOCALAPPDATA%\g1t\bin, and adds that to
7+# your PATH.
8+#
9+# G1T_INSTALL_DIR where to put g1t.exe
10+# G1T_VERSION a version to install, such as 0.2.0 (default: the latest)
11+#
12+# Docs: https://docs.g1t.sh/guides/cli/
13+$ErrorActionPreference = 'Stop'
14+$ProgressPreference = 'SilentlyContinue'
15+
16+$base = if ($env:G1T_DOWNLOAD_URL) { $env:G1T_DOWNLOAD_URL } else { 'https://g1t.sh/downloads/cli' }
17+$version = if ($env:G1T_VERSION) { $env:G1T_VERSION.TrimStart('v') } else { 'latest' }
18+$dir = if ($env:G1T_INSTALL_DIR) { $env:G1T_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'g1t\bin' }
19+
20+$arch = $env:PROCESSOR_ARCHITECTURE
21+if ($arch -ne 'AMD64' -and $arch -ne 'ARM64') { throw "g1t install: there is no build for $arch." }
22+# Windows on ARM runs the x64 build.
23+$file = 'g1t-windows-x64.exe'
24+
25+[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
26+$tmp = Join-Path ([IO.Path]::GetTempPath()) ('g1t-' + [Guid]::NewGuid())
27+New-Item -ItemType Directory -Path $tmp | Out-Null
28+try {
29+ Write-Host "Downloading g1t ($version, windows x64)..."
30+ $exe = Join-Path $tmp 'g1t.exe'
31+ Invoke-WebRequest -UseBasicParsing -Uri "$base/$version/$file" -OutFile $exe
32+ $sumsFile = Join-Path $tmp 'SHA256SUMS'
33+ Invoke-WebRequest -UseBasicParsing -Uri "$base/$version/SHA256SUMS" -OutFile $sumsFile
34+ $want = Get-Content $sumsFile |
35+ Where-Object { ($_ -split '\s+')[1] -eq $file } |
36+ ForEach-Object { ($_ -split '\s+')[0] } |
37+ Select-Object -First 1
38+ if (-not $want) { throw "g1t install: SHA256SUMS names no $file." }
39+ $have = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
40+ if ($have -ne $want.ToLower()) { throw "g1t install: the download's SHA-256 is $have, not $want. Nothing was installed." }
41+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
42+ Move-Item -Force -Path $exe -Destination (Join-Path $dir 'g1t.exe')
43+}
44+finally {
45+ Remove-Item -Recurse -Force -Path $tmp -ErrorAction SilentlyContinue
46+}
47+
48+$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
49+if (-not (($userPath -split ';') -contains $dir)) {
50+ $joined = if ($userPath) { "$userPath;$dir" } else { $dir }
51+ [Environment]::SetEnvironmentVariable('Path', $joined, 'User')
52+ $env:Path = "$env:Path;$dir"
53+ Write-Host "Added $dir to your PATH; terminals opened from now on have it."
54+}
55+$installed = & (Join-Path $dir 'g1t.exe') --version
56+Write-Host "Installed $installed to $dir\g1t.exe"
57+Write-Host 'Next: g1t auth login'
+79−0
1+#!/bin/sh
2+# Installs the g1t CLI on Linux or macOS:
3+#
4+# curl -fsSL https://g1t.sh/install | sh
5+#
6+# It downloads the build for this system from g1t.sh/downloads/cli, checks it
7+# against the release's SHA256SUMS, and puts it in ~/.local/bin.
8+#
9+# G1T_INSTALL_DIR where to put g1t (default: ~/.local/bin)
10+# G1T_VERSION a version to install, such as 0.2.0 (default: the latest)
11+#
12+# Docs: https://docs.g1t.sh/guides/cli/
13+set -eu
14+
15+base="${G1T_DOWNLOAD_URL:-https://g1t.sh/downloads/cli}"
16+version="${G1T_VERSION:-latest}"
17+version="${version#v}"
18+dir="${G1T_INSTALL_DIR:-$HOME/.local/bin}"
19+
20+say() { printf '%s\n' "$*" >&2; }
21+fail() {
22+ say "g1t install: $*"
23+ exit 1
24+}
25+
26+case "$(uname -s)" in
27+Linux) os=linux ;;
28+Darwin) os=macos ;;
29+MINGW* | MSYS* | CYGWIN*) fail "on Windows, run this in PowerShell instead: irm https://g1t.sh/install.ps1 | iex" ;;
30+*) fail "there is no build for $(uname -s)." ;;
31+esac
32+case "$(uname -m)" in
33+x86_64 | amd64) arch=x64 ;;
34+arm64 | aarch64) arch=arm64 ;;
35+*) fail "there is no build for $(uname -m)." ;;
36+esac
37+# An Intel shell on Apple silicon (Rosetta) gets the native build.
38+if [ "$os" = macos ] && [ "$arch" = x64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then
39+ arch=arm64
40+fi
41+file="g1t-$os-$arch"
42+
43+if command -v curl >/dev/null 2>&1; then
44+ get() { curl -fsSL --retry 3 -o "$2" "$1"; }
45+elif command -v wget >/dev/null 2>&1; then
46+ get() { wget -q -O "$2" "$1"; }
47+else
48+ fail "this needs curl or wget."
49+fi
50+
51+tmp="$(mktemp -d)"
52+trap 'rm -rf "$tmp"' EXIT INT TERM
53+
54+say "Downloading g1t ($version, $os $arch)..."
55+get "$base/$version/$file" "$tmp/g1t" || fail "could not download $base/$version/$file."
56+get "$base/$version/SHA256SUMS" "$tmp/SHA256SUMS" || fail "could not download $base/$version/SHA256SUMS."
57+
58+# A name may carry sha256sum's binary-mode `*`.
59+want="$(awk -v f="$file" '{ sub(/^[*]/, "", $2) } $2 == f { print $1 }' "$tmp/SHA256SUMS")"
60+[ -n "$want" ] || fail "SHA256SUMS names no $file."
61+if command -v sha256sum >/dev/null 2>&1; then
62+ have="$(sha256sum "$tmp/g1t" | awk '{ print $1 }')"
63+elif command -v shasum >/dev/null 2>&1; then
64+ have="$(shasum -a 256 "$tmp/g1t" | awk '{ print $1 }')"
65+else
66+ fail "this needs sha256sum or shasum to check the download."
67+fi
68+[ "$want" = "$have" ] || fail "the download's SHA-256 is $have, not $want. Nothing was installed."
69+
70+chmod +x "$tmp/g1t"
71+mkdir -p "$dir" || fail "could not make $dir. Set G1T_INSTALL_DIR to a directory you can write to."
72+mv "$tmp/g1t" "$dir/g1t" || fail "could not write $dir/g1t. Set G1T_INSTALL_DIR to a directory you can write to."
73+
74+say "Installed $("$dir/g1t" --version) to $dir/g1t"
75+case ":$PATH:" in
76+*":$dir:"*) ;;
77+*) say "$dir is not on your PATH. Add it to your shell's profile: export PATH=\"$dir:\$PATH\"" ;;
78+esac
79+say "Next: g1t auth login"
+3−0
9595 route("download", "routes/download.tsx"),
9696 // Releases of the desktop app, the self-hosted runner and the g1t CLI, from R2.
9797 route("downloads/:tool/*", "routes/downloads-runner.ts"),
98+ // The g1t CLI's install scripts, for Linux and macOS and for Windows. `install` is reserved.
99+ route("install", "routes/install-script.ts", { id: "routes/install-sh" }),
100+ route("install.ps1", "routes/install-script.ts", { id: "routes/install-ps1" }),
98101 // A workspace's own pages sit under `-`, which no repository can be named.
99102 route(":owner", "routes/workspace/layout.tsx", [
100103 index("routes/workspace/overview.tsx"),
+22−0
1+/**
2+ * The g1t CLI's install scripts: g1t.sh/install for Linux and macOS
3+ * (`curl -fsSL https://g1t.sh/install | sh`) and g1t.sh/install.ps1 for
4+ * Windows (`irm https://g1t.sh/install.ps1 | iex`). Each downloads the newest
5+ * release from g1t.sh/downloads/cli/ (scripts/cli-release.mjs), checks it
6+ * against the release's SHA256SUMS, and installs it. `install` is a
7+ * reserved name.
8+ */
9+import type { Route } from "./+types/install-script";
10+import sh from "../content/install/install.sh?raw";
11+import ps1 from "../content/install/install.ps1?raw";
12+
13+export function loader({ request }: Route.LoaderArgs) {
14+ const windows = new URL(request.url).pathname.endsWith(".ps1");
15+ return new Response(windows ? ps1 : sh, {
16+ headers: {
17+ "content-type": "text/plain; charset=utf-8",
18+ "cache-control": "public, max-age=300",
19+ "x-content-type-options": "nosniff",
20+ },
21+ });
22+}
+1−1
1616 "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"",
1717 "test:ops": "node --test \"scripts/ops/*.test.mjs\"",
1818 "test:conformance": "node --test \"conformance/*.test.mjs\"",
19− "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\""
19+ "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\" \"scripts/cli-release.test.mjs\""
2020 },
2121 "devDependencies": {
2222 "typescript": "^5.9.3",
+2−0
2323 "inbox", "notifications",
2424 // g1t.sh/download, the desktop app; g1t.sh/downloads/, the releases it and the runner come from.
2525 "download", "downloads",
26+ // g1t.sh/install and g1t.sh/install.ps1, the CLI's install scripts.
27+ "install",
2628 ]);
2729
2830 /**
+81−20
11 #!/usr/bin/env node
22 // Releases of the g1t CLI (crates/g1t): built for every platform,
3−// checksummed, and published to the g1t-downloads R2 bucket that g1t.sh
4−// serves at /downloads/cli/ (apps/web/app/routes/downloads-runner.ts).
3+// checksummed, signed, and published to the g1t-downloads R2 bucket that
4+// g1t.sh serves at /downloads/cli/ (apps/web/app/routes/downloads-runner.ts).
5+// g1t.sh/install and g1t.sh/install.ps1 (apps/web/app/content/install/)
6+// install the newest one.
57 //
68 // node scripts/cli-release.mjs build # every platform, in the cargo-zigbuild image (Docker)
9+// node scripts/cli-release.mjs manifest # SHA256SUMS, manifest.json and latest.json from what was built
10+// node scripts/cli-release.mjs sign # latest.json.sig
11+// node scripts/cli-release.mjs verify # checks the signature
712 // node scripts/cli-release.mjs publish [--dry-run]
813 //
914 // At cli/<version>/ in the bucket: g1t-linux-x64, -linux-arm64,
10−// -macos-x64, -macos-arm64, -windows-x64.exe, SHA256SUMS and
11−// manifest.json; at cli/: latest.json, the newest release's manifest.
12−// Unlike the runner, the CLI does not update itself, so nothing is signed:
13−// SHA256SUMS is what to check a download against.
15+// -macos-x64, -macos-arm64, -windows-x64.exe, SHA256SUMS, manifest.json and
16+// latest.json.sig; at cli/: latest.json, the newest release's manifest, and
17+// latest.json.sig, its Ed25519 signature in base64. SHA256SUMS is what the
18+// install scripts check a download against; the signature is what a CLI
19+// that updates itself will check, with the same release key as the
20+// runner's (scripts/runner-release.mjs keygen):
21+//
22+// RUNNER_RELEASE_KEY the private key: a g1t Actions secret
23+// G1T_RUNNER_RELEASE_KEY the public key, for `verify`
24+//
25+// The macOS builds link Apple's Security framework (the keychain), whose
26+// SDK the rust-cross/cargo-zigbuild image carries, so `build` runs there.
1427
1528 import { spawnSync } from "node:child_process";
16−import { createHash } from "node:crypto";
29+import { createHash, createPrivateKey } from "node:crypto";
1730 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1831 import { dirname, join } from "node:path";
1932 import { fileURLToPath } from "node:url";
2033
34+import { signBytes, verifyBytes } from "./runner-release.mjs";
35+
2136 const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
2237 const BUCKET = "g1t-downloads";
2338 const BUILDER = "ghcr.io/rust-cross/cargo-zigbuild:latest";
3651 }
3752
3853 const outDir = (v = version()) => join(ROOT, "target", "cli-release", v);
39−const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
54+export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
4055
4156 function run(command, args, options = {}) {
4257 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options });
4358 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
4459 }
4560
61+/**
62+ * The release's manifest, from the binaries in `dir`: each platform's file
63+ * with its SHA-256. Writes SHA256SUMS, manifest.json and latest.json there.
64+ */
65+export function manifest(dir, v, now = new Date()) {
66+ const files = {};
67+ for (const [platform, target] of Object.entries(TARGETS)) {
68+ const path = join(dir, target.file);
69+ if (!existsSync(path)) continue;
70+ files[platform] = { name: target.file, sha256: sha256(readFileSync(path)) };
71+ }
72+ if (Object.keys(files).length === 0) throw new Error(`no binaries in ${dir}: node scripts/cli-release.mjs build`);
73+ const body = { version: v, published_at: now.toISOString(), files };
74+ const text = `${JSON.stringify(body, null, 2)}\n`;
75+ writeFileSync(join(dir, "manifest.json"), text);
76+ writeFileSync(join(dir, "latest.json"), text);
77+ writeFileSync(join(dir, "SHA256SUMS"), Object.values(files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
78+ return body;
79+}
80+
4681 function build() {
4782 const v = version();
4883 const dir = outDir(v);
5893 "-w", "/src", BUILDER, "sh", "-c",
5994 `set -e; for t in ${triples}; do rustup target add $t >/dev/null 2>&1; cargo zigbuild --release --locked --package g1t --target $t; done`,
6095 ], { env: { ...process.env, MSYS_NO_PATHCONV: "1" } });
61− const files = {};
62− for (const [platform, target] of Object.entries(TARGETS)) {
96+ for (const target of Object.values(TARGETS)) {
6397 const built = join(ROOT, "target", "zig", target.triple, "release", target.exe ? "g1t.exe" : "g1t");
64− const bytes = readFileSync(built);
65− writeFileSync(join(dir, target.file), bytes);
66− files[platform] = { name: target.file, sha256: sha256(bytes) };
98+ writeFileSync(join(dir, target.file), readFileSync(built));
99+ }
100+ const m = manifest(dir, v);
101+ console.log(`built ${Object.keys(m.files).length} binaries of g1t ${v} into ${dir}`);
102+}
103+
104+function privateKey(text = process.env.RUNNER_RELEASE_KEY) {
105+ if (!text) throw new Error("RUNNER_RELEASE_KEY is not set");
106+ return createPrivateKey({ key: Buffer.from(text, "base64"), format: "der", type: "pkcs8" });
107+}
108+
109+function signRelease() {
110+ const dir = outDir();
111+ const bytes = readFileSync(join(dir, "latest.json"));
112+ writeFileSync(join(dir, "latest.json.sig"), signBytes(bytes, privateKey()));
113+ console.log(`signed g1t ${version()}`);
114+}
115+
116+function verifyRelease() {
117+ const dir = outDir();
118+ const publicRaw = process.env.G1T_RUNNER_RELEASE_KEY;
119+ if (!publicRaw) throw new Error("G1T_RUNNER_RELEASE_KEY is not set");
120+ const ok = verifyBytes(readFileSync(join(dir, "latest.json")), readFileSync(join(dir, "latest.json.sig"), "utf8"), publicRaw);
121+ if (!ok) throw new Error("latest.json's signature is not the release key's");
122+ const m = JSON.parse(readFileSync(join(dir, "latest.json"), "utf8"));
123+ for (const file of Object.values(m.files)) {
124+ if (sha256(readFileSync(join(dir, file.name))) !== file.sha256) throw new Error(`${file.name} is not the file the manifest names`);
67125 }
68− const manifest = { version: v, published_at: new Date().toISOString(), files };
69− writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
70− writeFileSync(join(dir, "latest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
71− writeFileSync(join(dir, "SHA256SUMS"), Object.values(files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
72− console.log(`built ${Object.keys(files).length} binaries of g1t ${v} into ${dir}`);
126+ console.log(`g1t ${m.version}: the signature and every file check out`);
73127 }
74128
75129 function publish(dryRun) {
76130 const v = version();
77131 const dir = outDir(v);
78132 if (!existsSync(join(dir, "manifest.json"))) throw new Error(`nothing built for ${v}: node scripts/cli-release.mjs build`);
133+ const signed = existsSync(join(dir, "latest.json.sig"));
79134 const put = (key, file, type) => {
80135 if (dryRun) return console.log(`would put ${key}`);
81136 run("npx", ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type], {
85140 cwd: join(ROOT, "apps/web"),
86141 });
87142 };
88− for (const target of Object.values(TARGETS)) put(`cli/${v}/${target.file}`, join(dir, target.file), "application/octet-stream");
143+ const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
144+ for (const file of Object.values(m.files)) put(`cli/${v}/${file.name}`, join(dir, file.name), "application/octet-stream");
89145 put(`cli/${v}/manifest.json`, join(dir, "manifest.json"), "application/json");
90146 put(`cli/${v}/SHA256SUMS`, join(dir, "SHA256SUMS"), "text/plain");
147+ if (signed) put(`cli/${v}/latest.json.sig`, join(dir, "latest.json.sig"), "text/plain");
91148 // Last, so `latest` never names a version whose files are not up yet.
149+ if (signed) put("cli/latest.json.sig", join(dir, "latest.json.sig"), "text/plain");
92150 put("cli/latest.json", join(dir, "latest.json"), "application/json");
93151 }
94152
95153 if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/cli-release.mjs")) {
96154 const [command, ...rest] = process.argv.slice(2);
97155 if (command === "build") build();
156+ else if (command === "manifest") manifest(outDir(), version());
157+ else if (command === "sign") signRelease();
158+ else if (command === "verify") verifyRelease();
98159 else if (command === "publish") publish(rest.includes("--dry-run"));
99160 else {
100− console.error("usage: node scripts/cli-release.mjs build|publish [--dry-run]");
161+ console.error("usage: node scripts/cli-release.mjs build|manifest|sign|verify|publish [--dry-run]");
101162 process.exit(2);
102163 }
103164 }
+52−0
1+// node --test scripts/cli-release.test.mjs
2+import assert from "node:assert/strict";
3+import { createPrivateKey } from "node:crypto";
4+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+import { tmpdir } from "node:os";
6+import { join } from "node:path";
7+import { test } from "node:test";
8+
9+import { TARGETS, manifest, sha256, version } from "./cli-release.mjs";
10+import { keygen, signBytes, verifyBytes } from "./runner-release.mjs";
11+
12+test("the version is crates/g1t's", () => {
13+ assert.match(version(), /^\d+\.\d+\.\d+/);
14+});
15+
16+test("the manifest and SHA256SUMS name each binary that was built", () => {
17+ const dir = mkdtempSync(join(tmpdir(), "cli-release-"));
18+ try {
19+ writeFileSync(join(dir, TARGETS["linux-x64"].file), "linux");
20+ writeFileSync(join(dir, TARGETS["windows-x64"].file), "windows");
21+ const m = manifest(dir, "0.2.0", new Date("2026-10-12T00:00:00Z"));
22+ assert.deepEqual(m, {
23+ version: "0.2.0",
24+ published_at: "2026-10-12T00:00:00.000Z",
25+ files: {
26+ "linux-x64": { name: "g1t-linux-x64", sha256: sha256("linux") },
27+ "windows-x64": { name: "g1t-windows-x64.exe", sha256: sha256("windows") },
28+ },
29+ });
30+ // The install scripts read `<sha256> <name>` lines.
31+ assert.equal(
32+ readFileSync(join(dir, "SHA256SUMS"), "utf8"),
33+ `${sha256("linux")} g1t-linux-x64\n${sha256("windows")} g1t-windows-x64.exe\n`,
34+ );
35+ assert.equal(readFileSync(join(dir, "latest.json"), "utf8"), readFileSync(join(dir, "manifest.json"), "utf8"));
36+ const keys = keygen();
37+ const key = createPrivateKey({ key: Buffer.from(keys.private, "base64"), format: "der", type: "pkcs8" });
38+ const bytes = readFileSync(join(dir, "latest.json"));
39+ assert.ok(verifyBytes(bytes, signBytes(bytes, key), keys.public));
40+ } finally {
41+ rmSync(dir, { recursive: true, force: true });
42+ }
43+});
44+
45+test("a manifest of nothing is refused", () => {
46+ const dir = mkdtempSync(join(tmpdir(), "cli-release-"));
47+ try {
48+ assert.throws(() => manifest(dir, "0.2.0"), /no binaries/);
49+ } finally {
50+ rmSync(dir, { recursive: true, force: true });
51+ }
52+});

Comments

Sign in to comment.