Skip to content

Commit 85ffd49

Browse files
grichaclaude
andcommitted
Add Docker registry support and release workflow
- Pull workspace image from ghcr.io/gricha/perry:<version> 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 <noreply@anthropic.com>
1 parent c78d4cf commit 85ffd49

File tree

6 files changed

+123
-15
lines changed

6 files changed

+123
-15
lines changed

.github/workflows/release.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
env:
9+
REGISTRY: ghcr.io
10+
IMAGE_NAME: ${{ github.repository }}
11+
12+
jobs:
13+
release:
14+
runs-on: ubuntu-latest
15+
permissions:
16+
contents: read
17+
packages: write
18+
id-token: write
19+
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Set up Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '22'
27+
registry-url: 'https://registry.npmjs.org'
28+
29+
- name: Set up Bun
30+
uses: oven-sh/setup-bun@v2
31+
32+
- name: Install dependencies
33+
run: |
34+
bun install
35+
cd web && bun install
36+
37+
- name: Build
38+
run: bun run build
39+
40+
- name: Run tests
41+
run: bun run test
42+
43+
- name: Log in to Container Registry
44+
uses: docker/login-action@v3
45+
with:
46+
registry: ${{ env.REGISTRY }}
47+
username: ${{ github.actor }}
48+
password: ${{ secrets.GITHUB_TOKEN }}
49+
50+
- name: Extract version from tag
51+
id: version
52+
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
53+
54+
- name: Build and push Docker image
55+
uses: docker/build-push-action@v5
56+
with:
57+
context: ./perry
58+
push: true
59+
tags: |
60+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
61+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
62+
63+
- name: Publish to npm
64+
run: npm publish --provenance --access public

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
{
22
"name": "@gricha/perry",
3-
"version": "0.1.13",
3+
"version": "0.0.1",
44
"description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.",
55
"type": "module",
66
"bin": {
77
"perry": "./dist/index.js"
88
},
9+
"files": [
10+
"dist"
11+
],
912
"scripts": {
1013
"build": "rm -rf ./dist && bun run build:ts && bun run build:web && bun link",
1114
"build:ts": "tsc && chmod +x dist/index.js",

src/docker/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,28 @@ export async function imageExists(tag: string): Promise<boolean> {
395395
}
396396

397397
export async function pullImage(tag: string): Promise<void> {
398-
await docker(['pull', tag]);
398+
return new Promise((resolve, reject) => {
399+
const child = spawn('docker', ['pull', tag], {
400+
stdio: ['ignore', 'inherit', 'inherit'],
401+
});
402+
child.on('error', reject);
403+
child.on('close', (code) => {
404+
if (code === 0) {
405+
resolve();
406+
} else {
407+
reject(new Error(`Failed to pull image ${tag}`));
408+
}
409+
});
410+
});
411+
}
412+
413+
export async function tryPullImage(tag: string): Promise<boolean> {
414+
try {
415+
await pullImage(tag);
416+
return true;
417+
} catch {
418+
return false;
419+
}
399420
}
400421

401422
export async function buildImage(

src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from './client/docker-proxy';
1717
import { loadAgentConfig, getConfigDir, ensureConfigDir } from './config/loader';
1818
import { buildImage } from './docker';
19-
import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE } from './shared/constants';
19+
import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE_LOCAL } from './shared/constants';
2020

2121
const program = new Command();
2222

@@ -472,10 +472,10 @@ program
472472
.action(async (options) => {
473473
const buildContext = './perry';
474474

475-
console.log(`Building workspace image ${WORKSPACE_IMAGE}...`);
475+
console.log(`Building workspace image ${WORKSPACE_IMAGE_LOCAL}...`);
476476

477477
try {
478-
await buildImage(WORKSPACE_IMAGE, buildContext, {
478+
await buildImage(WORKSPACE_IMAGE_LOCAL, buildContext, {
479479
noCache: options.noCache === false ? false : !options.cache,
480480
});
481481
console.log('Build complete.');

src/shared/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export const DEFAULT_AGENT_PORT = 7391;
33
export const SSH_PORT_RANGE_START = 2200;
44
export const SSH_PORT_RANGE_END = 2400;
55

6-
export const WORKSPACE_IMAGE = 'workspace:latest';
6+
export const WORKSPACE_IMAGE_LOCAL = 'workspace:latest';
7+
export const WORKSPACE_IMAGE_REGISTRY = 'ghcr.io/gricha/perry';
78

89
export const VOLUME_PREFIX = 'workspace-';
910
export const CONTAINER_PREFIX = 'workspace-';

src/workspace/manager.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AddressInfo, createServer } from 'net';
22
import fs from 'fs/promises';
33
import path from 'path';
44
import os from 'os';
5+
import pkg from '../../package.json';
56
import type { AgentConfig } from '../shared/types';
67
import type { Workspace, CreateWorkspaceOptions } from './types';
78
import { StateManager } from './state';
@@ -10,7 +11,8 @@ import * as docker from '../docker';
1011
import { getContainerName } from '../docker';
1112
import {
1213
VOLUME_PREFIX,
13-
WORKSPACE_IMAGE,
14+
WORKSPACE_IMAGE_LOCAL,
15+
WORKSPACE_IMAGE_REGISTRY,
1416
SSH_PORT_RANGE_START,
1517
SSH_PORT_RANGE_END,
1618
} from '../shared/constants';
@@ -32,6 +34,27 @@ async function findAvailablePort(start: number, end: number): Promise<number> {
3234
throw new Error(`No available port in range ${start}-${end}`);
3335
}
3436

37+
async function ensureWorkspaceImage(): Promise<string> {
38+
const registryImage = `${WORKSPACE_IMAGE_REGISTRY}:${pkg.version}`;
39+
40+
const localExists = await docker.imageExists(WORKSPACE_IMAGE_LOCAL);
41+
if (localExists) {
42+
return WORKSPACE_IMAGE_LOCAL;
43+
}
44+
45+
console.log(`Pulling workspace image ${registryImage}...`);
46+
const pulled = await docker.tryPullImage(registryImage);
47+
if (pulled) {
48+
return registryImage;
49+
}
50+
51+
throw new Error(
52+
`Workspace image not found. Either:\n` +
53+
` 1. Run 'perry build' to build locally, or\n` +
54+
` 2. Check your network connection to pull from registry`
55+
);
56+
}
57+
3558
interface CopyCredentialOptions {
3659
source: string;
3760
dest: string;
@@ -373,12 +396,7 @@ export class WorkspaceManager {
373396
await this.state.setWorkspace(workspace);
374397

375398
try {
376-
const imageReady = await docker.imageExists(WORKSPACE_IMAGE);
377-
if (!imageReady) {
378-
throw new Error(
379-
`Workspace image '${WORKSPACE_IMAGE}' not found. Run 'workspace build' first.`
380-
);
381-
}
399+
const workspaceImage = await ensureWorkspaceImage();
382400

383401
if (!(await docker.volumeExists(volumeName))) {
384402
await docker.createVolume(volumeName);
@@ -404,7 +422,7 @@ export class WorkspaceManager {
404422

405423
const containerId = await docker.createContainer({
406424
name: containerName,
407-
image: WORKSPACE_IMAGE,
425+
image: workspaceImage,
408426
hostname: name,
409427
privileged: true,
410428
restartPolicy: 'unless-stopped',
@@ -461,6 +479,7 @@ export class WorkspaceManager {
461479
);
462480
}
463481

482+
const workspaceImage = await ensureWorkspaceImage();
464483
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
465484

466485
const containerEnv: Record<string, string> = {
@@ -480,7 +499,7 @@ export class WorkspaceManager {
480499

481500
const containerId = await docker.createContainer({
482501
name: containerName,
483-
image: WORKSPACE_IMAGE,
502+
image: workspaceImage,
484503
hostname: name,
485504
privileged: true,
486505
restartPolicy: 'unless-stopped',

0 commit comments

Comments
 (0)