docs(plan): add implementation plan for Docker packaging and GitHub Actions CI pipeline

This commit is contained in:
Lucas Winther
2026-09-06 20:37:44 +02:00
parent 7ce86012d0
commit 1a32b8a179
@@ -0,0 +1,431 @@
# Docker Packaging & GitHub Actions CI/CD Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Package `openproject-mcp` into an official, multi-platform Docker container image and build an automated GitHub Actions CI/CD pipeline that runs tests, builds multi-arch container images (`linux/amd64`, `linux/arm64`), and publishes them to GitHub Container Registry (`ghcr.io`).
**Architecture:** A multi-stage `Dockerfile` based on `oven/bun:1-slim` installs production dependencies in a builder stage, copies them into a minimal runner stage running as non-root user `bun`, and executes `src/index.ts` over stdio. A unified GitHub Actions workflow `.github/workflows/ci.yml` coordinates testing (`bun run typecheck`, `bun test`) with multi-arch Docker image compilation via QEMU and Buildx, publishing to GHCR on main branch commits and release tags.
**Tech Stack:** Docker, Bun (v1.3.14), GitHub Actions (`setup-bun`, `setup-qemu`, `setup-buildx`, `metadata-action`, `login-action`, `build-push-action`), GHCR.
**Spec:** `docs/specs/2026-09-06-docker-ci-pipeline-design.md`
## Global Constraints
- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution.
- Base Container Image: `oven/bun:1-slim` with multi-stage build and unprivileged user `bun` (UID 1000).
- Transport: Interactive stdio execution (`ENTRYPOINT ["bun", "run", "src/index.ts"]`), allowing CLI flags like `--read-only` to be passed directly.
- Context Hygiene: `.dockerignore` must exclude `.env*`, `node_modules`, `tests`, `docs`, `scripts`, `.git`, and temporary files.
- CI Workflow: Single workflow file `.github/workflows/ci.yml` with `test` and `docker` jobs (`needs: test`).
- CI Platforms: Multi-arch `linux/amd64,linux/arm64` with GitHub Actions cache (`type=gha`).
- Stdio stream hygiene: Container stdout strictly reserved for JSON-RPC, diagnostics sent to stderr.
---
### Task 1: Multi-Stage Dockerfile & .dockerignore
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
- Create: `tests/docker.test.ts`
**Interfaces:**
- Consumes: `package.json`, `bun.lock`, `tsconfig.json`, `src/index.ts`
- Produces: Production Docker image artifact and local build verification test suite
- [ ] **Step 1: Write the failing test in `tests/docker.test.ts`**
```typescript
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
describe("Docker Container Packaging", () => {
test("Dockerfile exists and defines multi-stage build with non-root user", () => {
const dockerfilePath = resolve(ROOT_DIR, "Dockerfile");
expect(existsSync(dockerfilePath)).toBe(true);
const content = readFileSync(dockerfilePath, "utf-8");
expect(content).toContain("FROM oven/bun:1-slim AS builder");
expect(content).toContain("FROM oven/bun:1-slim AS runner");
expect(content).toContain("bun install --frozen-lockfile --production");
expect(content).toContain("USER bun");
expect(content).toContain('ENTRYPOINT ["bun", "run", "src/index.ts"]');
});
test(".dockerignore exists and excludes sensitive and unnecessary files", () => {
const ignorePath = resolve(ROOT_DIR, ".dockerignore");
expect(existsSync(ignorePath)).toBe(true);
const content = readFileSync(ignorePath, "utf-8");
expect(content).toContain("node_modules");
expect(content).toContain(".env*");
expect(content).toContain(".git");
expect(content).toContain("tests");
expect(content).toContain("docs");
expect(content).toContain("scripts");
});
test("Docker image builds and starts up cleanly in container", async () => {
// Check if docker daemon is available in test environment
const proc = Bun.spawn(["which", "docker"], { stdout: "pipe", stderr: "pipe" });
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.warn("Docker CLI not available, skipping container build execution test");
return;
}
// Build test image
const buildProc = Bun.spawn(["docker", "build", "-t", "openproject-mcp:test", "."], {
cwd: ROOT_DIR,
stdout: "pipe",
stderr: "pipe",
});
const buildExit = await buildProc.exited;
expect(buildExit).toBe(0);
// Run container with test env and check stderr banner
const runProc = Bun.spawn(
[
"docker",
"run",
"-i",
"--rm",
"-e",
"OPENPROJECT_BASE_URL=http://localhost:8080",
"-e",
"OPENPROJECT_API_KEY=test-token",
"openproject-mcp:test",
"--read-only",
],
{
cwd: ROOT_DIR,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}
);
// Give it 1.5s to start and write banner to stderr
await Bun.sleep(1500);
runProc.kill();
await runProc.exited;
const stderrText = await new Response(runProc.stderr).text();
expect(stderrText).toContain("[openproject-mcp] Server started (readOnly=true)");
}, 60000);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/docker.test.ts`
Expected: FAIL because `Dockerfile` and `.dockerignore` do not exist yet.
- [ ] **Step 3: Implement `Dockerfile` and `.dockerignore`**
Create `Dockerfile`:
```dockerfile
# Stage 1: Dependency resolution and production prune
FROM oven/bun:1-slim AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json bun.lock ./
# Install production dependencies only with strict lockfile
RUN bun install --frozen-lockfile --production
# Stage 2: Minimal runtime image
FROM oven/bun:1-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy installed production node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application configuration and source code
COPY package.json tsconfig.json ./
COPY src ./src
# Set ownership and drop root privileges
RUN chown -R bun:bun /app
USER bun
# Standard MCP stdio communication
ENTRYPOINT ["bun", "run", "src/index.ts"]
```
Create `.dockerignore`:
```
node_modules
.git
.github
.env*
coverage
tests
docs
scripts
.superpowers
*.log
*.md
docker-compose.yml
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/docker.test.ts`
Expected: PASS (all 3 tests pass including local docker build and container execution).
- [ ] **Step 5: Commit**
```bash
git add Dockerfile .dockerignore tests/docker.test.ts
git commit -m "feat(docker): add multi-stage Dockerfile and .dockerignore"
```
---
### Task 2: GitHub Actions CI/CD Pipeline Workflow
**Files:**
- Create: `.github/workflows/ci.yml`
- Create: `tests/ci-workflow.test.ts`
**Interfaces:**
- Consumes: `Dockerfile`, `.dockerignore`, `package.json`, `tests/`
- Produces: Automated GitHub Actions CI workflow triggered on PRs, main pushes, tags, and workflow_dispatch
- [ ] **Step 1: Write the failing test in `tests/ci-workflow.test.ts`**
```typescript
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
const WORKFLOW_PATH = resolve(ROOT_DIR, ".github/workflows/ci.yml");
describe("GitHub Actions CI Workflow", () => {
test("ci.yml exists and has proper triggers and permissions", () => {
expect(existsSync(WORKFLOW_PATH)).toBe(true);
const content = readFileSync(WORKFLOW_PATH, "utf-8");
// Triggers
expect(content).toContain("pull_request:");
expect(content).toContain("push:");
expect(content).toContain("workflow_dispatch:");
expect(content).toContain("- main");
expect(content).toContain("- 'v*.*.*'");
// Permissions
expect(content).toContain("permissions:");
expect(content).toContain("contents: read");
expect(content).toContain("packages: write");
});
test("ci.yml defines test job with Bun 1.3.14 and strict checks", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("test:");
expect(content).toContain("runs-on: ubuntu-latest");
expect(content).toContain("oven-sh/setup-bun@v2");
expect(content).toContain("bun-version: 1.3.14");
expect(content).toContain("bun install --frozen-lockfile");
expect(content).toContain("bun run typecheck");
expect(content).toContain("bun test");
});
test("ci.yml defines docker job with multi-arch buildx and ghcr publishing", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("docker:");
expect(content).toContain("needs: test");
expect(content).toContain("docker/setup-qemu-action@v3");
expect(content).toContain("docker/setup-buildx-action@v3");
expect(content).toContain("docker/metadata-action@v5");
expect(content).toContain("ghcr.io/${{ github.repository }}");
expect(content).toContain("docker/login-action@v3");
expect(content).toContain("registry: ghcr.io");
expect(content).toContain("docker/build-push-action@v6");
expect(content).toContain("platforms: linux/amd64,linux/arm64");
expect(content).toContain("push: ${{ github.event_name != 'pull_request' }}");
expect(content).toContain("type=gha");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/ci-workflow.test.ts`
Expected: FAIL because `.github/workflows/ci.yml` does not exist yet.
- [ ] **Step 3: Implement `.github/workflows/ci.yml`**
Create `.github/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Run test suite
run: bun test
docker:
name: Build & Publish Docker Image
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/ci-workflow.test.ts`
Expected: PASS (all tests pass).
- [ ] **Step 5: Commit**
```bash
git add .github/workflows/ci.yml tests/ci-workflow.test.ts
git commit -m "ci: create GitHub Actions workflow for testing and multi-arch Docker publishing"
```
---
### Task 3: Documentation, ADR-015 & Roadmap Tracking
**Files:**
- Modify: `docs/DECISIONS.md`
- Modify: `docs/TODO.md`
- Modify: `README.md`
**Interfaces:**
- Consumes: Docker packaging artifacts and CI workflow
- Produces: Updated architectural records, user documentation, and project roadmap tracking
- [ ] **Step 1: Add ADR-015 to `docs/DECISIONS.md`**
Append ADR-015 to `docs/DECISIONS.md`:
```markdown
## ADR-015: Docker Container Packaging and GitHub Actions CI/CD Pipeline
### Context
Users and client LLM applications (such as Claude Desktop, Cursor, or containerized agents) require a lightweight, zero-dependency method to run `openproject-mcp` without manually installing Bun or local development dependencies. Furthermore, pull requests and releases require automated validation to guarantee that tests pass and container images are securely built and published to GitHub Container Registry (`ghcr.io`).
### Decision
1. **Runtime Base Image**: Adopt `oven/bun:1-slim` with a multi-stage Docker build. The builder stage installs production dependencies (`bun install --frozen-lockfile --production`), and the runner stage copies only production node_modules and application sources.
2. **Security & Permissions**: Run container execution under the unprivileged `bun` user (UID 1000). Exclude sensitive environment files (`.env*`), git records, and local tokens via `.dockerignore`.
3. **Multi-Architecture**: Build container images for both `linux/amd64` and `linux/arm64` via Docker Buildx and QEMU.
4. **Interactive Stdio Execution**: Configure container `ENTRYPOINT ["bun", "run", "src/index.ts"]` with standard I/O streaming, allowing runtime options (such as `--read-only`) to be appended directly.
5. **Continuous Integration**: Implement a two-stage GitHub Actions workflow (`.github/workflows/ci.yml`):
- `test`: Executes `bun run typecheck` and `bun test` on PRs and main pushes.
- `docker`: Builds multi-arch images with `type=gha` cache, publishing to `ghcr.io` on pushes to `main` and release tags (`v*.*.*`), while validating builds without push on pull requests.
### Consequences
- Provides seamless Docker execution for desktop and server MCP clients via `docker run -i --rm ghcr.io/<owner>/openproject-mcp:latest`.
- Prevents container build regressions through automated PR verification.
- Guarantees multi-architecture compatibility across Apple Silicon and x86_64 host machines.
```
- [ ] **Step 2: Update `README.md` with Docker Quickstart**
Add a Docker section in `README.md` explaining:
- How to pull and run via `docker run -i --rm`
- Environment variables (`OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`)
- How to enable `--read-only` mode
- Configuration snippets for Claude Desktop and Cursor
- [ ] **Step 3: Update `docs/TODO.md`**
Record the Docker Container Packaging and GitHub Actions CI/CD Pipeline under completed tasks in `docs/TODO.md`.
- [ ] **Step 4: Run full test suite**
Run: `bun test`
Expected: PASS (all test files pass, 0 failures).
- [ ] **Step 5: Commit**
```bash
git add docs/DECISIONS.md docs/TODO.md README.md
git commit -m "docs: record ADR-015, update README with Docker usage, and update TODO.md"
```