build: self-contained multi-stage Dockerfile + native CI/CD

- Dockerfile builds the Vite bundle (pinned pnpm) in stage 1, serves it via
  hanzoai/spa in stage 2 — a fresh docker build no longer needs a pre-built
  app/dist (gitignored), so CI and local builds match.
- .dockerignore keeps the build context lean (deps/bundle rebuilt in-image).
- .github/workflows/cicd.yml builds+pushes ghcr.io/hanzoai/cal:sha-<short> on
  the self-hosted hanzo-build-linux-amd64 pool — zero GitHub-hosted minutes.
- deploy/mirror-to-forge.sh mirrors any repo to git.hanzo.ai/hanzo/<name>.
- deploy/base-native/cicd.yml: paired base image workflow (prepared, not applied).
This commit is contained in:
2026-07-22 14:26:00 -07:00
parent 87aba71aaf
commit 06b77a783a
6 changed files with 272 additions and 9 deletions
+15
View File
@@ -0,0 +1,15 @@
# Keep the build context lean and let the image build its own deps/bundle.
# node_modules is re-installed in-container (host symlinks/arch would not port);
# dist is rebuilt by `pnpm build` in stage 1.
**/node_modules
**/dist
*.tsbuildinfo
.git
.gitignore
.DS_Store
.env
.env.local
Dockerfile
.dockerignore
deploy
LLM.md
+78
View File
@@ -0,0 +1,78 @@
name: cicd
# Canonical home = github.com/hanzoai/cal. This SAME file also runs natively on
# the git.hanzo.ai forge, whose runner scans WORKFLOW_DIRS = .hanzo/workflows,
# .github/workflows, .gitea/workflows — so no .hanzo/workflows copy is needed:
# .github/workflows is both GitHub-discoverable AND executed by the Hanzo runner.
#
# The invariant that makes this "native, our way" is the COMPUTE, not the folder:
# every job pins a self-hosted Hanzo pool via `runs-on` — NEVER a GitHub-hosted
# runner (ubuntu-latest, etc.). Zero GitHub Actions minutes are ever consumed.
#
# The SPA bundle is built INSIDE the multi-stage Dockerfile (stage 1, pinned
# pnpm), so this workflow is one build — checkout + buildx + push. The FE has no
# private modules (all public npm: @calcom/atoms MIT, react), so no GH_PAT
# build-secret is needed here, unlike the Go repos.
on:
push:
branches: [main]
tags: ['v*']
pull_request:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
image:
# Self-hosted Hanzo pool (arc-system / git.hanzo.ai runner). Same label the
# hanzoai/base release job uses. NEVER a GitHub-hosted runner.
runs-on: hanzo-build-linux-amd64
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Resolve short SHA
id: sha
run: echo "short=$(echo "${GITHUB_SHA}" | cut -c1-7)" >> "$GITHUB_OUTPUT"
# GHCR push. On the ARC (GitHub-context) pool the auto GITHUB_TOKEN carries
# packages:write. When this file runs on the native git.hanzo.ai gitea
# runner, the forge's auto token is scoped to the forge, not ghcr.io — set
# a repo/org secret GH_PAT (write:packages) and swap the password below.
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
# PRs build (validate the Dockerfile) but do not push; main/tags push.
push: ${{ github.event_name != 'pull_request' }}
provenance: false
tags: |
ghcr.io/hanzoai/cal:sha-${{ steps.sha.outputs.short }}
ghcr.io/hanzoai/cal:latest
- name: Report image + deploy note
if: ${{ github.event_name != 'pull_request' }}
run: |
{
echo "### cal FE image"
echo "Built + pushed \`ghcr.io/hanzoai/cal:sha-${{ steps.sha.outputs.short }}\` on \`${{ runner.name }}\`."
echo ""
echo "Deploy is a **gated CTO step**: pin this tag on the \`cal\` App CR;"
echo "the operator rolls it out. This workflow never deploys."
} >> "$GITHUB_STEP_SUMMARY"
+29 -7
View File
@@ -1,13 +1,35 @@
# syntax=docker/dockerfile:1
# cal.hanzo.ai — Cal's <Booker> (debranded as @hanzo/calendar) as a static SPA,
# served by hanzoai/spa (Go static server, :3000). `pnpm build` writes the Vite
# bundle to app/dist. The booking (/{host}/{event}) and manage (/booking/{uid})
# routes are client-rendered SPA shells; hanzoai/spa falls back to index.html for
# any client-routed path so the router resolves them and the atoms fetch on mount.
# served by hanzoai/spa (Go static server, :3000). Self-contained multi-stage:
# stage 1 compiles the Vite bundle with the pinned pnpm; stage 2 serves it. A
# fresh `docker build .` needs no pre-built dist on the host (app/dist is
# gitignored), so CI and local builds are identical.
# --- stage 1: build app/dist ---
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable
# Manifests first: the install layer caches independently of source edits.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY app/package.json app/package.json
COPY packages/calendar/package.json packages/calendar/package.json
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Sources, then build → app/dist. @hanzo/calendar is workspace TS source that
# Vite transpiles; @calcom/atoms is consumed as its pre-built ESM bundle.
COPY . .
RUN pnpm build
# --- stage 2: serve the bundle with the Hanzo static SPA server ---
# hanzoai/spa falls back to index.html for any client-routed path so the SPA
# router resolves /{owner}/{slug} and /booking/{uid} on the client.
FROM ghcr.io/hanzoai/spa:sha-b9d3a13-amd64
COPY app/dist /public
COPY --from=build /app/app/dist /public
# Ship the fork attribution alongside the served bundle (MIT @calcom/atoms).
COPY NOTICE /NOTICE
ENV PORT=3000
ENV ROOT=/public
# Allow same-origin + inline bootstrap (React hydration) and connect-src https: so
# the browser can reach the Base booking API at cal.hanzo.ai/v1/calendar.
# Allow same-origin + inline bootstrap (React hydration) and connect-src https:
# so the browser can reach the Base booking API at cal.hanzo.ai/v1/calendar.
ENV HANZO_STATIC_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-src 'self'; media-src 'self' https:"
EXPOSE 3000
+22 -2
View File
@@ -12,7 +12,21 @@ booking is created in Base.
SPA imports ONLY from here, never `@calcom/atoms` directly.
- `app` — the Vite + React SPA (`@hanzo/cal-app`). Routes: `/` landing,
`/{owner}/{slug}` the Booker, `/booking/{uid}` confirmation + cancel.
- `Dockerfile` — `FROM ghcr.io/hanzoai/spa`, `COPY app/dist /public` (SPA fallback).
- `Dockerfile` — self-contained multi-stage: stage 1 builds the Vite bundle with
the pinned pnpm; stage 2 is `FROM ghcr.io/hanzoai/spa` and copies the bundle to
`/public` (SPA fallback). A fresh `docker build .` needs no pre-built `app/dist`
(it is gitignored), so CI and local builds are identical.
- `.github/workflows/cicd.yml` — native CI. Every job pins the self-hosted Hanzo
pool `hanzo-build-linux-amd64` (`runs-on`) — never a GitHub-hosted runner, so
zero GHA minutes. On push it builds+pushes `ghcr.io/hanzoai/cal:sha-<short>`.
The same `.github` file also runs on the git.hanzo.ai forge, whose runner scans
`WORKFLOW_DIRS = .hanzo/workflows,.github/workflows,.gitea/workflows`. Deploy is
a gated CTO step (pin the tag on the `cal` App CR); CI never deploys.
- `deploy/mirror-to-forge.sh` — point the git.hanzo.ai mirror at the GitHub
canonical via server-side mirror-IN (the forge fetches from GitHub; idempotent
resync). Auth is the `hanzo auth token` JWT, kept out of argv.
- `deploy/base-native/cicd.yml` — the paired base image workflow, prepared for
post-red apply to `hanzoai/base/.github/workflows/`.
## Backend contract
`<Provider apiUrl>` sets `axios.defaults.baseURL`, so every atom request hits our
@@ -31,4 +45,10 @@ stubs (`app/src/stripe-*.ts`) — the bundle stays self-contained and CSP-clean.
## Fork discipline
Only MIT `@calcom/atoms` is used; no `ee/` / non-MIT Cal code. Cal's copyright stays
in `NOTICE`, Hanzo's added. Phase-1 milestone is a local/workspace `@hanzo/calendar`;
the immutable npm publish + `github.com/hanzoai/calendar` repo are the follow-on.
the immutable npm publish is the follow-on.
## Homing
Canonical origin is GitHub `github.com/hanzoai/cal`; git.hanzo.ai `hanzo/cal` is a
downstream mirror and the native CI executor. (The repo is `cal`, not `calendar`,
because `github.com/hanzoai/calendar` is a full fork of `calcom/cal.diy` — a
different artifact; the forge org is `hanzo`, it has no `hanzoai` org.)
+81
View File
@@ -0,0 +1,81 @@
name: cicd
# PREPARED, NOT APPLIED — copy to hanzoai/base/.github/workflows/cicd.yml
# post-red (the red review owns the base working tree right now).
#
# This is the base half of the calendar stack: a push→image build for the Go
# Base binary that carries the /v1/calendar contract, producing
# ghcr.io/hanzoai/base:cal-<sha> from base's ./Dockerfile.
#
# Same invariant as the cal FE: every job pins a self-hosted Hanzo pool
# (runs-on) — NEVER a GitHub-hosted runner, so zero GHA minutes. Because the
# git.hanzo.ai forge scans WORKFLOW_DIRS = .hanzo/workflows,.github/workflows,
# .gitea/workflows, this same .github file also executes natively on the forge.
#
# base already ships native CI (release.yaml = goreleaser binary;
# platform-build.yml = platform-dispatched image). This ADDS an on-push image
# build for the cal tag; those keep working via the legacy dirs — no flag day.
on:
push:
branches: [main, feat/calendar-cal-contract]
tags: ['v*']
pull_request:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
image:
runs-on: hanzo-build-linux-amd64
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
# base Dockerfile runs `git describe --tags` for -X ...Version.
fetch-depth: 0
- name: Resolve short SHA
id: sha
run: echo "short=$(echo "${GITHUB_SHA}" | cut -c1-7)" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
provenance: false
tags: |
ghcr.io/hanzoai/base:cal-${{ steps.sha.outputs.short }}
# Private cross-org modules (hanzoai/*, luxfi/*) resolve via authed git
# in the builder stage — base's Dockerfile mounts this as secret id
# `gh_token`. Same mechanism release.yaml uses; without it the build
# fails SECURITY ERROR on force-moved luxfi tags.
secrets: |
gh_token=${{ secrets.GH_PAT }}
- name: Report image + deploy note
if: ${{ github.event_name != 'pull_request' }}
run: |
{
echo "### base (cal contract) image"
echo "Built + pushed \`ghcr.io/hanzoai/base:cal-${{ steps.sha.outputs.short }}\`."
echo ""
echo "Deploy is a **gated CTO step**: pin this tag on the base App CR"
echo "that serves cal.hanzo.ai/v1/calendar. This workflow never deploys."
} >> "$GITHUB_STEP_SUMMARY"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# mirror-to-forge.sh — mirror a GitHub-canonical repo INTO git.hanzo.ai.
#
# Canonical origin is GitHub (github.com/hanzoai/<name>). git.hanzo.ai is a
# downstream mirror + native CI executor, so it FOLLOWS GitHub: the forge pulls
# from the canonical source (a server-side `git fetch +refs/*:refs/*`) rather
# than us push-seeding a second write target. Idempotent — the first call clones
# the source, a repeat call resyncs it.
#
# Auth: the live Hanzo IAM JWT (`hanzo auth token`) as a Bearer, written into a
# mode-600 curl config at call time — it never appears in argv, the URL, or the
# shell history. Requires a logged-in `hanzo` CLI (`hanzo auth whoami` resolves).
#
# Usage: deploy/mirror-to-forge.sh <name> [github-org]
# e.g.: deploy/mirror-to-forge.sh cal # public → anonymous fetch
# deploy/mirror-to-forge.sh base # (see private-source note)
#
# A PRIVATE GitHub source needs the forge's server-side GIT_MIRROR_TOKEN
# (allowlisted to github.com); that is forge deploy config, not set here.
set -euo pipefail
NAME="${1:?forge/github repo name required, e.g. cal}"
GH_ORG="${2:-hanzoai}"
API="https://api.hanzo.ai/v1/git"
SRC="https://github.com/${GH_ORG}/${NAME}.git"
CFG="$(mktemp)"; trap 'rm -f "$CFG"' EXIT; chmod 600 "$CFG"
# printf is a shell builtin → the token never appears in any process argv.
printf 'header = "Authorization: Bearer %s"\n' "$(hanzo auth token)" > "$CFG"
# 1) Ensure the forge repo exists under org `hanzo` (idempotent).
code="$(curl -sS -o /dev/null -m 30 -w '%{http_code}' -K "$CFG" \
-H 'Content-Type: application/json' \
-X POST "${API}/repos" -d "{\"name\":\"${NAME}\"}" || true)"
case "$code" in
201) echo "created forge repo hanzo/${NAME}" ;;
409) echo "forge repo hanzo/${NAME} already exists" ;;
*) echo "note: create returned HTTP ${code} (continuing)" ;;
esac
# 2) Mirror-IN: the forge force-fetches every ref from the GitHub canonical.
echo "mirroring ${SRC} -> git.hanzo.ai/hanzo/${NAME} ..."
curl -fsS -m 300 -K "$CFG" -H 'Content-Type: application/json' \
-X POST "${API}/repos/${NAME}/mirror" -d "{\"source\":\"${SRC}\"}" \
-w '\n<<HTTP %{http_code}>>\n'
echo "done — clone at https://git.hanzo.ai/hanzo/${NAME}.git"