All checks were successful
CI / lint (push) Successful in 1m1s
CI / test (push) Successful in 3m6s
CI / notebooks-smoke (push) Successful in 1m49s
Deploy / changes (push) Successful in 19s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / bcda (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Deploy / report (push) Successful in 25s
The changes job diffed github.event.before..SHA, so a re-push that cancelled the run in flight dropped that push's paths for good: the notebooks image never rebuilt for9cba4fd(Dockerfile) or409fc6f(notebooks/), and the registry stayed on thea4b4110Harden build. A new base step asks Gitea for the newest successful deploy.yml run on main and the detector diffs from its head_sha. Cancelled or failed runs stay in the next run's range until they land. No successful run or an API error leaves the base empty, which builds everything. The runner image has no python or jq, so the run JSON is paired with grep + awk; awk reads to EOF so grep never takes a SIGPIPE under pipefail. workflow_dispatch added so a catch-up run needs no push.
1045 lines
33 KiB
Python
1045 lines
33 KiB
Python
"""Gitea Actions workflow emitter.
|
||
|
||
Thin adapter over the GitHub backend — Gitea Actions uses the same
|
||
YAML syntax but with:
|
||
- Output to .gitea/workflows/ (not .github/workflows/)
|
||
- Registry auth via PAT secret (GITEA_TOKEN lacks package scope)
|
||
- Platform registry (not ghcr.io)
|
||
- Full URL form for actions (for air-gapped installs)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from backends.github import (
|
||
_HEADER,
|
||
)
|
||
|
||
# Pinned scanner/registry tooling. A `latest`/`main` install would make the
|
||
# tools that guard the supply chain an unpinned dependency themselves.
|
||
# Renovate bumps these via the regex manager in renovate.json; re-run
|
||
# gen_config.py after a bump.
|
||
# renovate: datasource=github-releases depName=aquasecurity/trivy
|
||
TRIVY_VERSION = "0.76.0"
|
||
# renovate: datasource=github-releases depName=google/go-containerregistry
|
||
CRANE_VERSION = "0.22.1"
|
||
|
||
# ── Override action refs with full GitHub URLs ───────────────────
|
||
# Gitea Actions resolves short refs (e.g. actions/checkout@v4) against
|
||
# the local Gitea instance. Use full URLs to pull from GitHub.
|
||
|
||
|
||
def _checkout_step() -> str:
|
||
return """\
|
||
- name: Checkout
|
||
uses: https://github.com/actions/checkout@v4"""
|
||
|
||
|
||
def _setup_buildx_step() -> str:
|
||
return "" # Not needed — we use plain docker build/push
|
||
|
||
|
||
def _setup_uv_step(uv_version: str) -> str:
|
||
# astral-sh/setup-uv is a composite action that calls the Gitea API
|
||
# internally, which fails because 'astral-sh' isn't a local user.
|
||
# Install uv directly via the official installer script instead.
|
||
return """\
|
||
- name: Set up uv
|
||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||
env:
|
||
UV_INSTALL_DIR: /usr/local/bin"""
|
||
|
||
|
||
def _build_push_step(
|
||
img: dict,
|
||
tags_expr: str,
|
||
registry: str,
|
||
owner_repo: str,
|
||
*,
|
||
no_cache: bool = False,
|
||
load_only: bool = False,
|
||
) -> str:
|
||
"""docker build (via socket) + crane push (daemonless, uses container DNS).
|
||
|
||
docker push goes through the host daemon which can't resolve 'gitea'.
|
||
crane pushes directly from the job container's network stack.
|
||
"""
|
||
name = img["name"]
|
||
tags = [t.strip() for t in tags_expr.split(",") if t.strip()]
|
||
local_tag = f"local/{name}:build"
|
||
# A from-scratch rebuild must also re-pull base images — --no-cache alone
|
||
# reuses whatever stale base the runner already has.
|
||
cache_flag = " --pull --no-cache" if no_cache else ""
|
||
lines = f"""\
|
||
- name: Build {name}
|
||
run: docker build{cache_flag} -f {img["dockerfile"]} -t {local_tag} {img["context"]}"""
|
||
if not load_only and tags:
|
||
# Save to tarball, then crane push each tag (daemonless)
|
||
push_cmds = "\n ".join(
|
||
f"crane push /tmp/{name}.tar {t} --insecure" for t in tags
|
||
)
|
||
lines += f"""
|
||
|
||
- name: Push {name}
|
||
run: |
|
||
docker save {local_tag} -o /tmp/{name}.tar
|
||
{push_cmds}"""
|
||
return lines
|
||
|
||
|
||
def _install_trivy_step() -> str:
|
||
"""Install a pinned trivy as a native binary — no Docker-in-Docker needed.
|
||
|
||
The installer from the release tag verifies the tarball's sha256 against
|
||
the release checksums.
|
||
"""
|
||
v = TRIVY_VERSION
|
||
return f"""\
|
||
- name: Install trivy
|
||
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/v{v}/contrib/install.sh | sh -s -- -b /usr/local/bin v{v}"""
|
||
|
||
|
||
def _trivy_step(
|
||
img: dict,
|
||
tag: str,
|
||
registry: str,
|
||
owner_repo: str,
|
||
) -> str:
|
||
"""Scan local Docker image — no registry pull, no DNS resolution."""
|
||
name = img["name"]
|
||
local_tag = f"local/{name}:build"
|
||
sev = img.get("trivy_severity", "HIGH,CRITICAL")
|
||
ec = img.get("trivy_exit_code", 0)
|
||
return f"""\
|
||
- name: Scan {name}
|
||
run: trivy image --severity {sev} --exit-code {ec} --format json -o {name}-scan.json {local_tag}"""
|
||
|
||
|
||
# ── Failure reporting ─────────────────────────────────────────────
|
||
|
||
|
||
def _failure_step(workflow_name: str, job_name: str) -> str:
|
||
"""Emit an if:failure() step that files a Gitea issue."""
|
||
return f"""\
|
||
- name: File failure issue
|
||
if: failure()
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
# Jobs that never install uv (image builds, infra checks) still
|
||
# need it here — otherwise the filer dies with "uv: command not
|
||
# found" and the failure is never filed.
|
||
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||
export PATH="$HOME/.local/bin:$PATH"
|
||
uv sync --no-dev --quiet 2>/dev/null || true
|
||
uv run python -m api.diag.ci \\
|
||
--workflow "{workflow_name}" --job "{job_name}" \\
|
||
--run "${{{{ github.run_number }}}}" \\
|
||
--sha "${{{{ github.sha }}}}" \\
|
||
--ref "${{{{ github.ref }}}}" || true"""
|
||
|
||
|
||
# ── Gitea-specific helpers ───────────────────────────────────────
|
||
|
||
|
||
def _docker_login_step(registry: str) -> str:
|
||
"""Install crane and authenticate — daemonless push tool."""
|
||
return f"""\
|
||
- name: Install crane
|
||
run: |
|
||
cd /tmp
|
||
base=https://github.com/google/go-containerregistry/releases/download/v{CRANE_VERSION}
|
||
tgz=go-containerregistry_Linux_x86_64.tar.gz
|
||
curl -fsSLO "$base/$tgz"
|
||
curl -fsSL "$base/checksums.txt" | grep -F " $tgz" | sha256sum -c -
|
||
tar xzf "$tgz" -C /usr/local/bin crane
|
||
|
||
- name: Log in to registry
|
||
run: crane auth login {registry} -u "${{{{ secrets.REGISTRY_USER }}}}" -p "${{{{ secrets.REGISTRY_TOKEN }}}}"
|
||
env:
|
||
CRANE_INSECURE: "true\""""
|
||
|
||
|
||
# ── Workflow generators ──────────────────────────────────────────
|
||
|
||
|
||
def _gen_ci(
|
||
runner: str, uv_version: str, coverage_threshold: int = 99, **_kw: object
|
||
) -> tuple[str, str]:
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches: ["**"]
|
||
pull_request:
|
||
|
||
jobs:
|
||
lint:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Install dependencies
|
||
run: uv sync --dev
|
||
|
||
- name: Ruff check
|
||
run: uv run ruff check src/ tests/ --output-format=concise
|
||
|
||
- name: Ruff format
|
||
run: uv run ruff format --check src/ tests/
|
||
|
||
- name: Validate generated config
|
||
run: uv run python dev/scripts/gen_config.py --check
|
||
|
||
test:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Install dependencies
|
||
run: uv sync --dev
|
||
|
||
- name: Preinstall duckdb extensions
|
||
# tests/zot/test_duck.py runs INSTALL sqlite from every xdist
|
||
# worker; concurrent installs race the extension-file rename in
|
||
# ~/.duckdb ("Could not remove file ... sqlite_scanner", #515).
|
||
# Installing once up front makes the in-test INSTALL a no-op.
|
||
# ducklake: same race class, used by tests/aco DuckLakeContext.
|
||
run: uv run python -c "import duckdb; duckdb.connect().execute('INSTALL sqlite; INSTALL ducklake')"
|
||
|
||
- name: Pytest
|
||
# -n auto parallelizes across runner cores. Coverage combining
|
||
# is configured via [tool.coverage.run] parallel=true in
|
||
# pyproject.toml — without that the per-worker .coverage.* files
|
||
# don't merge reliably (see #388 for the prior revert).
|
||
run: uv run pytest tests/ -x --cov=src --cov-report=term-missing --cov-fail-under={coverage_threshold} -q -n auto
|
||
|
||
{_failure_step("CI", "test")}
|
||
|
||
notebooks-smoke:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Install dependencies
|
||
run: uv sync --dev
|
||
|
||
- name: Stub aco.duckdb
|
||
# The checkout has no data volume; conf.connect.duckdb() opens
|
||
# data/aco.duckdb read-only and fails if it doesn't exist. An
|
||
# empty-but-valid database is enough for the ci-smoke notebooks
|
||
# (schema browsing over information_schema returns zero rows).
|
||
run: |
|
||
[ -f data/aco.duckdb ] || uv run python -c \\
|
||
"import duckdb; duckdb.connect('data/aco.duckdb').close()"
|
||
|
||
- name: Run data-independent notebooks headless
|
||
# Executes the [ci_smoke] set from infra/marimo/nb-tests.toml via
|
||
# `marimo export session` and fails on any cell error. Runs on
|
||
# every push (not path-gated): notebooks import src/ modules, so
|
||
# src changes can break them too.
|
||
run: uv run python dev/scripts/nb_integration.py --set ci-smoke
|
||
|
||
{_failure_step("CI", "notebooks-smoke")}
|
||
"""
|
||
return (".gitea/workflows/ci.yml", content)
|
||
|
||
|
||
_CHANGES_JOB = "changes"
|
||
_CHANGES_STEP_ID = "filter"
|
||
_BASE_STEP_ID = "base"
|
||
_DEPLOY_WORKFLOW = "deploy.yml"
|
||
_ALL_CHANGED = "__all__"
|
||
_NULL_SHA = "0" * 40
|
||
|
||
# Shell body of the step that finds the last successful Deploy's commit.
|
||
_BASE_SCRIPT = f"""\
|
||
# Diff from the last *successful* Deploy, not from the previous
|
||
# push: a re-push cancels the run in flight, and diffing only
|
||
# github.event.before then dropped the cancelled push's paths for
|
||
# good (2026-10-27: the notebooks image never rebuilt for 9cba4fd
|
||
# or 409fc6f). A failed run likewise stays in the next run's range
|
||
# until it lands. The runner image has no python or jq; Gitea
|
||
# writes each run's "path" before its "head_sha", so grep + awk
|
||
# pair them (awk reads to the end, so grep never takes a SIGPIPE
|
||
# under pipefail). Nothing found leaves sha empty: build everything.
|
||
sha="$(curl -sf -H "Authorization: token $GITEA_TOKEN" \\
|
||
"$API/repos/$REPO/actions/runs?status=success&branch=main&limit=50" \\
|
||
| grep -o '"path":"[^"]*"\\|"head_sha":"[0-9a-f]*"' \\
|
||
| awk -F'"' '$2 == "path" {{p = $4}}
|
||
$2 == "head_sha" && !sha && index(p, "{_DEPLOY_WORKFLOW}@") == 1 {{sha = $4}}
|
||
END {{print sha}}')" \\
|
||
|| sha=""
|
||
echo "last successful deploy: ${{sha:-none}}"
|
||
echo "sha=$sha" >> "$GITHUB_OUTPUT\""""
|
||
|
||
# Shell body of the change-detection step; the braces of the `match`
|
||
# function are doubled so they survive the f-string.
|
||
_CHANGES_SCRIPT = f"""\
|
||
# `github.event.head_commit.modified` only describes the tip commit
|
||
# and is empty for merge commits, so a PR merged with a merge commit
|
||
# never matched any path filter. Diff a range instead: from the
|
||
# last successfully deployed commit (the base step) to the new tip.
|
||
# When that commit is unknown or unfetchable (no successful run,
|
||
# API unreachable, history rewrite) build everything rather than
|
||
# nothing.
|
||
changed={_ALL_CHANGED}
|
||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "{_NULL_SHA}" ] \\
|
||
&& git fetch --quiet --depth=1 origin "$BEFORE"; then
|
||
changed="$(git diff --name-only "$BEFORE" "$GITHUB_SHA")" || changed={_ALL_CHANGED}
|
||
fi
|
||
echo "changed since $BEFORE:"
|
||
printf '%s\\n' "$changed"
|
||
match() {{
|
||
name="$1"; shift
|
||
hit=false
|
||
if [ "$changed" = {_ALL_CHANGED} ]; then
|
||
hit=true
|
||
else
|
||
while IFS= read -r f; do
|
||
[ -n "$f" ] || continue
|
||
for g in "$@"; do
|
||
case "$f" in $g) hit=true ;; esac
|
||
done
|
||
done <<< "$changed"
|
||
fi
|
||
echo "$name=$hit" >> "$GITHUB_OUTPUT"
|
||
echo "$name: $hit"
|
||
}}"""
|
||
|
||
|
||
def _case_glob(pattern: str) -> str:
|
||
"""Translate a ``path_filter`` glob into a bash ``case`` pattern.
|
||
|
||
``*`` already crosses ``/`` in ``case``, so ``src/**`` becomes ``src/*``.
|
||
"""
|
||
while "**" in pattern:
|
||
pattern = pattern.replace("**", "*")
|
||
return pattern
|
||
|
||
|
||
def _changes_job(images: list[dict], runner: str) -> str:
|
||
"""Emit the job that turns the pushed diff into one boolean per image."""
|
||
outputs = "\n".join(
|
||
f" {img['name']}: "
|
||
f"${{{{ steps.{_CHANGES_STEP_ID}.outputs.{img['name']} }}}}"
|
||
for img in images
|
||
)
|
||
matches = []
|
||
for img in images:
|
||
globs = [_case_glob(p) for p in img.get("path_filter", [])]
|
||
if globs:
|
||
quoted = " ".join(f"'{g}'" for g in globs)
|
||
matches.append(f" match {img['name']} {quoted}")
|
||
else:
|
||
# No filter means the image follows every push.
|
||
matches.append(f' echo "{img["name"]}=true" >> "$GITHUB_OUTPUT"')
|
||
match_block = "\n".join(matches)
|
||
return f"""\
|
||
{_CHANGES_JOB}:
|
||
runs-on: {runner}
|
||
outputs:
|
||
{outputs}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
- name: Find the last successful deploy
|
||
id: {_BASE_STEP_ID}
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
API: http://git:3000/api/v1
|
||
REPO: ${{{{ github.repository }}}}
|
||
run: |
|
||
{_BASE_SCRIPT}
|
||
|
||
- name: Detect changed paths
|
||
id: {_CHANGES_STEP_ID}
|
||
env:
|
||
BEFORE: ${{{{ steps.{_BASE_STEP_ID}.outputs.sha }}}}
|
||
run: |
|
||
{_CHANGES_SCRIPT}
|
||
{match_block}"""
|
||
|
||
|
||
def _gen_deploy(
|
||
images: list[dict],
|
||
scans: list[dict],
|
||
registry: str,
|
||
owner_repo: str,
|
||
runner: str,
|
||
uv_version: str,
|
||
**_kw: object,
|
||
) -> tuple[str, str]:
|
||
scan_set = {s["name"] for s in scans}
|
||
|
||
# Gitea Actions doesn't support `paths:` filters on push, so one job
|
||
# computes which images changed since the last successful deploy and
|
||
# the per-image jobs gate on its outputs. workflow_dispatch runs the
|
||
# same catch-up without a push.
|
||
job_blocks = [_changes_job(images, runner)]
|
||
job_names = [_CHANGES_JOB]
|
||
for img in images:
|
||
name = img["name"]
|
||
job_names.append(name)
|
||
tags = (
|
||
f"{registry}/{owner_repo}/{name}:${{{{ env.SHORT_SHA }}}},"
|
||
f"{registry}/{owner_repo}/{name}:latest"
|
||
)
|
||
scan_step = ""
|
||
if name in scan_set:
|
||
scan_step = f"""
|
||
|
||
{_install_trivy_step()}
|
||
|
||
{_trivy_step(img, "${{{{ env.SHORT_SHA }}}}", registry, owner_repo)}"""
|
||
|
||
job_blocks.append(f"""\
|
||
{name}:
|
||
runs-on: {runner}
|
||
needs: [{_CHANGES_JOB}]
|
||
if: needs.{_CHANGES_JOB}.outputs.{name} == 'true'
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_docker_login_step(registry)}
|
||
{scan_step}
|
||
|
||
- name: Compute short SHA
|
||
run: echo "SHORT_SHA=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
|
||
|
||
{_build_push_step(img, tags, registry, owner_repo)}
|
||
|
||
{_failure_step("Deploy", name)}""")
|
||
|
||
jobs_block = "\n\n".join(job_blocks)
|
||
needs_list = ", ".join(job_names)
|
||
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Deploy
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
push:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
{jobs_block}
|
||
|
||
report:
|
||
runs-on: {runner}
|
||
needs: [{needs_list}]
|
||
if: always() && !cancelled()
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Report
|
||
# Only file an issue if at least one upstream build job failed.
|
||
# Without this gate api.diag.ci runs unconditionally and files
|
||
# a fake "failed" issue on every successful Deploy run.
|
||
if: contains(needs.*.result, 'failure')
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
uv sync --no-dev --quiet 2>/dev/null || true
|
||
uv run python -m api.diag.ci \\
|
||
--workflow "Deploy" --job "report" \\
|
||
--run "${{{{ github.run_number }}}}" \\
|
||
--sha "${{{{ github.sha }}}}" \\
|
||
--ref "${{{{ github.ref }}}}" || true
|
||
"""
|
||
return (".gitea/workflows/deploy.yml", content)
|
||
|
||
|
||
def _gen_harden(
|
||
images: list[dict],
|
||
scans: list[dict],
|
||
registry: str,
|
||
owner_repo: str,
|
||
runner: str,
|
||
uv_version: str,
|
||
**_kw: object,
|
||
) -> tuple[str, str]:
|
||
# Each image is built, scanned and pushed on its own, and a failed build
|
||
# only skips that image: one dead upstream base (the archived minio/mc,
|
||
# Sept–Oct 2026) used to abort the job before any scan ran, leaving every
|
||
# image unscanned for weeks. The gate step at the end still fails the job
|
||
# — after the vuln report — so the breakage is filed, not swallowed.
|
||
scan_names = {i["name"] for i in scans}
|
||
image_steps = []
|
||
for img in images:
|
||
name = img["name"]
|
||
step_id = f"build_{name.replace('-', '_')}"
|
||
built = f"steps.{step_id}.outcome == 'success'"
|
||
steps = [
|
||
f"""\
|
||
- name: Build {name}
|
||
id: {step_id}
|
||
continue-on-error: true
|
||
run: docker build --pull --no-cache -f {img["dockerfile"]} -t local/{name}:build {img["context"]}"""
|
||
]
|
||
if name in scan_names:
|
||
sev = img.get("trivy_severity", "HIGH,CRITICAL")
|
||
ec = img.get("trivy_exit_code", 0)
|
||
steps.append(f"""\
|
||
- name: Scan {name}
|
||
if: ${{{{ {built} }}}}
|
||
run: trivy image --severity {sev} --exit-code {ec} --format json -o {name}-scan.json local/{name}:build""")
|
||
steps.append(f"""\
|
||
- name: Push {name}
|
||
if: ${{{{ {built} }}}}
|
||
run: |
|
||
docker save local/{name}:build -o /tmp/{name}.tar
|
||
crane push /tmp/{name}.tar {registry}/{owner_repo}/{name}:hardened --insecure
|
||
crane push /tmp/{name}.tar {registry}/{owner_repo}/{name}:latest --insecure""")
|
||
image_steps.append("\n\n".join(steps))
|
||
image_block = "\n\n".join(image_steps)
|
||
|
||
gate_lines = "\n ".join(
|
||
f'[ "${{{{ steps.build_{i["name"].replace("-", "_")}.outcome }}}}" = success ]'
|
||
f' || failed="$failed {i["name"]}"'
|
||
for i in images
|
||
)
|
||
|
||
vuln_files = " ".join(f"{i['name']}-scan.json" for i in scans)
|
||
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Harden
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "0 2 * * 0"
|
||
|
||
jobs:
|
||
build-scan-report:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_docker_login_step(registry)}
|
||
|
||
{_install_trivy_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
{image_block}
|
||
|
||
- name: Close resolved or file new vuln issues
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
uv sync --no-dev
|
||
for f in {vuln_files}; do
|
||
if [ -f "$f" ]; then
|
||
uv run python -m api.diag.vuln --close "$f" || \\
|
||
uv run python -m api.diag.vuln "$f" || true
|
||
fi
|
||
done
|
||
|
||
- name: Fail if any image failed to build
|
||
run: |
|
||
failed=""
|
||
{gate_lines}
|
||
if [ -n "$failed" ]; then
|
||
echo "Images that failed to build (not scanned, not pushed):$failed"
|
||
exit 1
|
||
fi
|
||
|
||
{_failure_step("Harden", "build-scan-report")}
|
||
"""
|
||
return (".gitea/workflows/harden.yml", content)
|
||
|
||
|
||
def _gen_rebuild_all(
|
||
images: list[dict],
|
||
scans: list[dict],
|
||
registry: str,
|
||
owner_repo: str,
|
||
runner: str,
|
||
uv_version: str,
|
||
**_kw: object,
|
||
) -> tuple[str, str]:
|
||
build_steps = []
|
||
for img in images:
|
||
tags = (
|
||
f"{registry}/{owner_repo}/{img['name']}:${{{{ env.SHORT_SHA }}}},"
|
||
f"{registry}/{owner_repo}/{img['name']}:latest"
|
||
)
|
||
build_steps.append(_build_push_step(img, tags, registry, owner_repo))
|
||
build_block = "\n\n".join(build_steps)
|
||
|
||
scan_steps = []
|
||
for img in scans:
|
||
scan_steps.append(
|
||
_trivy_step(img, "${{ env.SHORT_SHA }}", registry, owner_repo)
|
||
)
|
||
scan_block = "\n\n".join(scan_steps)
|
||
|
||
vuln_files = " ".join(f"{i['name']}-scan.json" for i in scans)
|
||
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Rebuild All
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
|
||
jobs:
|
||
build-scan-report:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_docker_login_step(registry)}
|
||
|
||
{_install_trivy_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Compute short SHA
|
||
run: echo "SHORT_SHA=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
|
||
|
||
{build_block}
|
||
|
||
{scan_block}
|
||
|
||
- name: Report vulnerabilities
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
uv sync --no-dev
|
||
for f in {vuln_files}; do
|
||
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
|
||
done
|
||
|
||
{_failure_step("Rebuild All", "build-scan-report")}
|
||
"""
|
||
return (".gitea/workflows/rebuild-all.yml", content)
|
||
|
||
|
||
def _gen_infra_ci(
|
||
images: list[dict],
|
||
runner: str,
|
||
**_kw: object,
|
||
) -> tuple[str, str]:
|
||
all_paths: list[str] = []
|
||
for img in images:
|
||
all_paths.extend(img.get("path_filter", []))
|
||
paths_block = "\n".join(f" - {p!r}" for p in all_paths)
|
||
|
||
jobs_parts = []
|
||
for img in images:
|
||
if not img.get("hadolint", True):
|
||
continue
|
||
name = img["name"]
|
||
# The notebooks image gets a frontend smoke gate: boot the freshly
|
||
# built image and load the editor in a headless browser, failing on
|
||
# any console/page error. Guards against runtime-broken frontend
|
||
# bundles that compile cleanly (the 2026-07-09 "d is not a
|
||
# constructor" incident shipped through a green build).
|
||
fe_smoke_step = ""
|
||
if name == "notebooks":
|
||
fe_smoke_step = f"""
|
||
|
||
- name: Frontend smoke gate
|
||
run: python3 dev/scripts/nb_fe_smoke.py --image local/{name}:build --network ci"""
|
||
jobs_parts.append(f"""\
|
||
{name}:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
- name: Hadolint {name}
|
||
uses: https://github.com/hadolint/hadolint-action@v3.1.0
|
||
with:
|
||
dockerfile: {img["dockerfile"]}
|
||
|
||
{_setup_buildx_step()}
|
||
|
||
{_build_push_step(img, f"ci-test-{name}", "", "", load_only=True)}{fe_smoke_step}
|
||
|
||
{_failure_step("Infra CI", name)}""")
|
||
|
||
jobs_block = "\n\n".join(jobs_parts)
|
||
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Infra CI
|
||
|
||
on:
|
||
push:
|
||
paths:
|
||
{paths_block}
|
||
pull_request:
|
||
paths:
|
||
{paths_block}
|
||
|
||
jobs:
|
||
{jobs_block}
|
||
"""
|
||
return (".gitea/workflows/infra-ci.yml", content)
|
||
|
||
|
||
def _gen_notebooks_integration(
|
||
runner: str, uv_version: str, **_kw: object
|
||
) -> tuple[str, str]:
|
||
"""Nightly full-set notebook run inside the production container.
|
||
|
||
The full notebook set needs real data (aco.duckdb, ./data mounts) that
|
||
only the prod container has, so the scripts are docker-cp'd in and run
|
||
there. Failures are routed through nb_issue_filer (dedup + auto-close),
|
||
not exit codes — a red nightly run should page via the issue tracker,
|
||
not accumulate api.diag.ci duplicates. 03:30 sits before the 06:00
|
||
pkg-supply-chain run and outside interactive hours (duckdb lock, #508).
|
||
"""
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Notebooks Integration
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "30 3 * * *"
|
||
|
||
jobs:
|
||
notebooks-integration:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Run full notebook set in prod container
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
set -euo pipefail
|
||
docker exec notebooks mkdir -p /tmp/nbtest
|
||
docker cp dev/scripts/nb_integration.py notebooks:/tmp/nbtest/
|
||
docker cp dev/scripts/nb_issue_filer.py notebooks:/tmp/nbtest/
|
||
docker cp infra/marimo/nb-tests.toml notebooks:/tmp/nbtest/
|
||
docker exec \\
|
||
-e GITEA_TOKEN \\
|
||
-e GITEA_API_BASE=http://git:3000/api/v1 \\
|
||
notebooks \\
|
||
uv run --project /home/kert/workspace python /tmp/nbtest/nb_integration.py \\
|
||
--set all --file-issues \\
|
||
--nb-dir /home/kert/notebooks \\
|
||
--config /tmp/nbtest/nb-tests.toml \\
|
||
--report /tmp/nbtest/report.json
|
||
docker cp notebooks:/tmp/nbtest/report.json nb-integration-report.json
|
||
cat nb-integration-report.json
|
||
|
||
- name: Frontend smoke against live service
|
||
run: python3 dev/scripts/nb_fe_smoke.py --url http://notebooks:2718 --network gateway
|
||
|
||
{_failure_step("Notebooks Integration", "notebooks-integration")}
|
||
"""
|
||
return (".gitea/workflows/notebooks-integration.yml", content)
|
||
|
||
|
||
def _gen_zotero_sync(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||
"""Nightly bib → Zotero routing for mail-ingested items.
|
||
|
||
mail-poller ingests CMS mail into bib continuously, but Zotero only
|
||
sees those items when `stack bib sync-zotero` pushes them — and that
|
||
had no scheduler, so the Cmsupdates collection silently froze for two
|
||
months (Apr 29 → Jul 10, 212-item backlog). sync writes directly into
|
||
zotero.sqlite, so the running Zotero app must be stopped for the
|
||
duration; the stop/start lives in workflow steps (visible, and
|
||
restart is if:always()) rather than the CLI's --hold-zotero, because
|
||
the sync runs docker-exec'd inside the api container, which has no
|
||
docker socket. 04:15 sits after the 03:30 notebooks-integration run
|
||
so the two never contend for zotero.sqlite.
|
||
"""
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Zotero Sync
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "15 4 * * *"
|
||
|
||
jobs:
|
||
zotero-sync:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Stop zotero (release SQLite write lock)
|
||
run: docker stop zotero
|
||
|
||
- name: Sync mail-ingested bib items into Zotero
|
||
# --merge-dupes: listserv re-sends are now aliased at ingest
|
||
# (Task 3), and any same-URL twins that still land in Zotero are
|
||
# merged (union tags/collections/notes/attachments, keep-earliest)
|
||
# rather than deleted.
|
||
run: |
|
||
docker exec api uv run --no-sync \\
|
||
stack bib sync-zotero --tag source:email --no-hold \\
|
||
--merge-dupes
|
||
|
||
- name: Sync Federal Register rule items into Zotero
|
||
# Rule items only ever reached Zotero via one-off manual syncs
|
||
# (P36 step 5); P37 skipped that step and the CY2027 NPRM never
|
||
# arrived (#626/#627). Tag-scoped: a full unscoped sync would
|
||
# hydrate ~180k bib items.
|
||
run: |
|
||
docker exec api uv run --no-sync \\
|
||
stack bib sync-zotero --tag source:federal-register --no-hold
|
||
|
||
- name: Restart zotero
|
||
if: always()
|
||
run: docker start zotero
|
||
|
||
{_failure_step("Zotero Sync", "zotero-sync")}
|
||
"""
|
||
return (".gitea/workflows/zotero-sync.yml", content)
|
||
|
||
|
||
def _gen_comment_pull(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||
"""Nightly pull of every open regulations.gov docket.
|
||
|
||
Nothing pulled open dockets on a schedule: the FY2027 Hospice and
|
||
CY2027 ESRD proposed-rule dockets sat unpulled for months (215 + 228
|
||
comments, found 2026-10-27). `stack bib pull-open-dockets` walks each
|
||
unsealed docket from its watermark and enriches the new comments; a
|
||
docket quiet long enough after its comment period seals itself, so the
|
||
job costs nothing between rulemaking seasons. `fetch-pfs-comments` for
|
||
the current year registers a new PFS proposed rule (July) the night it
|
||
appears. Indexing runs in the llm container (its embeddings host and
|
||
pgvector settings live there). 02:30 runs before the 03:30 notebooks,
|
||
03:40 golden and 04:15 Zotero jobs.
|
||
"""
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Comment Pull
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "30 2 * * *"
|
||
|
||
jobs:
|
||
comment-pull:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Register this year's PFS proposed rule, if published
|
||
run: |
|
||
docker exec api uv run --no-sync \\
|
||
stack bib fetch-pfs-comments --since "$(date +%Y)-01-01" --attachments
|
||
|
||
- name: Pull and enrich every open docket
|
||
run: |
|
||
docker exec api uv run --no-sync stack bib pull-open-dockets
|
||
|
||
- name: Index new comments for the chat search
|
||
run: |
|
||
docker exec llm uv run --no-sync stack llm index --collection comments
|
||
|
||
{_failure_step("Comment Pull", "comment-pull")}
|
||
"""
|
||
return (".gitea/workflows/comment-pull.yml", content)
|
||
|
||
|
||
def _gen_llm_golden(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||
"""Nightly golden longitudinal evaluation of the chat (P49 Task 7).
|
||
|
||
Mirrors ``_gen_notebooks_integration``: the golden set needs a live
|
||
chat over the real pgvector/DuckDB/Ollama stack, which only the
|
||
``llm`` container (``uvicorn llm.api:app``, port 8000, no published
|
||
port) has — so the runner and the golden set are docker-cp'd in and
|
||
run there against its own ``http://localhost:8000``. Regressions
|
||
are filed through ``nb_issue_filer`` (dedup + auto-close) under the
|
||
``llm`` label, not ``notebooks``. 03:40 sits after the 03:30
|
||
notebooks-integration run and before the 04:15 zotero-sync run.
|
||
"""
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: LLM Golden
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "40 3 * * *"
|
||
|
||
jobs:
|
||
llm-golden:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Run golden longitudinal evaluation against the live chat
|
||
# golden_rc is captured (not left to `set -e`) so a failing golden
|
||
# run still docker-cps the report back and cats it — llm_golden.py
|
||
# itself exits 0 once --file-issues has filed the regressions
|
||
# (nb_issue_filer is the failure channel here), but this still
|
||
# guards the case where it exits nonzero before reaching that
|
||
# point (e.g. a crash while loading the golden set).
|
||
env:
|
||
GITEA_TOKEN: ${{{{ secrets.DEPLOY_TOKEN }}}}
|
||
run: |
|
||
set -euo pipefail
|
||
docker exec llm mkdir -p /tmp/golden
|
||
docker cp dev/scripts/llm_golden.py llm:/tmp/golden/
|
||
docker cp dev/scripts/nb_issue_filer.py llm:/tmp/golden/
|
||
docker cp tests/llm/golden_lineage.yaml llm:/tmp/golden/
|
||
docker exec \\
|
||
-e GITEA_TOKEN \\
|
||
-e GITEA_API_BASE=http://git:3000/api/v1 \\
|
||
-e NB_ISSUE_LABEL=llm \\
|
||
llm \\
|
||
uv run --no-sync --project /app python /tmp/golden/llm_golden.py run \\
|
||
--url http://localhost:8000 \\
|
||
--set /tmp/golden/golden_lineage.yaml \\
|
||
--report /tmp/golden/report.json \\
|
||
--file-issues --source nightly-llm-golden || golden_rc=$?
|
||
docker cp llm:/tmp/golden/report.json llm-golden-report.json || true
|
||
cat llm-golden-report.json || true
|
||
exit "${{golden_rc:-0}}"
|
||
|
||
{_failure_step("LLM Golden", "llm-golden")}
|
||
"""
|
||
return (".gitea/workflows/llm-golden.yml", content)
|
||
|
||
|
||
def _gen_bcda_vendor(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||
"""Weekly drift check of the sparsely vendored BCDA upstream (P52).
|
||
|
||
``dev/vendor/MANIFEST.toml`` pins CMSgov/bcda-app (the v3 handler
|
||
and its tests, request validation, the OperationOutcome writer,
|
||
the v3 Postman collection, gzip fixtures, sandbox config) and the
|
||
bcda.cms.gov v3 data dictionary + sample files that
|
||
``bcda.express.v3`` is tested against. ``--check`` compares the
|
||
pinned blob/sha256 digests with upstream and exits 1 on drift; the
|
||
failure step files the issue. Nothing is auto-committed — a
|
||
payload change upstream is something a human reads before the
|
||
flattener is re-pointed (``vendor_bcda_app.py --sync`` then
|
||
``gen_bcda_v3_crosswalk.py``). Standard library only, so it runs
|
||
before ``uv sync``.
|
||
"""
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: BCDA Vendor
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
schedule:
|
||
- cron: "30 5 * * 1"
|
||
|
||
jobs:
|
||
bcda-vendor:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Verify the vendored tree matches the manifest
|
||
run: uv run --no-project python dev/scripts/vendor_bcda_app.py --verify
|
||
|
||
- name: Check CMSgov/bcda-app and bcda.cms.gov for drift
|
||
run: uv run --no-project python dev/scripts/vendor_bcda_app.py --check
|
||
|
||
{_failure_step("BCDA Vendor", "bcda-vendor")}
|
||
"""
|
||
return (".gitea/workflows/bcda-vendor.yml", content)
|
||
|
||
|
||
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
|
||
content = f"""\
|
||
{_HEADER}
|
||
name: Release
|
||
|
||
on:
|
||
push:
|
||
tags: ["v*"]
|
||
|
||
jobs:
|
||
release:
|
||
runs-on: {runner}
|
||
steps:
|
||
{_checkout_step()}
|
||
|
||
{_setup_uv_step(uv_version)}
|
||
|
||
- name: Build package
|
||
run: uv build --out-dir dist/
|
||
|
||
- name: Create release
|
||
uses: https://github.com/softprops/action-gh-release@v2
|
||
with:
|
||
files: |
|
||
dist/*.whl
|
||
dist/*.tar.gz
|
||
|
||
{_failure_step("Release", "release")}
|
||
"""
|
||
return (".gitea/workflows/release.yml", content)
|
||
|
||
|
||
# ── Public API ────────────────────────────────────────────────────
|
||
|
||
|
||
def emit(
|
||
images: list[dict],
|
||
scans: list[dict],
|
||
platform: dict,
|
||
ci_cfg: dict,
|
||
) -> dict[str, str]:
|
||
"""Return {relative_path: content} for all Gitea Actions workflows."""
|
||
# ci_registry is the internal HTTP endpoint used for push (avoids TLS)
|
||
registry = ci_cfg.get("ci_registry", platform["registry"])
|
||
owner_repo = platform["repo"]
|
||
runner = ci_cfg.get("runner_labels", ["ubuntu-latest"])[0]
|
||
uv_version = ci_cfg.get("uv_version", "latest")
|
||
|
||
common = dict(
|
||
images=images,
|
||
scans=scans,
|
||
registry=registry,
|
||
owner_repo=owner_repo,
|
||
runner=runner,
|
||
uv_version=uv_version,
|
||
coverage_threshold=ci_cfg.get("coverage_threshold", 99),
|
||
)
|
||
|
||
files: dict[str, str] = {}
|
||
for gen_fn in (
|
||
_gen_ci,
|
||
_gen_deploy,
|
||
_gen_harden,
|
||
_gen_rebuild_all,
|
||
_gen_infra_ci,
|
||
_gen_notebooks_integration,
|
||
_gen_zotero_sync,
|
||
_gen_comment_pull,
|
||
_gen_llm_golden,
|
||
_gen_bcda_vendor,
|
||
_gen_release,
|
||
):
|
||
path, content = gen_fn(**common) # type: ignore[arg-type]
|
||
files[path] = content
|
||
|
||
return files
|