diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8644d67 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Build context for the dashboard image (see Dockerfile). Keep it close to .gitignore so the image +# is built from what a clean checkout contains, and never from local secrets or build output. +.git +.github +.husky/_ +.vscode +**/node_modules +**/.turbo +**/dist +**/.output +**/.nuxt +**/.vercel +**/.vitehub +**/.maizzle +**/.data +**/.code-zero +**/coverage +**/playwright-report +**/test-results +**/*.tsbuildinfo +**/*.log +.skilld +.env +.env.* +**/.env +**/.env.* +!**/.env.example +code-zero.deployment.yml +Dockerfile +.dockerignore diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..df42c9d --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,172 @@ +name: cd + +# Builds the dashboard container image from the root Dockerfile and publishes it to the GitHub +# Container Registry, modelled on wolfstar-project/.github's reusable-publish-image workflow: +# each platform builds natively on its own runner and pushes by digest, then one job merges the +# digests into a single multi-arch manifest list. The resulting image runs on Docker, Railway, or +# any other container platform (see the Dockerfile for the runtime contract). + +on: + push: + branches: + - main + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: 🐳 Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read # checkout repository + packages: write # push image layers to ghcr.io + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - name: Prepare + env: + PLATFORM: ${{ matrix.platform }} + run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # A tag builds the `release` channel only when it is exactly `v` of the dashboard + # package, so a stray or stale tag cannot publish an image that reports a different release. + - name: Resolve build environment + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [[ "$REF_TYPE" == "tag" ]]; then + version="$(node --print "require('./apps/dashboard/package.json').version")" + if [[ "$REF_NAME" != "v$version" ]]; then + echo "::error::Tag $REF_NAME does not match apps/dashboard version v$version" >&2 + exit 1 + fi + build_env=release + elif [[ "$REF_NAME" == "$DEFAULT_BRANCH" ]]; then + build_env=canary + else + build_env=preview + fi + echo "BUILD_ENV=$build_env" >> "$GITHUB_ENV" + + - name: Extract metadata (labels) for Docker + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + CODE_ZERO_BUILD_COMMIT=${{ github.sha }} + CODE_ZERO_BUILD_BRANCH=${{ github.ref_name }} + CODE_ZERO_BUILD_ENV=${{ env.BUILD_ENV }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: 📦 Create and push manifest list + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + permissions: + packages: write # push the manifest list to ghcr.io + steps: + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + # `latest` follows the default branch, so a Railway or Docker service pointed at + # `ghcr.io//:latest` tracks `main`; release tags add semver tags alongside. + - name: Docker meta + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=long,prefix= + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + # shellcheck disable=SC2046 + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${IMAGE}@sha256:%s " *) + + - name: Inspect image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + VERSION: ${{ steps.meta.outputs.version }} + run: docker buildx imagetools inspect "${IMAGE}:${VERSION}" diff --git a/AGENTS.md b/AGENTS.md index c6a71b9..9aee323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,15 +175,16 @@ aube run build ## CI/CD -| Workflow | Purpose | Trigger | -| ---------------------------- | --------------------------------------------------------- | ------------------------------- | -| `ci.yaml` | Lint, repository metadata, typecheck, tests, build, i18n | PR, push to `main`, merge group | -| `autofix.yml` | Pushes formatting and lint fixes back to the pull request | PR, merge group | -| `zizmor.yaml` | Static analysis of GitHub Actions workflows | PR, push to `main`, merge group | -| `semantic-pull-requests.yml` | Validates PR titles against Conventional Commits | PR opened, edited, synchronized | -| `release.yaml` | Validates release artifacts | Manual dispatch | -| `labelsync.yml` | Syncs repository labels | Daily schedule, manual dispatch | -| `stale.yml` | Marks and closes stale issues and pull requests | Daily schedule, manual dispatch | +| Workflow | Purpose | Trigger | +| ---------------------------- | --------------------------------------------------------- | ------------------------------------- | +| `ci.yaml` | Lint, repository metadata, typecheck, tests, build, i18n | PR, push to `main`, merge group | +| `cd.yml` | Builds and publishes the dashboard image to GHCR | Push to `main`, `vX.Y.Z` tags, manual | +| `autofix.yml` | Pushes formatting and lint fixes back to the pull request | PR, merge group | +| `zizmor.yaml` | Static analysis of GitHub Actions workflows | PR, push to `main`, merge group | +| `semantic-pull-requests.yml` | Validates PR titles against Conventional Commits | PR opened, edited, synchronized | +| `release.yaml` | Validates release artifacts | Manual dispatch | +| `labelsync.yml` | Syncs repository labels | Daily schedule, manual dispatch | +| `stale.yml` | Marks and closes stale issues and pull requests | Daily schedule, manual dispatch | ## Pull requests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a63a125 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 + +# Container image for the single deployable app, `apps/dashboard`. +# +# The build stage installs the workspace with the pinned aube version and builds the dashboard +# (and the workspace packages it depends on) through Turborepo, with the self-hosted `node` +# ViteHub preset. That emits Nitro's self-contained `.output/` bundle, which is the only thing +# the runtime stage copies: no workspace sources, no dev dependencies, no package manager. +# +# The image listens on `$PORT` (default 3000), so it runs unchanged on Docker, Railway, and any +# other container platform that injects the port it routes to. + +ARG NODE_VERSION=24.19.0 + +FROM node:${NODE_VERSION}-bookworm-slim AS build + +ARG AUBE_VERSION=1.41.0 + +# Build metadata published under `runtimeConfig.public.buildInfo` (see packages/build-env). The +# checkout's `.git` is not part of the build context, so CI passes these in explicitly. +ARG CODE_ZERO_BUILD_COMMIT="" +ARG CODE_ZERO_BUILD_BRANCH="" +ARG CODE_ZERO_BUILD_URL="" +ARG CODE_ZERO_BUILD_PRODUCTION_URL="" +ARG CODE_ZERO_BUILD_ENV="" + +# `CI=true` skips the Husky install in the `prepare` script and keeps tools non-interactive. +ENV CI=true \ + HUSKY=0 \ + NITRO_PRESET=node-server \ + CODE_ZERO_BUILD_COMMIT=${CODE_ZERO_BUILD_COMMIT} \ + CODE_ZERO_BUILD_BRANCH=${CODE_ZERO_BUILD_BRANCH} \ + CODE_ZERO_BUILD_URL=${CODE_ZERO_BUILD_URL} \ + CODE_ZERO_BUILD_PRODUCTION_URL=${CODE_ZERO_BUILD_PRODUCTION_URL} \ + CODE_ZERO_BUILD_ENV=${CODE_ZERO_BUILD_ENV} + +RUN npm install --global --ignore-scripts=false "@endevco/aube@${AUBE_VERSION}" + +WORKDIR /workspace + +COPY . . + +RUN aube ci \ + && aube exec turbo run build --filter=@code-zero/dashboard + +FROM node:${NODE_VERSION}-bookworm-slim AS runtime + +# The runner boundary clones and inspects target repositories, so the image ships git and the CA +# bundle it needs for HTTPS remotes. +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates git tini \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3000 + +WORKDIR /app + +COPY --from=build --chown=node:node /workspace/apps/dashboard/.output ./.output + +# `fs-lite` KV keeps task history under `.data/kv` relative to the working directory. Mount a +# volume at /app/.data to keep it across restarts. No `VOLUME` instruction on purpose: Railway +# rejects images that declare one and attaches its own volumes instead. +RUN mkdir -p /app/.data && chown node:node /app/.data + +USER node + +EXPOSE 3000 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index c442ce7..f80f91c 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,40 @@ model: Issue-to-PR work is opt-in twice: `issues.enabled` must be true and the issue must carry the `issues.requireLabel` label, so arbitrary issue text can never start a run. Issue text is untrusted input for the runtime to validate — never instructions. The run first decides from repository evidence whether the issue actually reports a real problem, and (unless `issues.validationComment` is disabled) posts that verdict back on the issue: confirmed with its evidence, not confirmed with every rejection reason, or inconclusive for a human. A pull request is opened only when the run completed, its changes were applied, and every repository check passed. Verified changes are published to a fresh `issues.branchPrefix` branch (never force-updated, never the default branch), and the pull request body is the run's evidence: acceptance criteria, plan, checks, and lifecycle. +### Container image + +The root `Dockerfile` builds the dashboard with the self-hosted `node` preset and ships only the +`.output/` bundle on `node:24-bookworm-slim`, with `git` for the runner and `tini` as PID 1. The +`cd` workflow publishes it as a multi-arch (`linux/amd64`, `linux/arm64`) image to +`ghcr.io/wolfstar-project/code-zero`: `latest` and `main` follow the default branch, every build is +also tagged with its full commit SHA, and `vX.Y.Z` tags matching the dashboard version add `X.Y.Z` and `X.Y`. + +```bash +docker build -t code-zero . +docker run --rm -p 3000:3000 --env-file apps/dashboard/.env \ + -v code-zero-data:/app/.data \ + -v "$PWD/code-zero.deployment.yml:/app/code-zero.deployment.yml:ro" \ + code-zero +``` + +The server listens on `$PORT` (default `3000`), so Railway and other platforms that inject a port +work without extra configuration: point a Railway service at the GHCR image, or let it build the +repository's `Dockerfile` directly. Runtime configuration is the environment described above. The +published image is built with the default auth policy, so its sign-in pages are labelled for +sign-up and GitHub OAuth being off; the server still enforces whatever policy the runtime +environment sets. Apply migrations against `DATABASE_URL` with +`aube run db:migrate` from a checkout before the first start. Task history lives in +`/app/.data`; mount a volume there to keep it across restarts (on Railway, a volume mounted as +root needs `RAILWAY_RUN_UID=0`, since the image runs as the unprivileged `node` user). + +The image carries only `.output/`, so the deployment policy has to be supplied: mount +`code-zero.deployment.yml` at `/app/code-zero.deployment.yml` as above, or mount it elsewhere and +point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS +origins, no `fix` or `autonomous` grants). The image ships `git` for the host runner but no +container engine, so `runner.isolation: container` is not supported by it: tasks that require +container isolation fail closed instead of running on the host. Deploy the `.output/` bundle on a +host with Docker or Podman when you need that mode. + --- ## Toolchain