ci: one CI path — .hanzo/workflows

Gitea collects workflows from the FIRST of WORKFLOW_DIRS that exists in the
pushed commit and ignores the rest (modules/actions/workflows.go,
listWorkflowsInDirs breaks on the first hit), so creating .hanzo/workflows
switches .github/workflows off wholesale — no run, no queued job, no error.
Every file therefore moves or dies in this one commit; nothing is left to look
like a fallback it could not be.

MIGRATED, unchanged apart from the label form and the notes below:
ci.yml (the Layer-2 deploy + E2E orchestrator), build-service.yml (the Layer-1
reusable), service-released.yml and update-image.yml (the two repository_dispatch
auto-bump shapes). Their runner label was already right — zoo-build-linux-amd64
is what the fleet advertises — and is now written in the list form the rest of
the fleet uses.

FIXED — service-released.yml HAS NEVER PARSED. Its `gh pr create --body`
markdown table sat at column 0 inside a block scalar indented to column 10, so
the scalar terminated at the table and the `|` was read as a second block
indicator:

  yaml.scanner.ScannerError: while scanning a block scalar
    expected a comment or a line break, but found 'f'   (line 132, col 3)

That is why every single run of it is a failure. The table is now indented into
the scalar, which YAML strips back off, so the shell and the rendered PR body
are byte-identical to what was intended and the file loads.

DELETED — renovate.yml, and renovate.json with it. A `*/15 * * * *` cron, so 96
runs a day against the shared amd64 pool, for an inherited dependency bot we do
not use; it was measured at 83 runs in the last 24 hours, more than any real
pipeline on this forge. Its config file goes too rather than sit behind a bot
that is not there.

The reusable's PATH is part of its contract, so moving it is a caller-visible
change: services/actions.ResolveUses enforces the WORKFLOW_DIRS allowlist on
`uses:` paths, which means .github/workflows would have stopped resolving at the
narrowing anyway. Searched across the zooai org by both spellings — zoolabs/ and
zooai/ — and nothing outside this repo referenced it; the only hits were the
file itself and a comment in ci.yml. Callers now use
zooai/universe/.hanzo/workflows/build-service.yml@main.

build-push's matrix no longer carries the runner label. It still carries `arch`,
which is part of the published image tag (…-amd64), but which label the fleet
advertises is a fleet fact rather than a tag fact, and behind
${{ matrix.runner }} the one thing worth grepping for could not be grepped.

Concurrency groups added where they were missing: one in-flight bump per
service, never cancelled between `git push` and `gh pr create`, and one build
per calling service so two services do not serialise behind each other.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-30 17:23:27 -07:00
co-authored by Hanzo Dev
parent 3fd2126a42
commit e668d507f8
6 changed files with 63 additions and 101 deletions
-20
View File
@@ -1,20 +0,0 @@
name: Renovate
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
renovate:
runs-on: zoo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: renovatebot/github-action@v41
with:
configurationFile: renovate.json
token: ${{ secrets.GITHUB_TOKEN }}
@@ -1,11 +1,20 @@
# Reusable workflow: Lint + Test + Build + Push for a single service.
#
# Called from each service repo:
# uses: zoolabs/universe/.github/workflows/build-service.yml@main
# uses: zooai/universe/.hanzo/workflows/build-service.yml@main
# with:
# service_key: zoo-ngo
# app_name: zoo-ngo
#
# THE PATH IS PART OF THE CONTRACT, and it moved with the rest of this repo's
# CI. A reusable is resolved through services/actions.ResolveUses, which
# enforces the WORKFLOW_DIRS allowlist on the referenced path, so a `uses:`
# naming .github/workflows stops resolving the moment that list narrows to
# .hanzo/workflows alone. Nothing outside this repo referenced the old path when
# it moved — searched across the zooai org by both spellings, zoolabs/ and
# zooai/, and the only hits were this file and a comment in ci.yml. A caller
# added since uses the path above.
#
# Layer 1 of 2-layer CI defense:
# Layer 1 (this): unit tests + lint per service
# Layer 2 (ci.yml): full E2E across all services in universe
@@ -52,10 +61,17 @@ permissions:
packages: write
id-token: write
# Keyed on the calling service and its ref, so two services never serialise
# behind each other. Not cancellable: a superseded run may already have pushed a
# layer under the tag it was given.
concurrency:
group: build-service-${{ inputs.service_key }}-${{ github.ref }}
cancel-in-progress: false
jobs:
lint-test:
name: Lint + Test
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
if: ${{ !inputs.skip_tests }}
steps:
- uses: actions/checkout@v4
@@ -118,13 +134,18 @@ jobs:
# amd64 only. arm64 paused — DOKS does not currently offer arm64
# droplets. See ~/work/zoo/universe/LLM.md and shared Docker Build
# Standard for re-enable conditions.
#
# The matrix carries `arch` because it is part of the published image tag
# (…-amd64). It no longer carries the runner: which label the fleet
# advertises is a fleet fact, not a tag fact, and hiding it behind
# ${{ matrix.runner }} makes the one thing worth grepping for ungreppable.
# Re-enabling arm64 means a second matrix entry AND a runner that
# advertises the arm64 label — the two are not the same decision.
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: zoo-build-linux-amd64
runs-on: ${{ matrix.runner }}
arch: [amd64]
runs-on: [zoo-build-linux-amd64]
needs: [lint-test]
if: always() && (needs.lint-test.result == 'success' || needs.lint-test.result == 'skipped') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
outputs:
@@ -1,5 +1,12 @@
# Universe CI -- Layer 2 Orchestrator
#
# Runs from .hanzo/workflows, which is the one directory this forge collects.
# Gitea takes the FIRST of WORKFLOW_DIRS that exists in the pushed commit and
# ignores the rest (modules/actions/workflows.go, listWorkflowsInDirs breaks on
# the first hit), so this directory and .github/workflows were never going to
# coexist: creating one switches the other off silently, with no run, no queued
# job and no error. Everything this repo still wants moved here in one commit.
#
# Two modes:
# 1. service-built (repository_dispatch): A single service was built + tested.
# Update manifest, deploy that service, run smoke test.
@@ -23,7 +30,7 @@ on:
paths:
- 'manifests/**'
- 'scripts/**'
- '.github/workflows/ci.yml'
- '.hanzo/workflows/ci.yml'
- 'e2e/**'
workflow_dispatch:
@@ -67,7 +74,7 @@ jobs:
# ============================================================
resolve:
name: Resolve Environment
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
outputs:
environment: ${{ steps.env.outputs.environment }}
namespace: ${{ steps.env.outputs.namespace }}
@@ -125,7 +132,7 @@ jobs:
# ============================================================
update-manifest:
name: Update Manifest
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
needs: resolve
if: needs.resolve.outputs.mode == 'deploy-single'
steps:
@@ -213,7 +220,7 @@ jobs:
deploy-single:
name: Deploy ${{ needs.resolve.outputs.service_key }}
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
needs: [resolve]
if: needs.resolve.outputs.mode == 'deploy-single' && github.event_name == 'push'
environment: ${{ needs.resolve.outputs.environment }}
@@ -290,7 +297,7 @@ jobs:
# ============================================================
deploy-all:
name: Deploy All Services
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
needs: [resolve]
if: needs.resolve.outputs.mode == 'deploy-all'
environment: ${{ needs.resolve.outputs.environment }}
@@ -350,7 +357,7 @@ jobs:
# ============================================================
e2e:
name: E2E
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
needs: [resolve, deploy-all]
if: always() && (needs.resolve.outputs.mode == 'deploy-all' || needs.resolve.outputs.mode == 'e2e-only') && needs.resolve.result == 'success'
timeout-minutes: 25
@@ -6,7 +6,7 @@
# 2. Opens a PR: auto-bump/<service>-<version>
# 3. Enables auto-merge (squash) — merges on green CI
#
# Companion to .github/workflows/update-image.yml which handles the older
# Companion to .hanzo/workflows/update-image.yml which handles the older
# `image-published` dispatch shape (kept for repos that haven't migrated).
# New service repos should dispatch a per-service event type listed below.
@@ -32,10 +32,17 @@ permissions:
contents: write
pull-requests: write
# Per service, not per version: two dispatches for the same service must not
# race each other onto the same auto-bump branch. Not cancellable — a cancelled
# run can leave a pushed branch with no PR on it.
concurrency:
group: service-released-${{ github.event.action }}
cancel-in-progress: false
jobs:
auto-bump:
name: "Bump ${{ github.event.action }} → ${{ github.event.client_payload.version }}"
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
steps:
- name: Validate payload
run: |
@@ -129,13 +136,13 @@ jobs:
--title "chore(deps): bump ${SERVICE} to ${VERSION}" \
--body "Auto-bump from zooai/${SERVICE} release.
| field | value |
|---------|-------|
| service | \`${SERVICE}\` |
| version | \`${VERSION}\` |
| image | \`${IMAGE}\` |
| sha | \`${SHA}\` |
" \
| field | value |
|---------|-------|
| service | \`${SERVICE}\` |
| version | \`${VERSION}\` |
| image | \`${IMAGE}\` |
| sha | \`${SHA}\` |
" \
--label "auto-bump" \
--base main 2>&1 | tail -1)
@@ -8,9 +8,15 @@ permissions:
contents: write
pull-requests: write
# Same reasoning as service-released.yml: one in-flight bump per service, and
# never cancelled midway between `git push` and `gh pr create`.
concurrency:
group: update-image-${{ github.event.client_payload.service }}
cancel-in-progress: false
jobs:
update:
runs-on: zoo-build-linux-amd64
runs-on: [zoo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
-59
View File
@@ -1,59 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["kubernetes", "custom.regex"],
"kubernetes": {
"fileMatch": ["(^k8s|^infra/k8s)/.+\\.yaml$"]
},
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^manifests/.*\\.yml$", "compose.*\\.yml$", "(^k8s|^infra/k8s)/.+\\.yaml$"],
"matchStrings": [
"image:\\s*(?<depName>[\\w.\\-/]+):(?<currentValue>[\\w][\\w.\\-]+)"
],
"datasourceTemplate": "docker",
"versioningTemplate": "docker"
}
],
"packageRules": [
{
"description": "Group all image updates into a single PR",
"matchDatasources": ["docker"],
"groupName": "docker-images",
"groupSlug": "docker-images",
"automerge": false,
"labels": ["dependencies", "docker"]
},
{
"description": "Auto-merge patch updates for internal images",
"matchDatasources": ["docker"],
"matchPackagePatterns": ["ghcr.io/(hanzoai|luxfi|zooai)/.*"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "pr"
},
{
"description": "Major versions require review",
"matchDatasources": ["docker"],
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-upgrade"]
},
{
"description": "Ignore ARC runner images",
"matchPackagePatterns": ["ghcr.io/actions/actions-runner"],
"enabled": false
},
{
"description": "Pin digests for deterministic deploys",
"matchDatasources": ["docker"],
"pinDigests": true
}
],
"schedule": ["every 15 minutes"],
"prHourlyLimit": 10,
"prConcurrentLimit": 3,
"rebaseWhen": "behind-base-branch",
"labels": ["renovate", "dependencies"]
}