From 65fec9867add44bddabb2e5f5a570c596bc474d2 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 8 Jul 2026 15:22:53 +0000 Subject: [PATCH 1/2] feat: distribute devbox as installer-managed binaries --- .github/installer.json | 20 ++++++ .github/workflows/binary-release.yml | 23 ++++++ .github/workflows/pr-tests.yml | 5 +- .github/workflows/release-npm-package.yml | 85 ----------------------- README.md | 28 ++++---- bun.lock | 7 +- package.json | 25 ++----- src/cli.ts | 12 ++++ src/core.ts | 83 ++++++++++++++++++++-- src/status.ts | 2 +- src/types.d.ts | 4 ++ src/update.ts | 37 ++++++++++ src/version.ts | 3 + tests/core.test.ts | 43 +++++++++++- tests/examples.live.test.ts | 8 +++ tests/status.test.ts | 4 +- tests/update.test.ts | 52 ++++++++++++++ 17 files changed, 313 insertions(+), 128 deletions(-) create mode 100644 .github/installer.json create mode 100644 .github/workflows/binary-release.yml delete mode 100644 .github/workflows/release-npm-package.yml create mode 100644 src/types.d.ts create mode 100644 src/update.ts create mode 100644 src/version.ts create mode 100644 tests/update.test.ts diff --git a/.github/installer.json b/.github/installer.json new file mode 100644 index 0000000..7267936 --- /dev/null +++ b/.github/installer.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "repo": "pablozaiden/devbox", + "installDir": "$HOME/.local/bin", + "binaries": [ + { + "name": "devbox", + "assetPrefix": "devbox", + "postInstallMessage": "Run 'devbox --help' to see available commands." + } + ], + "checksums": { + "required": true, + "extension": ".sha256" + }, + "platforms": { + "linux": ["x64", "arm64"], + "darwin": ["x64", "arm64"] + } +} diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml new file mode 100644 index 0000000..9c66130 --- /dev/null +++ b/.github/workflows/binary-release.yml @@ -0,0 +1,23 @@ +name: Binary Release + +on: + release: + types: [published] + +jobs: + binaries: + uses: pablozaiden/installer/.github/workflows/reusable-binary-release.yml@2e28bc9fbf69385ac5172e526a1268f72db02363 + permissions: + contents: write + with: + install_command: bun install --frozen-lockfile + prebuild_command: bun run test:fast + binaries: | + [ + { + "name": "devbox", + "asset_prefix": "devbox", + "build_command": "bun build --compile --target=$BUN_TARGET --outfile dist/devbox-$RELEASE_TARGET src/cli.ts", + "output_path": "dist/devbox-$RELEASE_TARGET" + } + ] diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index e34d0fb..590f8ab 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -38,5 +38,8 @@ jobs: - name: Run tests run: bun test - - name: Build package + - name: Build binary run: bun run build + + - name: Smoke test binary + run: ./dist/devbox --help diff --git a/.github/workflows/release-npm-package.yml b/.github/workflows/release-npm-package.yml deleted file mode 100644 index ac50462..0000000 --- a/.github/workflows/release-npm-package.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Release NPM Package - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: Release tag (for example v1.2.3) - required: true - -jobs: - publish: - name: Publish to npm - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - name: Determine release tag - id: release - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG="${{ github.event.inputs.tag }}" - else - TAG="${{ github.event.release.tag_name }}" - fi - - if [ -z "$TAG" ]; then - echo "Error: release tag is empty" - exit 1 - fi - - VERSION="${TAG#v}" - - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: ${{ steps.release.outputs.tag }} - - - name: Install Bun - run: | - curl -fsSL https://bun.com/install | bash - echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Set package version from release tag - run: npm pkg set version="${{ steps.release.outputs.version }}" - - - name: Verify package version - run: | - EXPECTED_VERSION="${{ steps.release.outputs.version }}" - ACTUAL_VERSION="$(node -p "require('./package.json').version")" - if [ "$EXPECTED_VERSION" != "$ACTUAL_VERSION" ]; then - echo "Error: package version mismatch. Expected $EXPECTED_VERSION, got $ACTUAL_VERSION" - exit 1 - fi - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run fast tests - run: bun run test:fast - - - name: Build package - run: bun run build - - - name: Inspect package contents - run: npm pack --dry-run - - - name: Publish package - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - npm publish --tag unstable --provenance --access public - else - npm publish --provenance --access public - fi diff --git a/README.md b/README.md index 01972d6..81daf82 100644 --- a/README.md +++ b/README.md @@ -20,26 +20,27 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der ## Installation -Install globally with Bun: +Install `devbox` with the generic `@pablozaiden/installer` script: ```bash -bun install -g @pablozaiden/devbox +curl -fsSL https://raw.githubusercontent.com/pablozaiden/installer/main/install.sh | sh -s -- pablozaiden/devbox ``` -Or install globally with npm: +The installer downloads the Linux or macOS binary for your CPU architecture, verifies the published `.sha256` checksum, and installs it into `$HOME/.local/bin` by default. Add that directory to `PATH` if the installer reports it is not already available. + +Run `devbox` with no arguments to see the CLI help. + +To update an installed binary later: ```bash -npm install -g @pablozaiden/devbox +devbox update ``` -After either install, `devbox` is available in any directory. - -Run `devbox` with no arguments to see the CLI help. +Use `devbox update --check` to check for a newer release without installing it, or `devbox update --version ` to install a specific release. ## Requirements - macOS or Linux -- [Node.js](https://nodejs.org/) or [Bun](https://bun.sh/) to run the installed CLI - Docker - Dev Container CLI available as `devcontainer` in whatever environment runs `devbox` - For SSH agent sharing: either a valid host `SSH_AUTH_SOCK`, or Docker Desktop host services @@ -78,6 +79,9 @@ devbox up --devcontainer-subpath services/api # Start from a specific built-in template instead of a repo devcontainer devbox up --template python +# Check for and install newer devbox release binaries +devbox update + # List the built-in templates as JSON devbox templates @@ -181,25 +185,25 @@ Use `bun test tests/examples.live.test.ts` to run only the live example-devconta Use `bun run test:fast` if you want the non-live suite only, or run `DEVBOX_SKIP_LIVE_EXAMPLE_TESTS=1 bun test` directly. -The build step emits a bundled executable JS entrypoint at `dist/devbox.js`. +The build step emits a standalone Bun-compiled binary at `dist/devbox`. For local development from this repository: - use `bun run src/cli.ts` while iterating on source changes -- use `./dist/devbox.js` after `bun run build` to exercise the packaged artifact +- use `./dist/devbox` after `bun run build` to exercise the packaged artifact For a quick smoke test, this repository includes `examples/smoke-workspace/.devcontainer/devcontainer.json`, which also exercises the Docker-in-Docker path: ```bash cd examples/smoke-workspace -../../dist/devbox.js up --allow-missing-ssh +../../dist/devbox up --allow-missing-ssh ``` For a more realistic feature-heavy example, this repository also includes `examples/complex-workspace/.devcontainer/devcontainer.json`: ```bash cd examples/complex-workspace -../../dist/devbox.js up +../../dist/devbox up ``` The complex example uses several devcontainer features, so the first `up` or `rebuild` can take a while. `devbox` prints periodic elapsed-time progress lines while the devcontainer image/features are still being prepared and while the SSH runner is being installed. diff --git a/bun.lock b/bun.lock index 559d09d..2b5794d 100644 --- a/bun.lock +++ b/bun.lock @@ -3,8 +3,9 @@ "configVersion": 1, "workspaces": { "": { - "name": "expose-devcontainer", + "name": "@pablozaiden/devbox", "dependencies": { + "@pablozaiden/installer": "0.0.6", "jsonc-parser": "3.3.1", }, "devDependencies": { @@ -13,6 +14,8 @@ }, }, "packages": { + "@pablozaiden/installer": ["@pablozaiden/installer@0.0.6", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-nGAyjWQvciV/hpa6W9Gs/w53U8PvI06g156gpBW2OWeAY3GU4mhY4VBFspXi7jH9NVR0CzrxhjBsPnd5tg1vFQ=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], @@ -21,6 +24,8 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/package.json b/package.json index 94ac82b..a93b70d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@pablozaiden/devbox", "version": "0.1.0", + "private": true, "type": "module", "description": "CLI to run and expose a devcontainer with SSH agent sharing and a bundled SSH server.", "repository": { @@ -17,31 +18,15 @@ "ssh", "cli" ], - "publishConfig": { - "access": "public" - }, - "os": [ - "darwin", - "linux" - ], - "engines": { - "node": ">=18" - }, - "files": [ - "dist/", - "README.md" - ], - "bin": { - "devbox": "./dist/devbox.js" - }, "scripts": { - "build": "rm -rf dist && mkdir -p dist && bun build --target=node --packages=bundle --outfile dist/devbox.js src/cli.ts && chmod +x dist/devbox.js", + "build": "rm -rf dist && mkdir -p dist && bun build --compile --outfile dist/devbox src/cli.ts && chmod +x dist/devbox", + "build:release": "rm -rf dist && mkdir -p dist && bun build --compile --target=$BUN_TARGET --outfile dist/devbox-$RELEASE_TARGET src/cli.ts && chmod +x dist/devbox-$RELEASE_TARGET", "dev": "bun run src/cli.ts", "test": "bun test", - "test:fast": "DEVBOX_SKIP_LIVE_EXAMPLE_TESTS=1 bun test", - "pack:dry-run": "npm pack --dry-run" + "test:fast": "DEVBOX_SKIP_LIVE_EXAMPLE_TESTS=1 bun test" }, "dependencies": { + "@pablozaiden/installer": "0.0.6", "jsonc-parser": "3.3.1" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 1a370a9..aa5837f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -68,6 +68,7 @@ import { createRunnerMetadata, serializeRunnerMetadata } from "./runnerState"; import { getDevboxStatus } from "./status"; import { ariseManagedWorkspaces } from "./arise"; import { listTemplateSummaries } from "./templates"; +import { runUpdateCommand } from "./update"; async function main(): Promise { const parsed = parseArgs(process.argv.slice(2)); @@ -86,6 +87,17 @@ async function main(): Promise { return; } + if (parsed.command === "update") { + const exitCode = await runUpdateCommand({ + checkOnly: parsed.checkOnly ?? false, + version: parsed.version, + }); + if (exitCode !== 0) { + process.exitCode = exitCode; + } + return; + } + const workspacePath = await realpath(process.cwd()); const state = await loadWorkspaceState(workspacePath); diff --git a/src/core.ts b/src/core.ts index 9fbb855..67f73fa 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,8 +4,7 @@ import { access, chmod, lstat, mkdir, readFile, rm, writeFile } from "node:fs/pr import os from "node:os"; import path from "node:path"; import { parse as parseJsonc } from "jsonc-parser/lib/esm/main.js"; -import type { ParseError } from "jsonc-parser"; -import pkg from "../package.json"; +import type { ParseError } from "jsonc-parser/lib/esm/main.js"; import { CLI_NAME, DEFAULT_UP_AUTO_PORT_START, @@ -22,8 +21,9 @@ import { WORKSPACE_LABEL_KEY, } from "./constants"; import { getTemplateDefinition } from "./templates"; +import { DEVBOX_VERSION } from "./version"; -export type CommandName = "up" | "down" | "rebuild" | "shell" | "status" | "arise" | "templates" | "help"; +export type CommandName = "up" | "down" | "rebuild" | "shell" | "status" | "arise" | "templates" | "update" | "help"; export interface ParsedArgs { command: CommandName; @@ -34,6 +34,8 @@ export interface ParsedArgs { templateName?: string; githubUser?: string; githubHost?: string; + checkOnly?: boolean; + version?: string; } export type DevcontainerConfig = Record; @@ -138,7 +140,7 @@ export class UserError extends Error { export function helpText(): string { return [ - `${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server`, + `${CLI_NAME} v${DEVBOX_VERSION} - manage a devcontainer plus a bundled SSH server`, "", "Usage:", ` ${CLI_NAME}`, @@ -148,6 +150,7 @@ export function helpText(): string { ` ${CLI_NAME} status`, ` ${CLI_NAME} templates`, ` ${CLI_NAME} arise`, + ` ${CLI_NAME} update [--check] [--version ]`, ` ${CLI_NAME} down [--devcontainer-subpath ]`, ` ${CLI_NAME} help`, ` ${CLI_NAME} --help`, @@ -159,6 +162,7 @@ export function helpText(): string { " status Print JSON describing the managed devbox for this workspace.", " templates Print JSON describing the built-in templates.", " arise Restart stopped managed workspaces discovered from existing containers.", + " update Check for or install newer devbox release binaries.", " down Stop and remove the managed container for this workspace.", " help Show this help.", "", @@ -191,7 +195,8 @@ export function parseArgs(argv: string[]): ParsedArgs { first === "shell" || first === "status" || first === "arise" || - first === "templates" + first === "templates" || + first === "update" ) { command = first; args.shift(); @@ -210,6 +215,8 @@ export function parseArgs(argv: string[]): ParsedArgs { let templateName: string | undefined; let githubUser: string | undefined; let githubHost: string | undefined; + let checkOnly = false; + let version: string | undefined; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -224,6 +231,32 @@ export function parseArgs(argv: string[]): ParsedArgs { continue; } + if (arg === "--check") { + checkOnly = true; + continue; + } + + if (arg === "--version") { + const value = args[index + 1]; + if (!value) { + throw new UserError("Expected a value after --version."); + } + version = value.trim(); + if (!version) { + throw new UserError("Expected a value after --version."); + } + index += 1; + continue; + } + + if (arg.startsWith("--version=")) { + version = arg.slice("--version=".length).trim(); + if (!version) { + throw new UserError("Expected a value after --version."); + } + continue; + } + if (arg === "--devcontainer-subpath") { const value = args[index + 1]; if (!value) { @@ -338,6 +371,12 @@ export function parseArgs(argv: string[]): ParsedArgs { if (command === "arise") { throw new UserError("The arise command does not accept a port."); } + if (command === "templates") { + throw new UserError("The templates command does not accept a port."); + } + if (command === "update") { + throw new UserError("The update command does not accept a port."); + } port = parsePort(positionals[0]); } @@ -361,6 +400,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept a port."); } + if (command === "update" && port !== undefined) { + throw new UserError("The update command does not accept a port."); + } + if (command === "shell" && devcontainerSubpath !== undefined) { throw new UserError("The shell command does not accept --devcontainer-subpath."); } @@ -377,6 +420,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --devcontainer-subpath."); } + if (command === "update" && devcontainerSubpath !== undefined) { + throw new UserError("The update command does not accept --devcontainer-subpath."); + } + if (command === "down" && sshPublicKeyPath !== undefined) { throw new UserError("The down command does not accept --ssh-public-key."); } @@ -397,6 +444,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --ssh-public-key."); } + if (command === "update" && sshPublicKeyPath !== undefined) { + throw new UserError("The update command does not accept --ssh-public-key."); + } + if (command !== "up" && command !== "rebuild" && githubUser !== undefined) { throw new UserError(`The ${command} command does not accept --gh-user.`); } @@ -417,6 +468,10 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --allow-missing-ssh."); } + if (command === "update" && allowMissingSsh) { + throw new UserError("The update command does not accept --allow-missing-ssh."); + } + if (templateName !== undefined && devcontainerSubpath !== undefined) { throw new UserError("--template cannot be combined with --devcontainer-subpath."); } @@ -445,6 +500,22 @@ export function parseArgs(argv: string[]): ParsedArgs { throw new UserError("The templates command does not accept --template."); } + if (command === "update" && templateName !== undefined) { + throw new UserError("The update command does not accept --template."); + } + + if (command !== "update" && checkOnly) { + throw new UserError(`The ${command} command does not accept --check.`); + } + + if (command !== "update" && version !== undefined) { + throw new UserError(`The ${command} command does not accept --version.`); + } + + if (command === "update" && checkOnly && version !== undefined) { + throw new UserError("Cannot combine --check with --version."); + } + return { command, port, @@ -454,6 +525,8 @@ export function parseArgs(argv: string[]): ParsedArgs { ...(templateName ? { templateName } : {}), ...(githubUser ? { githubUser } : {}), ...(githubHost ? { githubHost } : {}), + ...(command === "update" ? { checkOnly } : {}), + ...(command === "update" && version !== undefined ? { version } : {}), }; } diff --git a/src/status.ts b/src/status.ts index 78d4bc7..acadbfc 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,7 +1,7 @@ import { readFile as readFileFromFs } from "node:fs/promises"; import path from "node:path"; import { parse as parseJsonc } from "jsonc-parser/lib/esm/main.js"; -import type { ParseError } from "jsonc-parser"; +import type { ParseError } from "jsonc-parser/lib/esm/main.js"; import { type DockerInspect, type GithubAuthPreference, diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..9d18d63 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,4 @@ +declare module "*.sh" { + const content: string; + export default content; +} diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..3d04d9b --- /dev/null +++ b/src/update.ts @@ -0,0 +1,37 @@ +import { runUpdateCommand as runInstallerUpdateCommand, type UpdaterDependencies } from "@pablozaiden/installer"; +import { DEVBOX_VERSION } from "./version"; + +const GITHUB_REPOSITORY = "pablozaiden/devbox"; +const BINARY_NAME = "devbox"; + +export interface UpdateCommandOptions { + checkOnly: boolean; + version?: string; +} + +export type DevboxUpdateDependencies = Partial & { + currentVersion?: string; +}; + +export const DEVBOX_UPDATER_CONFIG = { + repository: GITHUB_REPOSITORY, + binaryName: BINARY_NAME, + currentVersion: DEVBOX_VERSION, + productName: "Devbox", + checksum: { required: true }, +}; + +export async function runUpdateCommand( + command: UpdateCommandOptions, + dependencyOverrides: DevboxUpdateDependencies = {}, +): Promise { + const { currentVersion, ...installerDependencyOverrides } = dependencyOverrides; + return await runInstallerUpdateCommand( + command, + { + ...DEVBOX_UPDATER_CONFIG, + currentVersion: currentVersion ?? DEVBOX_UPDATER_CONFIG.currentVersion, + }, + installerDependencyOverrides, + ); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..b96a1a6 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,3 @@ +import packageJson from "../package.json"; + +export const DEVBOX_VERSION = packageJson.version; diff --git a/tests/core.test.ts b/tests/core.test.ts index 03da973..19f6929 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -74,6 +74,23 @@ describe("parseArgs", () => { expect(parseArgs(["templates"])).toEqual({ command: "templates", allowMissingSsh: false }); }); + test("supports the update subcommand", () => { + expect(parseArgs(["update"])).toEqual({ command: "update", allowMissingSsh: false, checkOnly: false }); + expect(parseArgs(["update", "--check"])).toEqual({ command: "update", allowMissingSsh: false, checkOnly: true }); + expect(parseArgs(["update", "--version", "v1.2.3"])).toEqual({ + command: "update", + allowMissingSsh: false, + checkOnly: false, + version: "v1.2.3", + }); + expect(parseArgs(["update", "--version=1.2.3"])).toEqual({ + command: "update", + allowMissingSsh: false, + checkOnly: false, + version: "1.2.3", + }); + }); + test("supports selecting a devcontainer subpath", () => { expect(parseArgs(["up", "5001", "--devcontainer-subpath", "services/api"])).toEqual({ command: "up", @@ -177,6 +194,29 @@ describe("parseArgs", () => { ); }); + test("update rejects unrelated options and invalid update option combinations", () => { + expect(() => parseArgs(["update", "5001"])).toThrow("The update command does not accept a port."); + expect(() => parseArgs(["update", "--allow-missing-ssh"])).toThrow( + "The update command does not accept --allow-missing-ssh.", + ); + expect(() => parseArgs(["update", "--devcontainer-subpath", "services/api"])).toThrow( + "The update command does not accept --devcontainer-subpath.", + ); + expect(() => parseArgs(["update", "--ssh-public-key", "/tmp/id_rsa.pub"])).toThrow( + "The update command does not accept --ssh-public-key.", + ); + expect(() => parseArgs(["update", "--template", "python"])).toThrow( + "The update command does not accept --template.", + ); + expect(() => parseArgs(["update", "--gh-user", "work"])).toThrow( + "The update command does not accept --gh-user.", + ); + expect(() => parseArgs(["update", "--check", "--version", "1.2.3"])).toThrow( + "Cannot combine --check with --version.", + ); + expect(() => parseArgs(["up", "--check"])).toThrow("The up command does not accept --check."); + }); + test("rebuild rejects --template", () => { expect(() => parseArgs(["rebuild", "--template", "go"])).toThrow("The rebuild command does not accept --template."); }); @@ -218,6 +258,7 @@ describe("helpText", () => { expect(text).toContain("status"); expect(text).toContain("templates"); expect(text).toContain("arise"); + expect(text).toContain("update"); expect(text).toContain("down"); expect(text).toContain("help"); }); @@ -358,7 +399,7 @@ describe("loadWorkspaceState", () => { }); describe("resolveUpPortPreference", () => { - const state = { + const state: WorkspaceState = { version: 2, workspacePath: "/tmp/ws", workspaceHash: "hash", diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 11f0a88..14697f3 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -246,6 +246,10 @@ describe("example workspaces (real devcontainers)", () => { expect(gitUserEmailInContainer.stdout.trim()).toBe("example@author.test"); const knownHostsInContainer = execInContainer(fixture, firstContainerId, "cat ~/.ssh/known_hosts"); + expect(fixture.knownHostsContent).not.toBeNull(); + if (fixture.knownHostsContent === null) { + throw new Error("Expected known_hosts content."); + } expect(knownHostsInContainer.stdout).toBe(fixture.knownHostsContent); const sshAuthSockInContainer = execInContainer( @@ -254,6 +258,10 @@ describe("example workspaces (real devcontainers)", () => { 'printf "%s" "${SSH_AUTH_SOCK:-}" && test -S "${SSH_AUTH_SOCK:-/missing}"', ); expect(sshAuthSockInContainer.exitCode).toBe(0); + expect(fixture.expectedContainerSshAuthSockPath).not.toBeNull(); + if (fixture.expectedContainerSshAuthSockPath === null) { + throw new Error("Expected a container SSH auth sock path."); + } expect(sshAuthSockInContainer.stdout).toBe(fixture.expectedContainerSshAuthSockPath); const hostKeyInContainer = execInContainerAsRoot(fixture, firstContainerId, "find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*_key' | head -n 1"); diff --git a/tests/status.test.ts b/tests/status.test.ts index 000d44a..d9c1d8d 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -192,7 +192,7 @@ describe("getDevboxStatus", () => { userDataDir: "/tmp/state", template: null, githubAuth: null, - lastContainerId: null, + lastContainerId: undefined, updatedAt: "2026-03-16T00:00:00.000Z", }, }, @@ -235,7 +235,7 @@ describe("getDevboxStatus", () => { userDataDir: "/tmp/state", template: null, githubAuth: null, - lastContainerId: null, + lastContainerId: undefined, updatedAt: "2026-03-16T00:00:00.000Z", }, }, diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..cd8d271 --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { DEVBOX_UPDATER_CONFIG, runUpdateCommand } from "../src/update"; + +describe("runUpdateCommand", () => { + test("checks for updates using the devbox release asset contract", async () => { + const output: string[] = []; + const requestedUrls: string[] = []; + const fetchFn = Object.assign( + async (input: RequestInfo | URL) => { + requestedUrls.push(String(input)); + return Response.json({ + tag_name: "v0.2.0", + assets: [ + { + name: "devbox-v0.2.0-linux-x64", + browser_download_url: "https://example.com/devbox-v0.2.0-linux-x64", + }, + { + name: "devbox-v0.2.0-linux-x64.sha256", + browser_download_url: "https://example.com/devbox-v0.2.0-linux-x64.sha256", + }, + ], + }); + }, + { preconnect: () => {} }, + ) as typeof fetch; + + const exitCode = await runUpdateCommand( + { checkOnly: true }, + { + currentVersion: "0.1.0", + out: message => output.push(message), + err: message => output.push(message), + getPlatform: () => ({ platform: "linux", arch: "x64" }), + fetchFn, + }, + ); + + expect(exitCode).toBe(0); + expect(requestedUrls).toEqual(["https://api.github.com/repos/pablozaiden/devbox/releases/latest"]); + expect(output).toContain("Update available: 0.1.0 -> 0.2.0"); + }); + + test("uses the confirmed updater configuration", () => { + expect(DEVBOX_UPDATER_CONFIG).toMatchObject({ + repository: "pablozaiden/devbox", + binaryName: "devbox", + productName: "Devbox", + checksum: { required: true }, + }); + }); +}); From 4b776afde2f97571f436d8ba541ecfcfca8c0ecf Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 8 Jul 2026 15:31:30 +0000 Subject: [PATCH 2/2] ci(release): validate release tag before binary build --- .github/workflows/binary-release.yml | 3 ++- package.json | 3 ++- scripts/validate-release-version.ts | 17 +++++++++++++++++ tests/examples.live.test.ts | 12 ++++++------ 4 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 scripts/validate-release-version.ts diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 9c66130..22e4341 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -11,7 +11,8 @@ jobs: contents: write with: install_command: bun install --frozen-lockfile - prebuild_command: bun run test:fast + update_package_version: false + prebuild_command: bun run validate:release-version && bun run test:fast binaries: | [ { diff --git a/package.json b/package.json index a93b70d..190c493 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "build:release": "rm -rf dist && mkdir -p dist && bun build --compile --target=$BUN_TARGET --outfile dist/devbox-$RELEASE_TARGET src/cli.ts && chmod +x dist/devbox-$RELEASE_TARGET", "dev": "bun run src/cli.ts", "test": "bun test", - "test:fast": "DEVBOX_SKIP_LIVE_EXAMPLE_TESTS=1 bun test" + "test:fast": "DEVBOX_SKIP_LIVE_EXAMPLE_TESTS=1 bun test", + "validate:release-version": "bun run scripts/validate-release-version.ts" }, "dependencies": { "@pablozaiden/installer": "0.0.6", diff --git a/scripts/validate-release-version.ts b/scripts/validate-release-version.ts new file mode 100644 index 0000000..44bdb5c --- /dev/null +++ b/scripts/validate-release-version.ts @@ -0,0 +1,17 @@ +import packageJson from "../package.json"; + +const releaseTag = process.env.TAG ?? process.env.GITHUB_REF_NAME; + +if (releaseTag === undefined || releaseTag.trim() === "") { + throw new Error("Expected TAG or GITHUB_REF_NAME to contain the release tag."); +} + +const normalizedTagVersion = releaseTag.trim().replace(/^v/, ""); + +if (normalizedTagVersion !== packageJson.version) { + throw new Error( + `Release tag version ${releaseTag} does not match package.json version ${packageJson.version}.`, + ); +} + +console.log(`Release tag ${releaseTag} matches package.json version ${packageJson.version}.`); diff --git a/tests/examples.live.test.ts b/tests/examples.live.test.ts index 14697f3..12f7c9c 100644 --- a/tests/examples.live.test.ts +++ b/tests/examples.live.test.ts @@ -246,11 +246,11 @@ describe("example workspaces (real devcontainers)", () => { expect(gitUserEmailInContainer.stdout.trim()).toBe("example@author.test"); const knownHostsInContainer = execInContainer(fixture, firstContainerId, "cat ~/.ssh/known_hosts"); - expect(fixture.knownHostsContent).not.toBeNull(); - if (fixture.knownHostsContent === null) { + const knownHostsContent = fixture.knownHostsContent; + if (knownHostsContent === null) { throw new Error("Expected known_hosts content."); } - expect(knownHostsInContainer.stdout).toBe(fixture.knownHostsContent); + expect(knownHostsInContainer.stdout).toBe(knownHostsContent); const sshAuthSockInContainer = execInContainer( fixture, @@ -258,11 +258,11 @@ describe("example workspaces (real devcontainers)", () => { 'printf "%s" "${SSH_AUTH_SOCK:-}" && test -S "${SSH_AUTH_SOCK:-/missing}"', ); expect(sshAuthSockInContainer.exitCode).toBe(0); - expect(fixture.expectedContainerSshAuthSockPath).not.toBeNull(); - if (fixture.expectedContainerSshAuthSockPath === null) { + const expectedContainerSshAuthSockPath = fixture.expectedContainerSshAuthSockPath; + if (expectedContainerSshAuthSockPath === null) { throw new Error("Expected a container SSH auth sock path."); } - expect(sshAuthSockInContainer.stdout).toBe(fixture.expectedContainerSshAuthSockPath); + expect(sshAuthSockInContainer.stdout).toBe(expectedContainerSshAuthSockPath); const hostKeyInContainer = execInContainerAsRoot(fixture, firstContainerId, "find /etc/ssh -maxdepth 1 -type f -name 'ssh_host_*_key' | head -n 1"); expect(hostKeyInContainer.stdout.trim().length).toBeGreaterThan(0);