Some checks failed
CI / lint (pull_request) Successful in 34s
CI / notebooks-smoke (pull_request) Successful in 1m29s
CI / test (pull_request) Failing after 2m8s
CI / lint (push) Successful in 34s
CI / test (push) Failing after 2m13s
CI / notebooks-smoke (push) Successful in 1m41s
The per-image jobs in deploy.yml were gated on contains(github.event.head_commit.modified, …). That list only describes the tip commit of a push and Gitea leaves it empty for merge commits, so every PR landed with a merge commit skipped all six image builds; the llm image for #1120 had to be built and deployed by hand. A new 'changes' job fetches the push's previous tip (github.event.before) into the shallow checkout and diffs it against the new tip, so the whole pushed range counts: a merge commit's branch, or all commits of a direct multi-commit push. Each image job now needs that job and gates on its boolean output; an image without path filters follows every push. When the previous tip is unknown or unfetchable (first push, history rewrite) every image builds rather than none. Tests cover the generated shape and run the detector script in a scratch repository for the merge-commit, multi-commit and unknown-tip cases.
862 lines
26 KiB
Python
862 lines
26 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,
|
|
)
|
|
|
|
# ── 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"
|
|
cache_flag = " --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 trivy as a native binary — no Docker-in-Docker needed."""
|
|
return """\
|
|
- name: Install trivy
|
|
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin"""
|
|
|
|
|
|
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: curl -sL https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz | tar xz -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"
|
|
_ALL_CHANGED = "__all__"
|
|
_NULL_SHA = "0" * 40
|
|
|
|
# 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 the pushed range instead:
|
|
# the branch tip before the push against the new one. When that
|
|
# previous tip is unknown or unfetchable (first push, 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: Detect changed paths
|
|
id: {_CHANGES_STEP_ID}
|
|
env:
|
|
BEFORE: ${{{{ github.event.before }}}}
|
|
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 the pushed range touches and the per-image
|
|
# jobs gate on its outputs.
|
|
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:
|
|
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]:
|
|
build_steps = []
|
|
for img in images:
|
|
tags = (
|
|
f"{registry}/{owner_repo}/{img['name']}:hardened,"
|
|
f"{registry}/{owner_repo}/{img['name']}:latest"
|
|
)
|
|
build_steps.append(
|
|
_build_push_step(img, tags, registry, owner_repo, no_cache=True)
|
|
)
|
|
build_block = "\n\n".join(build_steps)
|
|
|
|
scan_steps = []
|
|
for img in scans:
|
|
scan_steps.append(_trivy_step(img, "hardened", 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: 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)}
|
|
|
|
{build_block}
|
|
|
|
{scan_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
|
|
|
|
{_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_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_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_llm_golden,
|
|
_gen_release,
|
|
):
|
|
path, content = gen_fn(**common) # type: ignore[arg-type]
|
|
files[path] = content
|
|
|
|
return files
|