feat(standards): add Drone CI standard (PyPI package + Docker deployment)

Document the two `.drone.yml` shapes used across the org, derived from the
repos:

- Type A `python-lib.yaml`: build-and-publish-pypi + backfill-pypi-from-tags
- Type B `service-docker.yaml`: build-and-deploy-service + build-and-deploy-site

- add reference page `10_drone_pipelines`
- vendor `src/templates/drone/` into the MCP bundle (`drone.json`)
- expose `registry://drone` + `list_drone_templates` / `get_drone_template`
- wire `build.py` to vendor drone templates; rebuild nav/index (14 modules)
This commit is contained in:
2026-10-15 00:52:38 +05:30
parent 18d52bdb7c
commit a754366901
14 changed files with 1133 additions and 2 deletions

View File

@@ -336,6 +336,82 @@ def build_templates(folder: Path, dry_run: bool = False) -> None:
f"({len(manifest['components'])} components)")
# ── Drone standard bundle ─────────────────────────────────────────────────────
# The two CI shapes shipped in ``src/templates/drone``. There are exactly two
# types of ``.drone.yml`` in the org: a Python package (private PyPI) and a
# Docker deployment (build / push / run). See standards "10_drone_pipelines".
_DRONE_COMPONENTS: list[dict[str, str]] = [
{"kind": "drone-pypi", "path": "python-lib.yaml", "placeholder": "",
"description": "Drone pipeline: build + publish a Python package to the private PyPI index."},
{"kind": "drone-service", "path": "service-docker.yaml", "placeholder": "",
"description": "Drone pipeline: build, push, and deploy a Docker service or site."},
]
def build_drone_manifest(template_root: Path) -> dict[str, object]:
"""Describe every CI template in the vendored drone standard.
Args:
template_root (Path): The vendored ``templates/drone`` directory.
Returns:
dict[str, object]: Manifest with the two component entries.
"""
components: list[dict[str, str]] = []
for entry in _DRONE_COMPONENTS:
rel = entry["path"]
if not (template_root / rel).exists():
raise SystemExit(f"[ERR] drone template missing: {rel}")
components.append({
"kind": entry["kind"],
"path": rel,
"placeholder": entry["placeholder"],
"description": entry["description"],
})
return {
"project": "standards",
"template": "templates/drone",
"types": ["pypi", "docker"],
"components": components,
}
def build_drone_templates(folder: Path, dry_run: bool = False) -> None:
"""Vendor ``folder/src/templates/drone`` into ``<name>/mcp/templates/drone``.
Ships the two ``.drone.yml`` shapes (PyPI package, Docker deployment) in the
MCP bundle so an agent can pull the exact CI file, and emits ``drone.json``
describing them.
Args:
folder (Path): The hub folder owning ``src/templates/drone``.
dry_run (bool): Print the plan without executing.
"""
name = folder.name
src = folder / "src" / "templates" / "drone"
if not src.is_dir():
return
out_dir = folder / "mcp" / "templates" / "drone"
if dry_run:
print(f"[DRN] {name} {src.relative_to(ROOT)}/** -> {out_dir.relative_to(ROOT)}/**")
return
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
src, out_dir,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git"),
)
manifest = build_drone_manifest(out_dir)
(folder / "mcp" / "drone.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"[DRN] {name} drone templates vendored to {out_dir.relative_to(ROOT)}/ "
f"({len(manifest['components'])} components)")
# ── CLI ──────────────────────────────────────────────────────────────────────
@@ -355,6 +431,7 @@ def main() -> None:
for folder in folders:
build_bundle(folder, dry_run=args.dry_run)
build_templates(folder, dry_run=args.dry_run)
build_drone_templates(folder, dry_run=args.dry_run)
build_html(folder, dry_run=args.dry_run)

View File

@@ -10,6 +10,7 @@ Resources:
docs://spec (the pinned auth-server OpenAPI contract)
registry://libraries (the structured catalog)
registry://components (the template component index)
registry://drone (the CI template index)
Tools:
ping (from the base server)
@@ -17,6 +18,8 @@ Tools:
list_app_components (the template component kinds)
get_app_component (kind -> exact template file, seed tokens renamed)
get_app_template (the whole app skeleton as a path -> content map)
list_drone_templates (the two CI shapes: PyPI package / Docker deployment)
get_drone_template (kind -> exact .drone.yml template)
Prompts:
intake (the questions to gather before building)
@@ -126,6 +129,32 @@ class StandardsMCPServer(MCPServer):
return {"error": "not_found", "path": str(path)}
return json.loads(path.read_bytes())
def _drone(self) -> dict[str, Any]:
"""Load the CI (Drone) template manifest."""
path = Path(self.mcp_root) / "drone.json"
if not path.exists():
return {"error": "not_found", "path": str(path), "components": []}
return json.loads(path.read_bytes())
def _find_drone_component(self, kind: str) -> dict[str, Any] | None:
for entry in self._drone().get("components", []):
if entry.get("kind") == kind:
return entry
return None
def _read_drone_component(self, entry: dict[str, Any]) -> dict[str, Any]:
"""Read a vendored drone template file."""
rel = entry["path"]
path = Path(self.mcp_root) / "templates" / "drone" / rel
if not path.exists():
return {"error": "not_found", "path": str(path)}
return {
"kind": entry["kind"],
"path": rel,
"placeholder": entry.get("placeholder"),
"content": path.read_text(encoding="utf-8"),
}
def _find_component(self, kind: str) -> dict[str, Any] | None:
for entry in self._components().get("components", []):
if entry.get("kind") == kind:
@@ -195,6 +224,10 @@ class StandardsMCPServer(MCPServer):
def components() -> dict[str, Any]:
return self._components()
@self.app.resource("registry://drone")
def drone() -> dict[str, Any]:
return self._drone()
@self.app.resource("docs://spec")
def spec() -> dict[str, Any]:
"""Return the pinned auth-server OpenAPI contract.
@@ -307,6 +340,46 @@ class StandardsMCPServer(MCPServer):
"files": files,
}
@self.app.tool()
def list_drone_templates() -> list[dict[str, Any]]:
"""List the CI (Drone) template shapes.
There are exactly two types of ``.drone.yml`` in the org: a Python
package published to the private PyPI index, and a Docker
deployment that builds, pushes, and runs a container.
Returns:
list[dict[str, Any]]:
Entries with ``kind``, ``path``, ``placeholder`` and
``description``. Fetch one with :func:`get_drone_template`.
"""
return self._drone().get("components", [])
@self.app.tool()
def get_drone_template(kind: str) -> dict[str, Any]:
"""Return the exact CI template for a component kind.
Use this to copy the current ``.drone.yml`` shape instead of
inventing one. See :func:`list_drone_templates` for valid kinds
("drone-pypi", "drone-service").
Args:
kind (str): Component kind, e.g. "drone-pypi" or
"drone-service".
Returns:
dict[str, Any]: ``{kind, path, placeholder, content}`` or an
error entry when the kind is unknown.
"""
entry = self._find_drone_component(kind)
if entry is None:
return {
"error": "unknown_kind",
"kind": kind,
"available": [c["kind"] for c in self._drone().get("components", [])],
}
return self._read_drone_component(entry)
@self.app.prompt()
def intake() -> str:
"""Return the questions to gather before building an app.

22
standards/mcp/drone.json Normal file
View File

@@ -0,0 +1,22 @@
{
"project": "standards",
"template": "templates/drone",
"types": [
"pypi",
"docker"
],
"components": [
{
"kind": "drone-pypi",
"path": "python-lib.yaml",
"placeholder": "",
"description": "Drone pipeline: build + publish a Python package to the private PyPI index."
},
{
"kind": "drone-service",
"path": "service-docker.yaml",
"placeholder": "",
"description": "Drone pipeline: build, push, and deploy a Docker service or site."
}
]
}

View File

@@ -1,6 +1,6 @@
{
"project": "standards",
"type": "standards",
"modules_count": 13,
"modules_count": 14,
"source": "hand-authored"
}

View File

@@ -0,0 +1,8 @@
{
"module": "drone_pipelines",
"content": {
"path": "drone_pipelines",
"docstring": "# Drone Pipelines\n\n## Summary\n\nEvery Aetos repository ships a Drone CI definition at `.drone.yml`. There is\nexactly **one standard per repository**, and there are **two types of file**,\nchosen by what the repository produces:\n\n| Type | File | Produces | Repos |\n|------|------|----------|-------|\n| **A — PyPI package** | `templates/drone/python-lib.yaml` | A wheel + sdist on the private index | `dagpipe`, `doc-forge`, `hexa`, `mail-intake`, `mongo-ops`, `omniread`, `openapi-first`, `py-jwt` |\n| **B — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server`, `docs`, `mcp` |\n\nBoth templates run on `platform: linux / arm64` with the workspace at\n`/drone/src`. Copy the matching template to `<repo>/.drone.yml`; the Type A file\nneeds no edits, the Type B file needs its placeholders filled in (below).\n\n> Conventions: `.drone.yml` (not `.yaml`), one file per repo, secrets injected\n> with `from_secret` — never commit credentials.\n\n---\n\n## Type A — PyPI package (`python-lib.yaml`)\n\nFor internal libraries. The file contains **two pipelines**:\n\n1. **`build-and-publish-pypi`** — triggered on `tag`:\n - `check-version` — read `name` / `version` from `pyproject.toml` and query\n `$PIP_REPO_URL/simple/<package>/`; if that version already exists, exit\n `78` and skip the whole build.\n - `quality-gate` — install the package with its `[dev]` extras from the\n private index, then `black --check`, `ruff check`, `mypy`, `pydoclint`,\n `pytest`.\n - `build-package` — `python -m build` into `dist/`.\n - `upload-to-private-pypi` — `twine upload` to `$PIP_REPO_URL`.\n2. **`backfill-pypi-from-tags`** — triggered on `custom`:\n - `fetch-tags` — `git fetch --tags --force`.\n - `build-and-upload-missing` — walk every tag in version order, skip the ones\n already on the index, `git checkout` the rest, build, and upload. This is\n the disaster-recovery / replay path.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.\n\n- **Ship as-is.** Package name and version come from `pyproject.toml`, so the\n same file is reused verbatim across every library.\n\n---\n\n## Type B — Docker deployment (`service-docker.yaml`)\n\nFor services and sites that ship a container image. The file contains **two\npipelines**, each mirroring a repo family:\n\n1. **`build-and-deploy-service`** — triggered on `tag` (a long-running\n API/worker service, e.g. `auth-server`):\n - `fetch-tags` — resolve the latest tag into `LATEST_TAG.txt`; fail if there\n are no tags.\n - `check-remote-image` — if `aetos/<app>:<tag>` already exists locally, exit\n `78` and skip the build.\n - `quality-gate` — install from `requirements.txt` and run\n `black` / `ruff` / `mypy` / `pytest`.\n - `build-image` — `docker build` with the private-index build args, tagged\n `aetos/<app>:<tag>` and `:latest`.\n - `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.\n - `stop-old` — `docker rm -f <app>`.\n - `run-container` — `docker run -d --name <app> -p <host>:<container>\n --restart always` with the service's `-e` environment.\n2. **`build-and-deploy-site`** — triggered on `push` (a static/HTTP site, e.g.\n `docs`, `mcp`): `build-image` → `push-image` → `stop-old` → `run-container`,\n with no tag resolution and no quality gate.\n\nSecrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD` (service build args),\n`REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASS`, plus the service runtime env\n(for example `MONGO_*`, `JWT_SECRET`).\n\nPlaceholders to fill in per repo:\n\n| Placeholder | Meaning | Example |\n|-------------|---------|---------|\n| `<app>` | service image / container name | `auth-server` |\n| `aetos/<app>` | image repository on the registry | `aetos/auth-server` |\n| `<host-port>` / `<container-port>` | published : in-image port | `9003:8000` |\n| `<site>` | site image / container name | `docs` |\n| `<ENV_VAR>` | runtime environment variable | `MONGO_HOST` |\n\n---\n\n## What not to do\n\n- Do not hand-roll a third shape. A repo is either Type A or Type B, never both.\n- Do not inline secrets; always use `from_secret`.\n- Do not drop the `check-version` / `check-remote-image` skip — it guards the\n index and the running container against duplicate publishes.\n- Do not rename the pipelines; tooling and runbooks key off\n `build-and-publish-pypi`, `build-and-deploy-service`, and\n `build-and-deploy-site`.\n\n---\n\n## Related\n\n- [01 – Build Recipe](01_build_recipe.md) · [09 – Infra Adapters](09_infra_adaptors.md) · [13 – Library Registry](13_library_registry.md)\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "index",
"content": {
"path": "index",
"docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\n- **Build Recipe** — the ordered, agentic checklist for a new app.\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n- **Genericity Matrix** — what is library-backed, template-provided, or authored.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\n",
"docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\n- **Build Recipe** — the ordered, agentic checklist for a new app.\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n- **Drone Pipelines** — the two CI shapes: a PyPI package or a Docker deployment.\n- **Genericity Matrix** — what is library-backed, template-provided, or authored.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\n",
"objects": {}
}
}

View File

@@ -44,6 +44,11 @@
"resource": "docs://modules/infra_adaptors",
"title": "Infra Adapters"
},
{
"module": "drone_pipelines",
"resource": "docs://modules/drone_pipelines",
"title": "Drone Pipelines"
},
{
"module": "genericity_matrix",
"resource": "docs://modules/genericity_matrix",

View File

@@ -0,0 +1,164 @@
# Drone standard — Type A: PyPI package
#
# Canonical pipeline for internal Python libraries that ship a wheel/sdist to
# the private index (dagpipe, doc-forge, hexa, mail-intake, mongo-ops,
# omniread, openapi-first, py-jwt).
#
# Two pipelines ship together:
# build-and-publish-pypi — tag-triggered build + quality gate + publish.
# backfill-pypi-from-tags — custom-triggered replay of every git tag that is
# missing from the index (disaster recovery).
#
# Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD.
# Copy to <repo>/.drone.yml unchanged; the package name and version are read
# from pyproject.toml, so no per-repo edits are required.
---
kind: pipeline
type: docker
name: build-and-publish-pypi
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: check-version
image: curlimages/curl:latest
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- echo "Checking if $PACKAGE_NAME==$VERSION exists on $PIP_REPO_URL ..."
- |
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" "$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -qE "${PACKAGE_NAME}-${VERSION}(-|\.tar\.gz|\.whl)"; then
echo "$PACKAGE_NAME==$VERSION already exists; skipping build."
exit 78
else
echo "New version detected: $PACKAGE_NAME==$VERSION"
fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip build
- |
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]" "pydoclint>=0.9.1"
- echo "Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy
- pydoclint .
- python -m pytest
- name: build-package
image: python:3.13-slim
commands:
- pip install --upgrade pip build
- echo "Building Python package..."
- python -m build
- ls -l dist
- name: upload-to-private-pypi
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade twine
- echo "Uploading to private PyPI at $PIP_REPO_URL ..."
- |
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: backfill-pypi-from-tags
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: fetch-tags
image: alpine/git
commands:
- git fetch --tags --force
- name: build-and-upload-missing
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- apt-get update
- apt-get install -y git curl ca-certificates
- pip install --upgrade pip build twine
- |
set -e
PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | cut -d'"' -f2)
echo "Package: $PACKAGE_NAME"
for TAG in $(git tag --sort=version:refname); do
VERSION="$TAG"
echo "Version: $VERSION"
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" \
"$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -qE "${PACKAGE_NAME}-${VERSION}(-|\.tar\.gz|\.whl)"; then
echo "Exists, skipping"
continue
fi
git checkout --force "$TAG"
echo "Building $VERSION"
rm -rf dist
python -m build
echo "Uploading $VERSION"
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
done
trigger:
event:
- custom

View File

@@ -0,0 +1,257 @@
# Drone standard — Type B: Docker deployment
#
# Canonical pipelines for services that build an image, push it to the private
# registry, and run it on the Docker host (auth-server, docs, mcp).
#
# Two variants ship together:
# build-and-deploy-service — a long-running API/worker service: fetch the git
# tag, skip if the image already exists, run the
# quality gate, build, push, restart the container.
# build-and-deploy-site — a static/HTTP site: build, push, restart, on
# every push (no tag, no quality gate).
#
# Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD (service only),
# REGISTRY_HOST, REGISTRY_USER, REGISTRY_PASS, plus the service's runtime env
# (for example MONGO_* / JWT_SECRET).
#
# Placeholders to set per repo:
# <app> image / container / network name (e.g. auth-server).
# aetos/<app> image repository on the private registry.
# <host-port> published host port; <container-port> the in-image port.
# <site> site image name for the site variant (e.g. docs, mcp).
# <env-var> runtime environment variable consumed by the service.
---
kind: pipeline
type: docker
name: build-and-deploy-service
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "No git tags found! Cannot continue."
exit 1
fi
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if aetos/<app>:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for aetos/<app>:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^aetos/<app>" || echo "(none)"
- |
if docker image inspect aetos/<app>:$IMAGE_TAG > /dev/null 2>&1; then
echo "Docker image aetos/<app>:$IMAGE_TAG already exists; skipping build"
exit 78
else
echo "Docker image aetos/<app>:$IMAGE_TAG not found; proceeding to build..."
fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip
- pip install --extra-index-url "https://$PIP_USERNAME:$PIP_PASSWORD@$PIP_REPO_URL/simple" -r requirements.txt
- pip install black ruff mypy
- echo "Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy .
- python -m pytest
- name: build-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Building Docker image aetos/<app>:$IMAGE_TAG ..."
- |
docker build \
--network=host \
--build-arg PIP_REPO_URL="$PIP_REPO_URL" \
--build-arg PIP_USERNAME="$PIP_USERNAME" \
--build-arg PIP_PASSWORD="$PIP_PASSWORD" \
-t aetos/<app>:$IMAGE_TAG \
-t aetos/<app>:latest \
/drone/src
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "Tagging images with registry prefix..."
- docker tag aetos/<app>:$IMAGE_TAG $REGISTRY_HOST/aetos/<app>:$IMAGE_TAG
- docker tag aetos/<app>:$IMAGE_TAG $REGISTRY_HOST/aetos/<app>:latest
- echo "Pushing aetos/<app>:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/aetos/<app>:$IMAGE_TAG
- echo "Pushing aetos/<app>:latest ..."
- docker push $REGISTRY_HOST/aetos/<app>:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Stopping old container..."
- docker rm -f <app> || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
<ENV_VAR>:
from_secret: <ENV_VAR>
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Starting container aetos/<app>:$IMAGE_TAG ..."
- |
docker run -d \
--name <app> \
-p <host-port>:<container-port> \
--add-host private-pi:192.168.1.111 \
--restart always \
-e <ENV_VAR>=$<ENV_VAR> \
aetos/<app>:$IMAGE_TAG
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: build-and-deploy-site
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: build-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Building Docker image for aetos/<site>..."
- docker build -t aetos/<site>:latest .
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- docker tag aetos/<site>:latest $REGISTRY_HOST/aetos/<site>:latest
- echo "Pushing image to $REGISTRY_HOST..."
- docker push $REGISTRY_HOST/aetos/<site>:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Stopping old container..."
- docker rm -f <site> || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Starting aetos/<site>:latest ..."
- |
docker run -d \
--name <site> \
-p <host-port>:<container-port> \
--restart always \
aetos/<site>:latest
trigger:
event:
- push

View File

@@ -0,0 +1,102 @@
# Drone Pipelines
## Summary
Every Aetos repository ships a Drone CI definition at `.drone.yml`. There is
exactly **one standard per repository**, and there are **two types of file**,
chosen by what the repository produces:
| Type | File | Produces | Repos |
|------|------|----------|-------|
| **A — PyPI package** | `templates/drone/python-lib.yaml` | A wheel + sdist on the private index | `dagpipe`, `doc-forge`, `hexa`, `mail-intake`, `mongo-ops`, `omniread`, `openapi-first`, `py-jwt` |
| **B — Docker deployment** | `templates/drone/service-docker.yaml` | An image pushed to the registry and run as a container | `auth-server`, `docs`, `mcp` |
Both templates run on `platform: linux / arm64` with the workspace at
`/drone/src`. Copy the matching template to `<repo>/.drone.yml`; the Type A file
needs no edits, the Type B file needs its placeholders filled in (below).
> Conventions: `.drone.yml` (not `.yaml`), one file per repo, secrets injected
> with `from_secret` — never commit credentials.
---
## Type A — PyPI package (`python-lib.yaml`)
For internal libraries. The file contains **two pipelines**:
1. **`build-and-publish-pypi`** — triggered on `tag`:
- `check-version` — read `name` / `version` from `pyproject.toml` and query
`$PIP_REPO_URL/simple/<package>/`; if that version already exists, exit
`78` and skip the whole build.
- `quality-gate` — install the package with its `[dev]` extras from the
private index, then `black --check`, `ruff check`, `mypy`, `pydoclint`,
`pytest`.
- `build-package` — `python -m build` into `dist/`.
- `upload-to-private-pypi` — `twine upload` to `$PIP_REPO_URL`.
2. **`backfill-pypi-from-tags`** — triggered on `custom`:
- `fetch-tags` — `git fetch --tags --force`.
- `build-and-upload-missing` — walk every tag in version order, skip the ones
already on the index, `git checkout` the rest, build, and upload. This is
the disaster-recovery / replay path.
Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`.
- **Ship as-is.** Package name and version come from `pyproject.toml`, so the
same file is reused verbatim across every library.
---
## Type B — Docker deployment (`service-docker.yaml`)
For services and sites that ship a container image. The file contains **two
pipelines**, each mirroring a repo family:
1. **`build-and-deploy-service`** — triggered on `tag` (a long-running
API/worker service, e.g. `auth-server`):
- `fetch-tags` — resolve the latest tag into `LATEST_TAG.txt`; fail if there
are no tags.
- `check-remote-image` — if `aetos/<app>:<tag>` already exists locally, exit
`78` and skip the build.
- `quality-gate` — install from `requirements.txt` and run
`black` / `ruff` / `mypy` / `pytest`.
- `build-image` — `docker build` with the private-index build args, tagged
`aetos/<app>:<tag>` and `:latest`.
- `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.
- `stop-old` — `docker rm -f <app>`.
- `run-container` — `docker run -d --name <app> -p <host>:<container>
--restart always` with the service's `-e` environment.
2. **`build-and-deploy-site`** — triggered on `push` (a static/HTTP site, e.g.
`docs`, `mcp`): `build-image` → `push-image` → `stop-old` → `run-container`,
with no tag resolution and no quality gate.
Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD` (service build args),
`REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASS`, plus the service runtime env
(for example `MONGO_*`, `JWT_SECRET`).
Placeholders to fill in per repo:
| Placeholder | Meaning | Example |
|-------------|---------|---------|
| `<app>` | service image / container name | `auth-server` |
| `aetos/<app>` | image repository on the registry | `aetos/auth-server` |
| `<host-port>` / `<container-port>` | published : in-image port | `9003:8000` |
| `<site>` | site image / container name | `docs` |
| `<ENV_VAR>` | runtime environment variable | `MONGO_HOST` |
---
## What not to do
- Do not hand-roll a third shape. A repo is either Type A or Type B, never both.
- Do not inline secrets; always use `from_secret`.
- Do not drop the `check-version` / `check-remote-image` skip — it guards the
index and the running container against duplicate publishes.
- Do not rename the pipelines; tooling and runbooks key off
`build-and-publish-pypi`, `build-and-deploy-service`, and
`build-and-deploy-site`.
---
## Related
- [01 – Build Recipe](01_build_recipe.md) · [09 – Infra Adapters](09_infra_adaptors.md) · [13 – Library Registry](13_library_registry.md)

View File

@@ -14,6 +14,7 @@ patterns, and the internal library catalog that agents consult while coding.
- **Worker & Progress** — queued work, progress reporting, and visibility.
- **Human-in-the-Loop** — guardrails and approvals inside automations.
- **Infra Adapters** — how infrastructure connects without leaking into domain.
- **Drone Pipelines** — the two CI shapes: a PyPI package or a Docker deployment.
- **Genericity Matrix** — what is library-backed, template-provided, or authored.
- **Auth Integration** — consuming the shared auth-server service (spec-driven,
no auth code).

View File

@@ -63,6 +63,7 @@ nav:
- Worker & Progress: 07_worker_and_progress.md
- Human-in-the-Loop: 08_human_in_loop.md
- Infra Adapters: 09_infra_adaptors.md
- Drone Pipelines: 10_drone_pipelines.md
- Genericity Matrix: 12_genericity_matrix.md
- Auth Integration: 14_auth_integration.md
- Library Registry: 13_library_registry.md

View File

@@ -0,0 +1,164 @@
# Drone standard — Type A: PyPI package
#
# Canonical pipeline for internal Python libraries that ship a wheel/sdist to
# the private index (dagpipe, doc-forge, hexa, mail-intake, mongo-ops,
# omniread, openapi-first, py-jwt).
#
# Two pipelines ship together:
# build-and-publish-pypi — tag-triggered build + quality gate + publish.
# backfill-pypi-from-tags — custom-triggered replay of every git tag that is
# missing from the index (disaster recovery).
#
# Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD.
# Copy to <repo>/.drone.yml unchanged; the package name and version are read
# from pyproject.toml, so no per-repo edits are required.
---
kind: pipeline
type: docker
name: build-and-publish-pypi
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: check-version
image: curlimages/curl:latest
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- echo "Checking if $PACKAGE_NAME==$VERSION exists on $PIP_REPO_URL ..."
- |
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" "$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -qE "${PACKAGE_NAME}-${VERSION}(-|\.tar\.gz|\.whl)"; then
echo "$PACKAGE_NAME==$VERSION already exists; skipping build."
exit 78
else
echo "New version detected: $PACKAGE_NAME==$VERSION"
fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip build
- |
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]" "pydoclint>=0.9.1"
- echo "Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy
- pydoclint .
- python -m pytest
- name: build-package
image: python:3.13-slim
commands:
- pip install --upgrade pip build
- echo "Building Python package..."
- python -m build
- ls -l dist
- name: upload-to-private-pypi
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade twine
- echo "Uploading to private PyPI at $PIP_REPO_URL ..."
- |
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: backfill-pypi-from-tags
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: fetch-tags
image: alpine/git
commands:
- git fetch --tags --force
- name: build-and-upload-missing
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- apt-get update
- apt-get install -y git curl ca-certificates
- pip install --upgrade pip build twine
- |
set -e
PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | cut -d'"' -f2)
echo "Package: $PACKAGE_NAME"
for TAG in $(git tag --sort=version:refname); do
VERSION="$TAG"
echo "Version: $VERSION"
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" \
"$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -qE "${PACKAGE_NAME}-${VERSION}(-|\.tar\.gz|\.whl)"; then
echo "Exists, skipping"
continue
fi
git checkout --force "$TAG"
echo "Building $VERSION"
rm -rf dist
python -m build
echo "Uploading $VERSION"
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
done
trigger:
event:
- custom

View File

@@ -0,0 +1,257 @@
# Drone standard — Type B: Docker deployment
#
# Canonical pipelines for services that build an image, push it to the private
# registry, and run it on the Docker host (auth-server, docs, mcp).
#
# Two variants ship together:
# build-and-deploy-service — a long-running API/worker service: fetch the git
# tag, skip if the image already exists, run the
# quality gate, build, push, restart the container.
# build-and-deploy-site — a static/HTTP site: build, push, restart, on
# every push (no tag, no quality gate).
#
# Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD (service only),
# REGISTRY_HOST, REGISTRY_USER, REGISTRY_PASS, plus the service's runtime env
# (for example MONGO_* / JWT_SECRET).
#
# Placeholders to set per repo:
# <app> image / container / network name (e.g. auth-server).
# aetos/<app> image repository on the private registry.
# <host-port> published host port; <container-port> the in-image port.
# <site> site image name for the site variant (e.g. docs, mcp).
# <env-var> runtime environment variable consumed by the service.
---
kind: pipeline
type: docker
name: build-and-deploy-service
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "No git tags found! Cannot continue."
exit 1
fi
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if aetos/<app>:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for aetos/<app>:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^aetos/<app>" || echo "(none)"
- |
if docker image inspect aetos/<app>:$IMAGE_TAG > /dev/null 2>&1; then
echo "Docker image aetos/<app>:$IMAGE_TAG already exists; skipping build"
exit 78
else
echo "Docker image aetos/<app>:$IMAGE_TAG not found; proceeding to build..."
fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip
- pip install --extra-index-url "https://$PIP_USERNAME:$PIP_PASSWORD@$PIP_REPO_URL/simple" -r requirements.txt
- pip install black ruff mypy
- echo "Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy .
- python -m pytest
- name: build-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Building Docker image aetos/<app>:$IMAGE_TAG ..."
- |
docker build \
--network=host \
--build-arg PIP_REPO_URL="$PIP_REPO_URL" \
--build-arg PIP_USERNAME="$PIP_USERNAME" \
--build-arg PIP_PASSWORD="$PIP_PASSWORD" \
-t aetos/<app>:$IMAGE_TAG \
-t aetos/<app>:latest \
/drone/src
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "Tagging images with registry prefix..."
- docker tag aetos/<app>:$IMAGE_TAG $REGISTRY_HOST/aetos/<app>:$IMAGE_TAG
- docker tag aetos/<app>:$IMAGE_TAG $REGISTRY_HOST/aetos/<app>:latest
- echo "Pushing aetos/<app>:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/aetos/<app>:$IMAGE_TAG
- echo "Pushing aetos/<app>:latest ..."
- docker push $REGISTRY_HOST/aetos/<app>:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Stopping old container..."
- docker rm -f <app> || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
<ENV_VAR>:
from_secret: <ENV_VAR>
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Starting container aetos/<app>:$IMAGE_TAG ..."
- |
docker run -d \
--name <app> \
-p <host-port>:<container-port> \
--add-host private-pi:192.168.1.111 \
--restart always \
-e <ENV_VAR>=$<ENV_VAR> \
aetos/<app>:$IMAGE_TAG
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: build-and-deploy-site
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: build-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Building Docker image for aetos/<site>..."
- docker build -t aetos/<site>:latest .
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- docker tag aetos/<site>:latest $REGISTRY_HOST/aetos/<site>:latest
- echo "Pushing image to $REGISTRY_HOST..."
- docker push $REGISTRY_HOST/aetos/<site>:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Stopping old container..."
- docker rm -f <site> || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "Starting aetos/<site>:latest ..."
- |
docker run -d \
--name <site> \
-p <host-port>:<container-port> \
--restart always \
aetos/<site>:latest
trigger:
event:
- push