Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
23 changes: 22 additions & 1 deletion src/docker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,28 @@ export async function imageExists(tag: string): Promise<boolean> {
}

export async function pullImage(tag: string): Promise<void> {
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<boolean> {
try {
await pullImage(tag);
return true;
} catch {
return false;
}
}

export async function buildImage(
Expand Down
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.');
Expand Down
3 changes: 2 additions & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-';
Expand Down
37 changes: 28 additions & 9 deletions src/workspace/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -32,6 +34,27 @@ async function findAvailablePort(start: number, end: number): Promise<number> {
throw new Error(`No available port in range ${start}-${end}`);
}

async function ensureWorkspaceImage(): Promise<string> {
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;
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand Down Expand Up @@ -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<string, string> = {
Expand All @@ -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',
Expand Down
Loading