From 85ffd49b2428c15603699cf1cff115ef25deff5d Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 5 Jan 2026 06:29:21 +0000 Subject: [PATCH] Add Docker registry support and release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pull workspace image from ghcr.io/gricha/perry: when local image not found - Fall back to local build if registry pull fails - Add release workflow that builds/pushes Docker image and publishes to npm - Reset version to 0.0.1 for fresh start - Add files field to package.json for clean npm publishes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 64 +++++++++++++++++++++++++++++++++++ package.json | 5 ++- src/docker/index.ts | 23 ++++++++++++- src/index.ts | 6 ++-- src/shared/constants.ts | 3 +- src/workspace/manager.ts | 37 +++++++++++++++----- 6 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..579d32bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: | + bun install + cd web && bun install + + - name: Build + run: bun run build + + - name: Run tests + run: bun run test + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./perry + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Publish to npm + run: npm publish --provenance --access public diff --git a/package.json b/package.json index 6218f9b4..b056a115 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "@gricha/perry", - "version": "0.1.13", + "version": "0.0.1", "description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.", "type": "module", "bin": { "perry": "./dist/index.js" }, + "files": [ + "dist" + ], "scripts": { "build": "rm -rf ./dist && bun run build:ts && bun run build:web && bun link", "build:ts": "tsc && chmod +x dist/index.js", diff --git a/src/docker/index.ts b/src/docker/index.ts index dbb8f21c..a11bf9f4 100644 --- a/src/docker/index.ts +++ b/src/docker/index.ts @@ -395,7 +395,28 @@ export async function imageExists(tag: string): Promise { } export async function pullImage(tag: string): Promise { - await docker(['pull', tag]); + return new Promise((resolve, reject) => { + const child = spawn('docker', ['pull', tag], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Failed to pull image ${tag}`)); + } + }); + }); +} + +export async function tryPullImage(tag: string): Promise { + try { + await pullImage(tag); + return true; + } catch { + return false; + } } export async function buildImage( diff --git a/src/index.ts b/src/index.ts index a71a104e..5a60275a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { } from './client/docker-proxy'; import { loadAgentConfig, getConfigDir, ensureConfigDir } from './config/loader'; import { buildImage } from './docker'; -import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE } from './shared/constants'; +import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE_LOCAL } from './shared/constants'; const program = new Command(); @@ -472,10 +472,10 @@ program .action(async (options) => { const buildContext = './perry'; - console.log(`Building workspace image ${WORKSPACE_IMAGE}...`); + console.log(`Building workspace image ${WORKSPACE_IMAGE_LOCAL}...`); try { - await buildImage(WORKSPACE_IMAGE, buildContext, { + await buildImage(WORKSPACE_IMAGE_LOCAL, buildContext, { noCache: options.noCache === false ? false : !options.cache, }); console.log('Build complete.'); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 528b9f43..c7344d01 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,7 +3,8 @@ export const DEFAULT_AGENT_PORT = 7391; export const SSH_PORT_RANGE_START = 2200; export const SSH_PORT_RANGE_END = 2400; -export const WORKSPACE_IMAGE = 'workspace:latest'; +export const WORKSPACE_IMAGE_LOCAL = 'workspace:latest'; +export const WORKSPACE_IMAGE_REGISTRY = 'ghcr.io/gricha/perry'; export const VOLUME_PREFIX = 'workspace-'; export const CONTAINER_PREFIX = 'workspace-'; diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 7d233bf0..25e8032e 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -2,6 +2,7 @@ import { AddressInfo, createServer } from 'net'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import pkg from '../../package.json'; import type { AgentConfig } from '../shared/types'; import type { Workspace, CreateWorkspaceOptions } from './types'; import { StateManager } from './state'; @@ -10,7 +11,8 @@ import * as docker from '../docker'; import { getContainerName } from '../docker'; import { VOLUME_PREFIX, - WORKSPACE_IMAGE, + WORKSPACE_IMAGE_LOCAL, + WORKSPACE_IMAGE_REGISTRY, SSH_PORT_RANGE_START, SSH_PORT_RANGE_END, } from '../shared/constants'; @@ -32,6 +34,27 @@ async function findAvailablePort(start: number, end: number): Promise { throw new Error(`No available port in range ${start}-${end}`); } +async function ensureWorkspaceImage(): Promise { + const registryImage = `${WORKSPACE_IMAGE_REGISTRY}:${pkg.version}`; + + const localExists = await docker.imageExists(WORKSPACE_IMAGE_LOCAL); + if (localExists) { + return WORKSPACE_IMAGE_LOCAL; + } + + console.log(`Pulling workspace image ${registryImage}...`); + const pulled = await docker.tryPullImage(registryImage); + if (pulled) { + return registryImage; + } + + throw new Error( + `Workspace image not found. Either:\n` + + ` 1. Run 'perry build' to build locally, or\n` + + ` 2. Check your network connection to pull from registry` + ); +} + interface CopyCredentialOptions { source: string; dest: string; @@ -373,12 +396,7 @@ export class WorkspaceManager { await this.state.setWorkspace(workspace); try { - const imageReady = await docker.imageExists(WORKSPACE_IMAGE); - if (!imageReady) { - throw new Error( - `Workspace image '${WORKSPACE_IMAGE}' not found. Run 'workspace build' first.` - ); - } + const workspaceImage = await ensureWorkspaceImage(); if (!(await docker.volumeExists(volumeName))) { await docker.createVolume(volumeName); @@ -404,7 +422,7 @@ export class WorkspaceManager { const containerId = await docker.createContainer({ name: containerName, - image: WORKSPACE_IMAGE, + image: workspaceImage, hostname: name, privileged: true, restartPolicy: 'unless-stopped', @@ -461,6 +479,7 @@ export class WorkspaceManager { ); } + const workspaceImage = await ensureWorkspaceImage(); const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END); const containerEnv: Record = { @@ -480,7 +499,7 @@ export class WorkspaceManager { const containerId = await docker.createContainer({ name: containerName, - image: WORKSPACE_IMAGE, + image: workspaceImage, hostname: name, privileged: true, restartPolicy: 'unless-stopped',