ci: run every lane on the forge, leave sync.yml on the mirror

git.hanzo.ai is canonical and reads .hanzo/workflows, so build.yml,
build-kms-fetch.yml and pr-preview.yml move there by changing directory and
nothing else — same GitHub Actions syntax, same runs-on, byte-identical files.

Three more lanes had been deleted from .github/workflows without a replacement
anywhere, which silently removed this repo's only gate and the only producer of
its release binaries and semver image tags. There was no red run to show it,
because a workflow that does not exist cannot fail. Restored byte-for-byte from
history at the path the forge reads:

  ci.yml                    go test -race ./... + builds kmsd and kms
  release.yml               v* tags: 4-way binary matrix, release, multi-arch image
  check-fe-ts-and-lint.yml  frontend typecheck on PRs

release.yml's notify-universe job is left exactly as it was; it is already
continue-on-error, so a missing UNIVERSE_DISPATCH_TOKEN on the forge degrades to
a yellow job instead of failing the release.

Not restored: validate-pr-title.yml, a conventional-commit title gate inherited
with an upstream PR template. Dropped: workflow-sanity.yml, which triggers on
paths .github/workflows/** and so can never fire again once the lanes live under
.hanzo. Neither builds, tests or publishes anything.

sync.yml is replaced with the canonical copy — the variant here predated it and
had drifted.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 08:42:36 -07:00
co-authored by Hanzo Dev
parent a5faad7752
commit fcd565d375
9 changed files with 420 additions and 54 deletions
+87 -45
View File
@@ -1,60 +1,102 @@
name: Sync to git.hanzo.ai
# DIRECTION: this repo on git.hanzo.ai is a PULL MIRROR fetching github.com/hanzoai/kms.
# GitHub is UPSTREAM, git.hanzo.ai is DOWNSTREAM. The giveaway is what this file does:
# mirror-sync tells Gitea to PULL, which only makes sense if GitHub is the source.
name: sync
# Carries pushed refs to git.hanzo.ai, the canonical forge, and does nothing
# else: build, test and release run natively there.
#
# This matters beyond labeling: a pull mirror force-updates its refs from upstream on
# every fetch, so anything committed directly on git.hanzo.ai is DISCARDED on the next
# sync. Do not treat the mirror as writable, and do not reword this into a "native on
# git.hanzo.ai" claim — that is the intended end state, not today's.
#
# What IS true: CI/CD runs on git.hanzo.ai — arcd executes .hanzo/workflows/ on the
# Hanzo git-runner. Making git.hanzo.ai canonical means converting the repo
# pull-mirror -> native and flipping the write path; an ops migration, not a config edit.
#
# So this nudge is a LATENCY fix, not a canonicality one: it asks Gitea to pull now
# instead of waiting out the ~10-minute mirror interval, so arcd sees the new commit
# sooner. Without this file the repo still mirrors — just later.
#
# IDEMPOTENT: mirror-sync just tells Gitea to pull HEAD, so N nudges == 1 (same latest
# state); concurrency coalesces a burst of pushes to a single in-flight sync. FAST: no
# checkout, one bounded curl, hard timeouts. FAIL-SOFT: a missing token or hiccup never
# fails the push (the App webhook still mirrors). Set HANZO_GIT_TOKEN to enable the nudge.
# Repo-agnostic. owner/name come from ${{ github.repository }}, so this file is
# dropped into every repo unchanged as .github/workflows/sync.yml and is the
# only workflow GitHub is permitted to run.
on:
push:
branches: [main]
# '**' matches slashes; '*' does not, and would skip feature/foo.
branches: ['**']
tags: ['**']
workflow_dispatch:
# Only the newest push's sync needs to run — cancel any older in-flight one (it would
# pull a now-stale HEAD). This is what makes rapid pushes idempotent + cheap.
# Serialize per ref. Never cancel: a cancelled run drops that ref and nothing
# reconciles it afterwards.
concurrency:
group: sync-git-hanzo-ai
cancel-in-progress: true
group: sync-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
# `.github/workflows` is also on Gitea's WORKFLOW_DIRS, so with Actions enabled on
# the mirror this file runs THERE too on every sync — where asking git.hanzo.ai to
# pull is circular. github.server_url is setting.AppURL on Gitea
# (services/actions/context.go:89), so this is the exact test for "am I upstream".
if: ${{ github.server_url == 'https://github.com' }}
# .github/workflows is also on the forge's own workflow search path, so this
# file executes there too in any repo without .hanzo/workflows, where it
# would push the forge to itself. server_url is the forge's AppURL, which
# makes this the test for "running on GitHub".
if: github.server_url == 'https://github.com'
# GitHub-hosted on purpose. This is the one workflow that must run ON GitHub,
# and the self-hosted pool that used to serve it is retired — a job asking for
# it now queues forever against a label nothing answers. Carrying refs is a
# push, not a build, so it does not belong on the build fabric anyway: builds
# run natively on git.hanzo.ai, which is the whole point of this file.
runs-on: ubuntu-latest
timeout-minutes: 2
timeout-minutes: 15
steps:
- name: Nudge git.hanzo.ai to pull HEAD (mirror-sync)
continue-on-error: true
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Push refs to git.hanzo.ai
env:
HANZO_GIT_TOKEN: ${{ secrets.HANZO_GIT_TOKEN }}
FORGE: https://git.hanzo.ai
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
set -u
if [ -z "${HANZO_GIT_TOKEN:-}" ]; then
echo "HANZO_GIT_TOKEN not set — relying on the mirror App webhook. Skipping nudge."
exit 0
set -euo pipefail
if [ -z "${GIT_TOKEN}" ]; then
echo "::error::GIT_TOKEN is unset, so this push never reached the canonical forge. Add the org secret."
exit 1
fi
if curl -fsS --max-time 20 --retry 2 --retry-delay 2 -X POST \
-H "Authorization: token ${HANZO_GIT_TOKEN}" \
"https://git.hanzo.ai/api/v1/repos/hanzoai/kms/mirror-sync"; then
echo "git.hanzo.ai mirror-sync triggered"
else
echo "mirror-sync nudge failed (non-fatal) — the App webhook still mirrors"
REMOTE="${FORGE}/${GITHUB_REPOSITORY}.git"
# The forge reads the token from the basic-auth password and ignores
# the username. base64 of a secret is not masked automatically.
AUTH="$(printf 'x-access-token:%s' "${GIT_TOKEN}" | base64 -w0)"
echo "::add-mask::${AUTH}"
# Host-scoped, so the forge token is never sent to github.com.
git config --local "http.${FORGE}/.extraheader" "Authorization: Basic ${AUTH}"
# Reachability is the only transient failure here, so it is the only
# thing retried. ls-remote exits 0 on an existing empty repo and
# non-zero when the forge is down or the repo is absent.
ERR="$(mktemp)"
REACHED=""
for attempt in 1 2 3; do
if git ls-remote "${REMOTE}" >/dev/null 2>"${ERR}"; then
REACHED=1
break
fi
echo "forge probe failed (attempt ${attempt}/3): $(cat "${ERR}")"
if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
if [ -z "${REACHED}" ]; then
echo "::error::git.hanzo.ai is unreachable or ${GITHUB_REPOSITORY} does not exist there. $(cat "${ERR}")"
exit 1
fi
# Additive and fast-forward-only: no leading '+', no --force, no
# --prune, no --mirror. Tags go every run so a tag that missed its own
# event still lands; git refuses to move one that already differs.
REFS=('refs/tags/*:refs/tags/*')
case "${GITHUB_REF}" in
refs/heads/*) REFS+=("${GITHUB_REF}:${GITHUB_REF}") ;;
esac
# Not --atomic: a diverged ref must not hold back the refs that are
# clean. Rejections still fail the job.
if ! git push "${REMOTE}" "${REFS[@]}"; then
echo "::error::git.hanzo.ai rejected a ref for ${GITHUB_REPOSITORY}. The forge is canonical and is never force-updated from here; reconcile the diverged branch or tag by hand."
exit 1
fi
-7
View File
@@ -1,7 +0,0 @@
name: Workflow Sanity
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
+35
View File
@@ -0,0 +1,35 @@
name: Check Frontend Type and Lint check
on:
pull_request:
types: [opened, synchronize]
paths:
- "frontend/**"
- "!frontend/README.md"
- "!frontend/.*"
- "frontend/.eslintrc.js"
jobs:
check-fe-ts-lint:
name: Check Frontend Type and Lint check
runs-on: hanzo-build-linux-amd64
timeout-minutes: 15
steps:
- name: Checkout source
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Type check
run: pnpm run typecheck
working-directory: frontend
+39
View File
@@ -0,0 +1,39 @@
name: CI Pipeline
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
test:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run unit tests
run: go test -v -race ./...
- name: Build
run: |
CGO_ENABLED=0 go build -v ./cmd/kmsd
CGO_ENABLED=0 go build -v ./cmd/kms
+221
View File
@@ -0,0 +1,221 @@
name: Release
# Multi-arch release pipeline triggered by semver tags.
#
# Produces:
# * kms-{linux,darwin}-{amd64,arm64} — admin CLI (cmd/kms)
# * kmsd-{linux,darwin}-{amd64,arm64} — server daemon (cmd/kmsd)
# * ghcr.io/hanzoai/kms:vX.Y.Z — multi-arch manifest
#
# Uses GitHub-hosted runners exclusively (ubuntu-24.04 / ubuntu-24.04-arm /
# macos-14 / macos-14). The legacy backend/ kms-core binaries are still
# produced by build-binaries.yml — this workflow only covers the Go CLI
# + daemon.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v1.0.0). Must already exist.'
required: true
type: string
permissions:
contents: write
packages: write
id-token: write
jobs:
binaries:
name: Build binaries (${{ matrix.goos }}/${{ matrix.goarch }})
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
runner: hanzo-build-linux-amd64
- goos: linux
goarch: arm64
runner: ubuntu-24.04-arm
- goos: darwin
goarch: amd64
runner: macos-14
- goos: darwin
goarch: arm64
runner: macos-14
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Resolve version
id: version
run: |
ref="${{ inputs.tag || github.ref_name }}"
echo "tag=${ref}" >> "$GITHUB_OUTPUT"
- name: Build kms + kmsd (${{ matrix.goos }}/${{ matrix.goarch }})
env:
CGO_ENABLED: '0'
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOEXPERIMENT: jsonv2
VERSION: ${{ steps.version.outputs.tag }}
# luxfi (+ hanzoai) modules are first-party/private: resolve them
# directly via git and skip the public checksum DB — force-moved
# upstream tags never match sum.golang.org. GH_PAT carries luxfi read
# access; the repo GITHUB_TOKEN is the fallback for public modules.
GOPRIVATE: github.com/luxfi/*,github.com/hanzoai/*
GONOSUMDB: github.com/luxfi/*,github.com/hanzoai/*
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
token="${GH_PAT:-$GITHUB_TOKEN}"
if [ -n "$token" ]; then
git config --global url."https://x-access-token:${token}@github.com/".insteadOf "https://github.com/"
fi
mkdir -p dist
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o "dist/kms-${GOOS}-${GOARCH}" ./cmd/kms
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o "dist/kmsd-${GOOS}-${GOARCH}" ./cmd/kmsd
# shasum is available on both linux and macos runners
(cd dist && shasum -a 256 "kms-${GOOS}-${GOARCH}" "kmsd-${GOOS}-${GOARCH}" \
> "kms-${GOOS}-${GOARCH}.sha256")
- uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
retention-days: 7
github-release:
name: GitHub Release
needs: [binaries]
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Resolve version
id: version
run: |
ref="${{ inputs.tag || github.ref_name }}"
echo "tag=${ref}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
generate_release_notes: true
files: |
dist/kms-linux-amd64
dist/kms-linux-arm64
dist/kms-darwin-amd64
dist/kms-darwin-arm64
dist/kmsd-linux-amd64
dist/kmsd-linux-arm64
dist/kmsd-darwin-amd64
dist/kmsd-darwin-arm64
dist/kms-linux-amd64.sha256
dist/kms-linux-arm64.sha256
dist/kms-darwin-amd64.sha256
dist/kms-darwin-arm64.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
name: Multi-arch Docker (ghcr.io/hanzoai/kms)
needs: [binaries]
runs-on: hanzo-build-linux-amd64
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- name: Resolve version
id: version
run: |
ref="${{ inputs.tag || github.ref_name }}"
echo "tag=${ref}" >> "$GITHUB_OUTPUT"
echo "semver=${ref#v}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
provenance: false
push: true
tags: |
ghcr.io/hanzoai/kms:${{ steps.version.outputs.tag }}
ghcr.io/hanzoai/kms:${{ steps.version.outputs.semver }}
ghcr.io/hanzoai/kms:latest
build-args: |
GO_EXPERIMENT=jsonv2
GITHUB_TOKEN=${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
notify-universe:
name: Notify universe (auto-bump pin)
needs: [docker]
runs-on: hanzo-build-linux-amd64
if: github.repository == 'hanzoai/kms'
continue-on-error: true
steps:
- name: Resolve version
id: version
run: |
ref="${{ inputs.tag || github.ref_name }}"
echo "tag=${ref}" >> "$GITHUB_OUTPUT"
- name: Repository dispatch
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.UNIVERSE_DISPATCH_TOKEN }}
repository: hanzoai/universe
event-type: kms
client-payload: |
{
"service": "kms",
"version": "${{ steps.version.outputs.tag }}",
"image": "ghcr.io/hanzoai/kms:${{ steps.version.outputs.tag }}",
"sha": "${{ github.sha }}"
}
+38 -2
View File
@@ -37,8 +37,44 @@ KMS_ENV=dev ./kmsd # HTTP :8443, ZAP :9999 (dev tolerates missing JWT config
Non-`dev`/`devnet`/`local` `KMS_ENV` refuses to boot without
`KMS_EXPECTED_ISSUER` + `KMS_EXPECTED_AUDIENCE` + `KMS_JWKS_URL`. Fail-closed.
CI: `hanzoai/.github/.github/workflows/docker-build.yml@main` → `ghcr.io/hanzoai/kms`,
`linux/amd64` on the `hanzo-build-linux-amd64` ARC pool, semver `v*` tags (never `:latest`).
## How this ships
One way, and it runs on our own stack:
push -> github.com/hanzoai/kms (a mirror)
.github/workflows/sync.yml carries refs onward
-> git.hanzo.ai/hanzoai/kms CANONICAL
.hanzo/workflows/ci.yml go test -race + builds kmsd/kms
.hanzo/workflows/build.yml ghcr.io/hanzoai/kms on main
.hanzo/workflows/release.yml v* tags: binaries + release + image
.hanzo/workflows/build-kms-fetch.yml ghcr.io/hanzoai/kms-fetch
.hanzo/workflows/check-fe-ts-and-lint.yml frontend typecheck on PRs
.hanzo/workflows/pr-preview.yml ephemeral PR preview env
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
build, check and deploy is a workflow under `.hanzo/workflows/`, which the forge
reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves between
the two by changing directory and nothing else.
`ci.yml`, `release.yml` and `check-fe-ts-and-lint.yml` had been deleted from
`.github/workflows` with no replacement anywhere — which silently removed the
repo's only gate and the only producer of the release binaries and semver image
tags, with no red run to show it, because a workflow that does not exist cannot
fail. They are restored here at the path the forge reads.
`release.yml` is the heaviest lane: a 4-way binary matrix
(linux/darwin × amd64/arm64) for `cmd/kms` + `cmd/kmsd`, a GitHub Release with
12 assets, a multi-arch `ghcr.io/hanzoai/kms:{vX.Y.Z, X.Y.Z, latest}`, and a
final best-effort `notify-universe` job. That last job is
`continue-on-error: true`, so a missing `UNIVERSE_DISPATCH_TOKEN` on the forge
degrades to a yellow job and never fails the release.
`build.yml`, `build-kms-fetch.yml` and `pr-preview.yml` are thin callers of
`hanzoai/.github/.github/workflows/*@main`. Those references resolve by path, so
they break the day the reusable workflows themselves move to `.hanzo/workflows` —
worth inlining then, not now.
## Entry points