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.
| 145 | 145 | - name: Check and test | |
| 146 | 146 | run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet | |
| 147 | 147 | - 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 |
| 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 |
| 128 | 128 | `.g1t/workflows/runner-release.yml` builds, signs and publishes it, and | |
| 129 | 129 | runners update themselves to it. `scripts/runner-release.mjs` does the | |
| 130 | 130 | 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/). | |
| 131 | 142 | - **The desktop app** is released the same way: bump `version` in | |
| 132 | 143 | `apps/desktop/src-tauri/Cargo.toml`, merge, and push a | |
| 133 | 144 | `desktop-v<version>` tag. `.g1t/workflows/desktop-release.yml` builds |
| 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' |
| 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" |
| 95 | 95 | route("download", "routes/download.tsx"), | |
| 96 | 96 | // Releases of the desktop app, the self-hosted runner and the g1t CLI, from R2. | |
| 97 | 97 | 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" }), | |
| 98 | 101 | // A workspace's own pages sit under `-`, which no repository can be named. | |
| 99 | 102 | route(":owner", "routes/workspace/layout.tsx", [ | |
| 100 | 103 | index("routes/workspace/overview.tsx"), |
| 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 | + | } |
| 16 | 16 | "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"", | |
| 17 | 17 | "test:ops": "node --test \"scripts/ops/*.test.mjs\"", | |
| 18 | 18 | "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\"" | |
| 20 | 20 | }, | |
| 21 | 21 | "devDependencies": { | |
| 22 | 22 | "typescript": "^5.9.3", |
| 23 | 23 | "inbox", "notifications", | |
| 24 | 24 | // g1t.sh/download, the desktop app; g1t.sh/downloads/, the releases it and the runner come from. | |
| 25 | 25 | "download", "downloads", | |
| 26 | + | // g1t.sh/install and g1t.sh/install.ps1, the CLI's install scripts. | |
| 27 | + | "install", | |
| 26 | 28 | ]); | |
| 27 | 29 | ||
| 28 | 30 | /** |
| 1 | 1 | #!/usr/bin/env node | |
| 2 | 2 | // 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. | |
| 5 | 7 | // | |
| 6 | 8 | // 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 | |
| 7 | 12 | // node scripts/cli-release.mjs publish [--dry-run] | |
| 8 | 13 | // | |
| 9 | 14 | // 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. | |
| 14 | 27 | ||
| 15 | 28 | import { spawnSync } from "node:child_process"; | |
| 16 | − | import { createHash } from "node:crypto"; | |
| 29 | + | import { createHash, createPrivateKey } from "node:crypto"; | |
| 17 | 30 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 18 | 31 | import { dirname, join } from "node:path"; | |
| 19 | 32 | import { fileURLToPath } from "node:url"; | |
| 20 | 33 | ||
| 34 | + | import { signBytes, verifyBytes } from "./runner-release.mjs"; | |
| 35 | + | ||
| 21 | 36 | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 22 | 37 | const BUCKET = "g1t-downloads"; | |
| 23 | 38 | const BUILDER = "ghcr.io/rust-cross/cargo-zigbuild:latest"; | |
| ⋯ | |||
| 36 | 51 | } | |
| 37 | 52 | ||
| 38 | 53 | 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"); | |
| 40 | 55 | ||
| 41 | 56 | function run(command, args, options = {}) { | |
| 42 | 57 | const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options }); | |
| 43 | 58 | if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`); | |
| 44 | 59 | } | |
| 45 | 60 | ||
| 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 | + | ||
| 46 | 81 | function build() { | |
| 47 | 82 | const v = version(); | |
| 48 | 83 | const dir = outDir(v); | |
| ⋯ | |||
| 58 | 93 | "-w", "/src", BUILDER, "sh", "-c", | |
| 59 | 94 | `set -e; for t in ${triples}; do rustup target add $t >/dev/null 2>&1; cargo zigbuild --release --locked --package g1t --target $t; done`, | |
| 60 | 95 | ], { 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)) { | |
| 63 | 97 | 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`); | |
| 67 | 125 | } | |
| 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`); | |
| 73 | 127 | } | |
| 74 | 128 | ||
| 75 | 129 | function publish(dryRun) { | |
| 76 | 130 | const v = version(); | |
| 77 | 131 | const dir = outDir(v); | |
| 78 | 132 | 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")); | |
| 79 | 134 | const put = (key, file, type) => { | |
| 80 | 135 | if (dryRun) return console.log(`would put ${key}`); | |
| 81 | 136 | run("npx", ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type], { | |
| ⋯ | |||
| 85 | 140 | cwd: join(ROOT, "apps/web"), | |
| 86 | 141 | }); | |
| 87 | 142 | }; | |
| 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"); | |
| 89 | 145 | put(`cli/${v}/manifest.json`, join(dir, "manifest.json"), "application/json"); | |
| 90 | 146 | 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"); | |
| 91 | 148 | // 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"); | |
| 92 | 150 | put("cli/latest.json", join(dir, "latest.json"), "application/json"); | |
| 93 | 151 | } | |
| 94 | 152 | ||
| 95 | 153 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/cli-release.mjs")) { | |
| 96 | 154 | const [command, ...rest] = process.argv.slice(2); | |
| 97 | 155 | if (command === "build") build(); | |
| 156 | + | else if (command === "manifest") manifest(outDir(), version()); | |
| 157 | + | else if (command === "sign") signRelease(); | |
| 158 | + | else if (command === "verify") verifyRelease(); | |
| 98 | 159 | else if (command === "publish") publish(rest.includes("--dry-run")); | |
| 99 | 160 | 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]"); | |
| 101 | 162 | process.exit(2); | |
| 102 | 163 | } | |
| 103 | 164 | } | |
| 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.