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
129 changes: 63 additions & 66 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
set -euo pipefail
# Root package.json is the canonical version (kept in sync with both packages).
base_version="$(git show "${BASE_SHA}:package.json" | jq -r .version)"
next="$(node .github/scripts/next-version.mjs "${base_version}" "${LABELS}")"
prerelease="${next}-build.${RUN_NUMBER}"
Expand All @@ -60,11 +61,13 @@ jobs:
echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT"
echo "Prerelease version: ${prerelease} (from ${base_version})"

- name: Set package version
- name: Set package versions
run: |
set -euo pipefail
jq --arg v "${{ steps.version.outputs.prerelease }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
for pkg in package.json packages/agent-gwt/package.json packages/clanker-cleanroom/package.json; do
jq --arg v "${{ steps.version.outputs.prerelease }}" '.version = $v' "${pkg}" > "${pkg}.tmp"
mv "${pkg}.tmp" "${pkg}"
done

- name: Lint
run: pnpm run lint
Expand All @@ -75,14 +78,16 @@ jobs:
- name: Build
run: pnpm run build

- name: Publish prerelease
- name: Publish prereleases
run: |
set -euo pipefail
pnpm publish \
--access public \
--tag "pr-${{ github.event.pull_request.number }}" \
--no-git-checks \
--ignore-scripts
for filter in clanker-cleanroom agent-gwt; do
pnpm --filter "${filter}" publish \
--access public \
--tag "pr-${{ github.event.pull_request.number }}" \
--no-git-checks \
--ignore-scripts
done

release:
if: >-
Expand Down Expand Up @@ -126,69 +131,52 @@ jobs:
echo "pr_tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "Release version: ${next} (from ${base_version}, tag pr-${PR_NUMBER})"

- name: Download prerelease tarball
id: pack
- name: Download and stage packages
id: stage
env:
PR_TAG: ${{ steps.version.outputs.pr_tag }}
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
# Work outside the git checkout — pnpm stage publish refuses unclean trees
workdir="${RUNNER_TEMP}/agent-gwt-stage"
mkdir -p "${workdir}"
cd "${workdir}"
# Avoid empty _authToken from setup-node's .npmrc when fetching a public package
npm pack "agent-gwt@${PR_TAG}" --userconfig /dev/null --registry https://registry.npmjs.org
tarball="$(ls -1 agent-gwt-*.tgz | head -n1)"
mkdir -p staged
tar -xzf "${tarball}" -C staged
jq --arg v "${NEXT}" '.version = $v' staged/package/package.json > staged/package/package.json.tmp
mv staged/package/package.json.tmp staged/package/package.json
echo "package_dir=${workdir}/staged/package" >> "$GITHUB_OUTPUT"
echo "Rewrote staged package version to ${NEXT} at ${workdir}/staged/package"

- name: Stage release
id: stage
env:
PACKAGE_DIR: ${{ steps.pack.outputs.package_dir }}
run: |
set -euo pipefail
# Keep stderr (WARN/OIDC logs) out of --json stdout so jq can parse.
err="$(mktemp)"
set +e
output="$(pnpm stage publish "${PACKAGE_DIR}" --access public --tag latest --json 2>"${err}")"
status=$?
set -e
cat "${err}" >&2 || true
rm -f "${err}"
printf '%s\n' "${output}"
if [[ "${status}" -ne 0 ]]; then
exit "${status}"
fi
if ! printf '%s\n' "${output}" | jq -e 'type == "object"' >/dev/null; then
echo "Stage publish did not return JSON" >&2
exit 1
fi
if printf '%s\n' "${output}" | jq -e '.error' >/dev/null; then
echo "Stage publish returned an error payload" >&2
exit 1
fi
stage_id="$(printf '%s\n' "${output}" | jq -r 'to_entries[0].value.stageId // empty')"
echo "stage_id=${stage_id}" >> "$GITHUB_OUTPUT"
stage_ids=()
for name in clanker-cleanroom agent-gwt; do
cd "${workdir}"
rm -rf staged "${name}"-*.tgz
npm pack "${name}@${PR_TAG}" --userconfig /dev/null --registry https://registry.npmjs.org
tarball="$(ls -1 ${name}-*.tgz | head -n1)"
mkdir -p staged
tar -xzf "${tarball}" -C staged
jq --arg v "${NEXT}" '
.version = $v
| if .dependencies["clanker-cleanroom"] then .dependencies["clanker-cleanroom"] = $v else . end
' staged/package/package.json > staged/package/package.json.tmp
mv staged/package/package.json.tmp staged/package/package.json
err="$(mktemp)"
set +e
output="$(pnpm stage publish "${workdir}/staged/package" --access public --tag latest --json 2>"${err}")"
status=$?
set -e
cat "${err}" >&2 || true
rm -f "${err}"
printf '%s\n' "${output}"
if [[ "${status}" -ne 0 ]]; then
exit "${status}"
fi
stage_id="$(printf '%s\n' "${output}" | jq -r 'to_entries[0].value.stageId // empty')"
stage_ids+=("${name}:${stage_id}")
done
{
echo "## Staged release"
echo ""
echo "Version **${{ steps.version.outputs.next }}** was staged from \`${{ steps.version.outputs.pr_tag }}\`."
echo "Version **${NEXT}** was staged from \`${PR_TAG}\`."
echo ""
if [[ -n "${stage_id}" ]]; then
echo "Approve with:"
echo ""
echo "\`\`\`bash"
echo "pnpm stage approve ${stage_id}"
echo "\`\`\`"
else
echo "Approve from the [Staged Packages](https://www.npmjs.com/stages) tab or via \`pnpm stage list\` / \`pnpm stage approve\`."
fi
for entry in "${stage_ids[@]}"; do
pkg="${entry%%:*}"
sid="${entry#*:}"
echo "- **${pkg}**: \`pnpm stage approve ${sid}\`"
done
echo ""
echo "Approval requires 2FA and cannot run via OIDC."
} >> "$GITHUB_STEP_SUMMARY"
Expand All @@ -198,16 +186,25 @@ jobs:
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
jq --arg v "${NEXT}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
packages=(package.json packages/agent-gwt/package.json packages/clanker-cleanroom/package.json)
for pkg in "${packages[@]}"; do
if [[ ! -f "${pkg}" ]]; then
echo "Missing ${pkg}; expected synced monorepo versions" >&2
exit 1
fi
jq --arg v "${NEXT}" '.version = $v' "${pkg}" > "${pkg}.tmp"
mv "${pkg}.tmp" "${pkg}"
done
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json
git add "${packages[@]}"
git commit -m "chore: release v${NEXT}"
git push origin main

- name: Remove PR dist-tag
- name: Remove PR dist-tags
continue-on-error: true
env:
PR_TAG: ${{ steps.version.outputs.pr_tag }}
run: npm dist-tag rm agent-gwt "${PR_TAG}" || true
run: |
npm dist-tag rm agent-gwt "${PR_TAG}" || true
npm dist-tag rm clanker-cleanroom "${PR_TAG}" || true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage/
.DS_Store
.wireit/
.codegraph/
clanker-cleanroom.images.json
52 changes: 35 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

## PR builds

Open PRs against `main` publish a prerelease under the dist-tag `pr-<number>`:
Open PRs against `main` publish prereleases under the dist-tag `pr-<number>` for **both** workspace packages:

```bash
pnpm add -D agent-gwt@pr-123
pnpm add -D clanker-cleanroom@pr-123 agent-gwt@pr-123
```

pnpm caches aggressively, so after the pipeline publishes a newer build to the same tag, force a re-resolve:

```bash
pnpm update agent-gwt@pr-123
pnpm update clanker-cleanroom@pr-123 agent-gwt@pr-123
```

## Releasing
Expand All @@ -27,20 +27,38 @@ Bump size is controlled by PR labels (`major` > `minor` > patch default). See [P

## Testing

`pnpm test` runs the unit suite with Docker mocked. `pnpm run test:e2e` runs `e2e/` against the real agent images. It needs Docker, and each agent's tests run only when that agent's credential is present on the host: Cursor needs `agent login` (`~/.config/cursor/auth.json`), Claude needs `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` in the environment. `globalSetup` builds the images for the agents that have credentials and the rest skip cleanly. On Apple Silicon export `DOCKER_DEFAULT_PLATFORM=linux/amd64` first.
```bash
pnpm install
pnpm run build
pnpm run test
pnpm run lint
```

`pnpm test` runs unit suites (Docker mocked) in both workspace packages. `pnpm run test:e2e` runs `packages/agent-gwt/e2e/` against real agent images. It needs Docker, and each agent's tests run only when that agent's credential is present on the host: Cursor needs `agent login` (`~/.config/cursor/auth.json`), Claude needs `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` in the environment. `globalSetup` calls `buildImages()` when any credential is present. On Apple Silicon export `DOCKER_DEFAULT_PLATFORM=linux/amd64` first.

## Architecture

| Layer | Role |
| ----------------- | -------------------------------------------------------------------------------------- |
| `given` / `when` | Agent-agnostic GWT DSL (no imports of `agents/<name>`) |
| `agents/registry` | Maps agent names → `Agent` |
| `agents/` | Shared `createAgent`, Docker invoke, image ensure/build |
| `agents/base/` | Shared Arch base image constants (`agent-gwt/base:local`) |
| `agents/cursor/` | Cursor bindings only (Dockerfile path, image, auth, CLI run) |
| `agents/claude/` | Claude Code bindings only (Dockerfile path, image, credentials, CLI run) |
| `docker/base/` | Shared Arch + yay Dockerfile (all agents `FROM` this tag) |
| `docker/<agent>/` | Per-agent Dockerfile (`FROM agent-gwt/base:local` + that product’s CLI) |
| `package-root` | Relative resolve to this package’s root (`src/` or `lib/` parent) — no directory scans |

Additional agents (Devin, Copilot, …) add `docker/<name>/Dockerfile` on the shared base, a folder under `agents/`, and a registry entry.
pnpm workspace with two packages:

| Package | Role |
| ---------------------------- | ------------------------------------------------------------------- |
| `packages/clanker-cleanroom` | Docker folder-graph builds, image registry JSON, agent run bindings |
| `packages/agent-gwt` | GWT steps (`given` / `when`) that call into `clanker-cleanroom` |

### `clanker-cleanroom`

| Layer | Role |
| --------------------- | --------------------------------------------------------------------------------- |
| `docker/*.Dockerfile` | Stock images; first line `# clanker-cleanroom/<name>`, `FROM` local tags for deps |
| `images/` | Parse folder → DAG → `buildImages` → `clanker-cleanroom.images.json` |
| `agents/` | Cursor/Claude bindings, `Agent` class, `runDocker` |
| `package-root` | Resolves installed package root so stock Dockerfiles come from `node_modules` |

Additional agents add a `*.Dockerfile` (with `# clanker-cleanroom/<name>` + `FROM clanker-cleanroom/base`), a folder under `agents/`, and a registry entry.

### `agent-gwt`

| Layer | Role |
| ---------------- | ------------------------------------------------------------ |
| `given` / `when` | Agent-agnostic GWT DSL |
| Re-exports | Soft-break surface for `buildImages`, agents, docker helpers |
66 changes: 49 additions & 17 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,39 @@

CI publishes via [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) (OIDC) from [`.github/workflows/publish.yml`](.github/workflows/publish.yml). There is no `NPM_TOKEN` secret.

This repo is a pnpm workspace with two publishable packages:

| Package | Path |
| ------------------- | ---------------------------------------------------------- |
| `clanker-cleanroom` | [`packages/clanker-cleanroom`](packages/clanker-cleanroom) |
| `agent-gwt` | [`packages/agent-gwt`](packages/agent-gwt) |

**Versions stay in sync** across three `package.json` files:

1. Root [`package.json`](package.json) (canonical for CI bumps)
2. [`packages/agent-gwt/package.json`](packages/agent-gwt/package.json)
3. [`packages/clanker-cleanroom/package.json`](packages/clanker-cleanroom/package.json)

CI publishes `clanker-cleanroom` first, then `agent-gwt` (so the rewritten `workspace:*` dependency resolves).

## One-time setup

### 1. Bootstrap the package on npm
### 1. Bootstrap each package on npm

Staged publishing requires the package to already exist. From a clean build, publish the initial version once (manually or with a temporary token):
Staged publishing and OIDC trusted publishers require the package name to already exist. From a clean build, publish each package once (manually or with a temporary token), **`clanker-cleanroom` first**:

```bash
pnpm install
pnpm run lint && pnpm run test && pnpm run build
pnpm publish --access public --ignore-scripts
pnpm --filter clanker-cleanroom publish --access public --ignore-scripts
pnpm --filter agent-gwt publish --access public --ignore-scripts
```

### 2. Configure the trusted publisher
`agent-gwt` is already on npm; only `clanker-cleanroom` needs a first-time create if it has never been published.

### 2. Configure the trusted publisher (each package)

On [npmjs.com](https://www.npmjs.com) → `agent-gwt` → Settings → Trusted Publisher:
On [npmjs.com](https://www.npmjs.com) → package → Settings → Trusted Publisher, for **both** `clanker-cleanroom` and `agent-gwt`:

| Field | Value |
| -------------------- | --------------------------------------------------- |
Expand All @@ -39,28 +57,42 @@ The release job commits `chore: release vX.Y.Z` to `main` with `GITHUB_TOKEN` (t

## How it works

| Event | What happens |
| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| PR open/sync/label against `main` | Build → `pnpm publish --tag pr-<n>` as `<next>-build.<run>` |
| PR merged to `main` | `npm pack agent-gwt@pr-<n>` → rewrite version → `pnpm stage publish` → bump `package.json` on `main` |
| Maintainer | `pnpm stage approve <id>` (2FA) or Approve on npmjs.com |
| Event | What happens |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| PR open/sync/label against `main` | Build → set all three versions → publish **both** packages under `--tag pr-<n>` as `<next>-build.<run>` |
| PR merged to `main` | `npm pack` each `@pr-<n>` → rewrite version → `pnpm stage publish` each → bump all three `package.json` files on `main` |
| Maintainer | `pnpm stage approve <id>` (2FA) or Approve on npmjs.com **for each staged package** |

### Version labels

| Label | Bump from `main`'s `package.json` |
| -------- | --------------------------------- |
| _(none)_ | patch |
| `minor` | minor |
| `major` | major |
| Label | Bump from root `package.json` on `main` |
| -------- | --------------------------------------- |
| _(none)_ | patch |
| `minor` | minor |
| `major` | major |

If both `major` and `minor` are present, `major` wins. The release version is recomputed from `main` at merge time so concurrent PRs stay monotonic.
If both `major` and `minor` are present, `major` wins. The release version is recomputed from `main` at merge time so concurrent PRs stay monotonic. Root + both workspace packages are set to that same version.

## Approving a staged release

Each package gets its own stage id (see the workflow job summary):

```bash
pnpm stage list
pnpm stage view <stage-id>
pnpm stage approve <stage-id>
pnpm stage approve <stage-id> # once per package
```

Or use the Staged Packages UI on npmjs.com. Approve/reject require interactive 2FA and cannot use OIDC.

## Install prereleases

```bash
pnpm add -D clanker-cleanroom@pr-123 agent-gwt@pr-123
```

After a newer build is published to the same tag:

```bash
pnpm update clanker-cleanroom@pr-123 agent-gwt@pr-123
```
Loading