diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 944a4f530c..06064556ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -788,7 +788,7 @@ jobs: base="$(basename "$artifact")" dest="$out_dir/kandev-desktop-${{ matrix.platform }}-$base" cp "$artifact" "$dest" - (cd "$out_dir" && shasum -a 256 "$(basename "$dest")" > "$(basename "$dest").sha256") + scripts/release/write-sha256.sh "$dest" "$dest.sha256" found=$((found + 1)) done < <(find "$bundle_dir" -type f \( \ -name '*.dmg' -o \ diff --git a/Dockerfile b/Dockerfile index 06fd3333d8..7ddf95ad4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,25 +14,59 @@ # Run: # docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest +FROM debian:bookworm-slim AS apt-keys + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /nodesource.gpg && \ + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /microsoft.gpg && \ + rm -rf /var/lib/apt/lists/* + FROM debian:bookworm-slim +ARG NODE_MAJOR=24 + +COPY --from=apt-keys /nodesource.gpg /usr/share/keyrings/nodesource.gpg +COPY --from=apt-keys /microsoft.gpg /usr/share/keyrings/microsoft.gpg + # Install runtime dependencies. gh is included because the GitHub integration # (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN -# is not set. Azure CLI + azure-devops extension support agentctl Azure Repos -# PR creation (az repos pr create). apprise is installed via pipx for -# notification fan-out. +# is not set. Node/npm support agent CLI installs and the universal image's +# pnpm layer. Azure CLI + azure-devops extension support agentctl Azure Repos PR +# creation (az repos pr create). apprise is installed via pipx for notification +# fan-out. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - git \ - gh \ + apt-transport-https \ ca-certificates \ curl \ + git \ + gh \ gosu \ tini \ python3 \ python3-venv \ pipx && \ - curl -sL https://aka.ms/InstallAzureCLIDeb | bash && \ + echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list && \ + arch="$(dpkg --print-architecture)" && \ + printf "%s\n" \ + "Types: deb" \ + "URIs: https://packages.microsoft.com/repos/azure-cli/" \ + "Suites: bookworm" \ + "Components: main" \ + "Architectures: $arch" \ + "Signed-by: /usr/share/keyrings/microsoft.gpg" \ + > /etc/apt/sources.list.d/azure-cli.sources && \ + apt-get update && \ + apt-get install -y --no-install-recommends nodejs azure-cli && \ + rm -f \ + /etc/apt/sources.list.d/nodesource.list \ + /etc/apt/sources.list.d/azure-cli.sources \ + /usr/share/keyrings/nodesource.gpg \ + /usr/share/keyrings/microsoft.gpg && \ rm -rf /var/lib/apt/lists/* && \ PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise diff --git a/Dockerfile.universal b/Dockerfile.universal index 95e36ae71a..9a5407fc8c 100644 --- a/Dockerfile.universal +++ b/Dockerfile.universal @@ -162,17 +162,17 @@ RUN set -eux; \ rm /tmp/rustup-init /tmp/rustup-init.sha256; \ chmod -R a+rX "$RUSTUP_HOME" "$CARGO_HOME" -# pnpm via corepack - install system-wide so users don't need -# `corepack enable` themselves. Drops shims into /usr/local/bin. +# pnpm via npm - install system-wide so users don't need to bootstrap it +# themselves. Drops shims into /usr/local/bin. # -# No separate sha256 step: corepack downloads from registry.npmjs.org and -# verifies the tarball against the registry's `integrity` field (SHA-512) -# before activating. The other tools above pin upstream `.sha256` sidecars -# because they're served from CDNs that don't expose package integrity at -# the API level; npm does. Decision is deliberate, not an oversight. +# No separate sha256 step: npm downloads from registry.npmjs.org and verifies +# the tarball against the registry's `integrity` field (SHA-512) before +# installing. The other tools above pin upstream `.sha256` sidecars because +# they're served from CDNs that don't expose package integrity at the API level; +# npm does. Decision is deliberate, not an oversight. RUN set -eux; \ - corepack enable; \ - corepack prepare "pnpm@${PNPM_VERSION}" --activate + npm install -g --prefix /usr/local "pnpm@${PNPM_VERSION}"; \ + pnpm --version # fd is installed as `fdfind` on Debian to avoid clashing with the legacy # fd package - symlink so agents calling plain `fd` still work. diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts index 26efa8ca98..d052c02b66 100644 --- a/apps/cli/src/release-config.test.ts +++ b/apps/cli/src/release-config.test.ts @@ -19,6 +19,10 @@ function extractDockerPnpmVersion(dockerfile: string): string | undefined { return dockerfile.match(/^ARG PNPM_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$/m)?.[1]; } +function extractDockerNodeMajor(dockerfile: string): string | undefined { + return dockerfile.match(/^ARG NODE_MAJOR=([0-9]+)$/m)?.[1]; +} + function extractPackageManagerPnpmVersion(packageJSON: string): string { const packageManager = (JSON.parse(packageJSON) as { packageManager?: string }).packageManager; const version = packageManager?.match(/^pnpm@([0-9]+\.[0-9]+\.[0-9]+)$/)?.[1]; @@ -145,18 +149,48 @@ function assertWorkflowPnpmVersions(file: string, expectedVersion: string): numb return versions.length; } -describe("release package manager version", () => { - it("pins pnpm consistently for Docker and GitHub Actions", () => { +describe("release runtime tooling configuration", () => { + it("pins runtime tooling consistently across Docker and GitHub Actions", () => { const packagePnpmVersion = extractPackageManagerPnpmVersion(readRepoFile("apps/package.json")); const dockerfile = readRepoFile("Dockerfile"); + const universalDockerfile = readRepoFile("Dockerfile.universal"); const dockerPnpmVersion = extractDockerPnpmVersion(dockerfile); + const universalDockerPnpmVersion = extractDockerPnpmVersion(universalDockerfile); + const dockerNodeMajor = extractDockerNodeMajor(dockerfile); expect(dockerfile).not.toContain("pnpm@latest"); + expect(dockerNodeMajor, "Dockerfile: NODE_MAJOR must stay on the release Node line").toBe("24"); + expect(dockerfile).toContain("AS apt-keys"); + expect(dockerfile).toContain( + "COPY --from=apt-keys /nodesource.gpg /usr/share/keyrings/nodesource.gpg", + ); + expect(dockerfile).toContain( + "COPY --from=apt-keys /microsoft.gpg /usr/share/keyrings/microsoft.gpg", + ); + expect(dockerfile).toContain("https://deb.nodesource.com/node_${NODE_MAJOR}.x"); + expect(dockerfile).toContain("/etc/apt/sources.list.d/azure-cli.sources"); + expect(dockerfile).not.toContain("InstallAzureCLIDeb"); + expect(dockerfile).not.toContain(" gnupg \\"); + expect(dockerfile).toMatch( + /rm -f[\s\\]+\/etc\/apt\/sources\.list\.d\/nodesource\.list[\s\\]+\/etc\/apt\/sources\.list\.d\/azure-cli\.sources[\s\\]+\/usr\/share\/keyrings\/nodesource\.gpg[\s\\]+\/usr\/share\/keyrings\/microsoft\.gpg/, + ); + expect(dockerfile).toMatch(/\bnodejs\b/); + expect(dockerfile).toMatch(/\bazure-cli\b/); if (dockerPnpmVersion !== undefined) { expect(dockerPnpmVersion, "Dockerfile: PNPM_VERSION must match apps/package.json").toBe( packagePnpmVersion, ); } + expect( + universalDockerPnpmVersion, + "Dockerfile.universal: PNPM_VERSION must match apps/package.json", + ).toBe(packagePnpmVersion); + expect(universalDockerfile).not.toContain("pnpm@latest"); + expect(universalDockerfile).not.toContain("corepack enable"); + expect(universalDockerfile).not.toContain("corepack prepare"); + expect(universalDockerfile).toContain( + 'npm install -g --prefix /usr/local "pnpm@${PNPM_VERSION}"', + ); const workflowSetupCount = workflowFiles().reduce( (count, file) => count + assertWorkflowPnpmVersions(file, packagePnpmVersion), @@ -210,6 +244,8 @@ describe("release desktop artifacts", () => { it("publishes desktop artifacts while leaving npm and Homebrew tied to runtime tarballs", () => { const workflow = releaseWorkflow(); + const checksumScript = readRepoFile("scripts/release/write-sha256.sh"); + const verifyAssetsScript = readRepoFile("scripts/release/verify-desktop-assets.sh"); const publishNpmScript = readRepoFile("scripts/release/publish-npm.sh"); const homebrewScript = readRepoFile("scripts/release/update-homebrew-tap.sh"); @@ -219,7 +255,12 @@ describe("release desktop artifacts", () => { expect(workflow).toContain("pattern: desktop-*"); expect(workflow).toContain("scripts/release/verify-desktop-assets.sh"); expect(workflow).toContain("dist/release-assets/kandev-desktop-*"); - expect(workflow).toContain('shasum -a 256 "$(basename "$dest")"'); + expect(workflow).toContain('scripts/release/write-sha256.sh "$dest" "$dest.sha256"'); + expect(workflow).not.toContain('shasum -a 256 "$(basename "$dest")"'); + expect(checksumScript).toContain("command -v shasum"); + expect(checksumScript).toContain("command -v sha256sum"); + expect(verifyAssetsScript).toContain("command -v shasum"); + expect(verifyAssetsScript).toContain("command -v sha256sum"); expect(publishNpmScript).toContain('asset="kandev-${platform}.tar.gz"'); expect(publishNpmScript).not.toContain("kandev-desktop-"); diff --git a/docs/images.md b/docs/images.md index 8c6c2f856c..b780828b14 100644 --- a/docs/images.md +++ b/docs/images.md @@ -11,7 +11,7 @@ Kandev publishes two container image flavors to GitHub Container Registry. Both ## What's in `:universal` (and what's not) -**Language toolchains:** Go (latest stable), Rust (rustup, stable default toolchain), pnpm (preinstalled via corepack so `pnpm` is on `$PATH` for everyone). +**Language toolchains:** Go (latest stable), Rust (rustup, stable default toolchain), pnpm (preinstalled through npm into `/usr/local` so `pnpm` is on `$PATH` for everyone). **Build essentials:** `build-essential` (gcc, g++, make, libc-dev), `pkg-config`, `python3-dev`, `libssl-dev`. These cover CGO compilation, native Python pip wheels, and native Node modules. diff --git a/scripts/release-desktop.test.sh b/scripts/release-desktop.test.sh index f2c855b0fb..b29af7e369 100644 --- a/scripts/release-desktop.test.sh +++ b/scripts/release-desktop.test.sh @@ -115,10 +115,24 @@ assets_dir="$TMP_DIR/assets" mkdir -p "$assets_dir" artifact="$assets_dir/kandev-desktop-linux-x64-test.deb" printf 'desktop artifact\n' > "$artifact" -(cd "$assets_dir" && shasum -a 256 "$(basename "$artifact")" > "$(basename "$artifact").sha256") +"$ROOT_DIR/scripts/release/write-sha256.sh" "$artifact" "$artifact.sha256" "$ROOT_DIR/scripts/release/verify-desktop-assets.sh" "$assets_dir" linux-x64 >/dev/null pass "verify-desktop-assets accepts matching checksums" +fallback_tools_dir="$TMP_DIR/sha256sum-tools" +fallback_assets_dir="$TMP_DIR/sha256sum-assets" +mkdir -p "$fallback_tools_dir" "$fallback_assets_dir" +for tool in bash basename dirname mkdir mv rm sha256sum; do + ln -s "$(command -v "$tool")" "$fallback_tools_dir/$tool" +done +fallback_artifact="$fallback_assets_dir/kandev-desktop-linux-x64-fallback.deb" +printf 'desktop artifact\n' > "$fallback_artifact" +PATH="$fallback_tools_dir" bash "$ROOT_DIR/scripts/release/write-sha256.sh" \ + "$fallback_artifact" "$fallback_artifact.sha256" +PATH="$fallback_tools_dir" bash "$ROOT_DIR/scripts/release/verify-desktop-assets.sh" \ + "$fallback_assets_dir" linux-x64 >/dev/null +pass "desktop asset checksums work without shasum" + printf 'tampered artifact\n' > "$artifact" if "$ROOT_DIR/scripts/release/verify-desktop-assets.sh" "$assets_dir" linux-x64 >"$OUT_FILE" 2>"$ERR_FILE"; then fail "verify-desktop-assets should reject checksum mismatches" diff --git a/scripts/release/verify-desktop-assets.sh b/scripts/release/verify-desktop-assets.sh index 8091ee32d1..9bf7c01935 100755 --- a/scripts/release/verify-desktop-assets.sh +++ b/scripts/release/verify-desktop-assets.sh @@ -34,6 +34,20 @@ if [ ! -d "$ASSETS_DIR" ]; then exit 1 fi +verify_checksum() { + local assets_dir="$1" + local checksum_file="$2" + + if command -v shasum >/dev/null 2>&1; then + (cd "$assets_dir" && shasum -a 256 -c "$checksum_file") + elif command -v sha256sum >/dev/null 2>&1; then + (cd "$assets_dir" && sha256sum -c "$checksum_file") + else + echo "Missing checksum tool: need shasum or sha256sum" >&2 + return 1 + fi +} + shopt -s nullglob for platform in "${REQUIRED_PLATFORMS[@]}"; do @@ -50,10 +64,7 @@ for platform in "${REQUIRED_PLATFORMS[@]}"; do echo "Missing desktop checksum: $checksum_file" >&2 exit 1 fi - ( - cd "$ASSETS_DIR" - shasum -a 256 -c "$(basename "$checksum_file")" >/dev/null - ) || { + verify_checksum "$ASSETS_DIR" "$(basename "$checksum_file")" >/dev/null || { echo "Checksum verification failed for: $artifact" >&2 exit 1 } diff --git a/scripts/release/write-sha256.sh b/scripts/release/write-sha256.sh new file mode 100755 index 0000000000..1ac2a303b9 --- /dev/null +++ b/scripts/release/write-sha256.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Write a SHA-256 checksum file for a release artifact. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: write-sha256.sh [checksum-file] + +Writes a checksum file using the portable " " format. The +filename in the checksum is always the artifact basename so the file can be +verified from the artifact directory on Linux, macOS, or Windows runners. +EOF +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + usage >&2 + exit 2 +fi + +artifact="$1" +checksum_file="${2:-$artifact.sha256}" + +if [ ! -f "$artifact" ]; then + echo "Missing artifact: $artifact" >&2 + exit 1 +fi + +artifact_dir="$(cd "$(dirname "$artifact")" && pwd -P)" +artifact_name="$(basename "$artifact")" +checksum_dir="$(dirname "$checksum_file")" +mkdir -p "$checksum_dir" + +tmp="${checksum_file}.tmp.$$" +trap 'rm -f "$tmp"' EXIT + +if command -v shasum >/dev/null 2>&1; then + (cd "$artifact_dir" && shasum -a 256 "$artifact_name") > "$tmp" +elif command -v sha256sum >/dev/null 2>&1; then + (cd "$artifact_dir" && sha256sum "$artifact_name") > "$tmp" +else + echo "Missing checksum tool: need shasum or sha256sum" >&2 + exit 1 +fi + +mv "$tmp" "$checksum_file" +trap - EXIT