diff --git a/build.py b/build.py index 3e678b2..ca16885 100644 --- a/build.py +++ b/build.py @@ -239,6 +239,8 @@ _TEMPLATE_COMPONENTS: list[dict[str, str]] = [ "description": "OpenAPI contract skeleton."}, {"kind": "pyproject", "path": "pyproject.toml", "placeholder": "", "description": "Packaging + dependency pins."}, + {"kind": "changelog", "path": "CHANGELOG.md", "placeholder": "", + "description": "Keep a Changelog file with an [Unreleased] section (versioning standard)."}, {"kind": "dockerfile", "path": "Dockerfile", "placeholder": "", "description": "API + worker image with private-index wiring."}, {"kind": "dockerignore", "path": ".dockerignore", "placeholder": "", @@ -412,6 +414,82 @@ def build_drone_templates(folder: Path, dry_run: bool = False) -> None: f"({len(manifest['components'])} components)") +# ── Release standard bundle ─────────────────────────────────────────────────── + +# The release artefacts shipped in ``src/templates/release``. One canonical +# changelog (Keep a Changelog + SemVer) and the service version file. See +# standards "11_versioning_and_changelog". +_RELEASE_COMPONENTS: list[dict[str, str]] = [ + {"kind": "changelog", "path": "CHANGELOG.md", "placeholder": "", + "description": "Keep a Changelog skeleton: one [Unreleased] section + compare links."}, + {"kind": "version-file", "path": "VERSION", "placeholder": "", + "description": "Single-line SemVer source of truth for services and sites."}, +] + + +def build_release_manifest(template_root: Path) -> dict[str, object]: + """Describe every artefact in the vendored release standard. + + Args: + template_root (Path): The vendored ``templates/release`` directory. + + Returns: + dict[str, object]: Manifest with the component entries and the + tag == version invariant. + """ + components: list[dict[str, str]] = [] + for entry in _RELEASE_COMPONENTS: + rel = entry["path"] + if not (template_root / rel).exists(): + raise SystemExit(f"[ERR] release template missing: {rel}") + components.append({ + "kind": entry["kind"], + "path": rel, + "placeholder": entry["placeholder"], + "description": entry["description"], + }) + return { + "project": "standards", + "template": "templates/release", + "invariant": "git tag == version file (enforced by the Drone guard)", + "components": components, + } + + +def build_release_templates(folder: Path, dry_run: bool = False) -> None: + """Vendor ``folder/src/templates/release`` into ``/mcp/templates/release``. + + Ships the canonical ``CHANGELOG.md`` and ``VERSION`` in the MCP bundle so an + agent can drop them into a repo, and emits ``release.json`` describing them. + + Args: + folder (Path): The hub folder owning ``src/templates/release``. + dry_run (bool): Print the plan without executing. + """ + name = folder.name + src = folder / "src" / "templates" / "release" + if not src.is_dir(): + return + out_dir = folder / "mcp" / "templates" / "release" + if dry_run: + print(f"[REL] {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_release_manifest(out_dir) + (folder / "mcp" / "release.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(f"[REL] {name} release templates vendored to {out_dir.relative_to(ROOT)}/ " + f"({len(manifest['components'])} components)") + + # ── CLI ────────────────────────────────────────────────────────────────────── @@ -432,6 +510,7 @@ def main() -> None: 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_release_templates(folder, dry_run=args.dry_run) build_html(folder, dry_run=args.dry_run) diff --git a/registry.py b/registry.py index 520abc7..6d6dae9 100644 --- a/registry.py +++ b/registry.py @@ -11,6 +11,7 @@ Resources: registry://libraries (the structured catalog) registry://components (the template component index) registry://drone (the CI template index) + registry://release (the release template index) Tools: ping (from the base server) @@ -20,6 +21,8 @@ Tools: 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) + list_release_templates (the changelog + version-file artefacts) + get_release_template (kind -> exact release template file) Prompts: intake (the questions to gather before building) @@ -136,6 +139,32 @@ class StandardsMCPServer(MCPServer): return {"error": "not_found", "path": str(path), "components": []} return json.loads(path.read_bytes()) + def _release(self) -> dict[str, Any]: + """Load the release (versioning + changelog) template manifest.""" + path = Path(self.mcp_root) / "release.json" + if not path.exists(): + return {"error": "not_found", "path": str(path), "components": []} + return json.loads(path.read_bytes()) + + def _find_release_component(self, kind: str) -> dict[str, Any] | None: + for entry in self._release().get("components", []): + if entry.get("kind") == kind: + return entry + return None + + def _read_release_component(self, entry: dict[str, Any]) -> dict[str, Any]: + """Read a vendored release standard file.""" + rel = entry["path"] + path = Path(self.mcp_root) / "templates" / "release" / 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_drone_component(self, kind: str) -> dict[str, Any] | None: for entry in self._drone().get("components", []): if entry.get("kind") == kind: @@ -228,6 +257,10 @@ class StandardsMCPServer(MCPServer): def drone() -> dict[str, Any]: return self._drone() + @self.app.resource("registry://release") + def release() -> dict[str, Any]: + return self._release() + @self.app.resource("docs://spec") def spec() -> dict[str, Any]: """Return the pinned auth-server OpenAPI contract. @@ -380,6 +413,45 @@ class StandardsMCPServer(MCPServer): } return self._read_drone_component(entry) + @self.app.tool() + def list_release_templates() -> list[dict[str, Any]]: + """List the release/versioning template artefacts. + + These are the files every repo carries for releases: a Keep a + Changelog ``CHANGELOG.md`` and, for services, a root ``VERSION`` + file. See standards "11_versioning_and_changelog". + + Returns: + list[dict[str, Any]]: + Entries with ``kind``, ``path``, ``placeholder`` and + ``description``. Fetch one with :func:`get_release_template`. + """ + return self._release().get("components", []) + + @self.app.tool() + def get_release_template(kind: str) -> dict[str, Any]: + """Return the exact release standard file for a component kind. + + Use this to drop the canonical changelog / version file into a repo + instead of inventing one. See :func:`list_release_templates` for + valid kinds ("changelog", "version-file"). + + Args: + kind (str): Component kind, e.g. "changelog" or "version-file". + + Returns: + dict[str, Any]: ``{kind, path, placeholder, content}`` or an + error entry when the kind is unknown. + """ + entry = self._find_release_component(kind) + if entry is None: + return { + "error": "unknown_kind", + "kind": kind, + "available": [c["kind"] for c in self._release().get("components", [])], + } + return self._read_release_component(entry) + @self.app.prompt() def intake() -> str: """Return the questions to gather before building an app. diff --git a/standards/mcp/components.json b/standards/mcp/components.json index 39ae3da..f272c5c 100644 --- a/standards/mcp/components.json +++ b/standards/mcp/components.json @@ -186,6 +186,12 @@ "placeholder": "", "description": "Packaging + dependency pins." }, + { + "kind": "changelog", + "path": "CHANGELOG.md", + "placeholder": "", + "description": "Keep a Changelog file with an [Unreleased] section (versioning standard)." + }, { "kind": "dockerfile", "path": "Dockerfile", diff --git a/standards/mcp/index.json b/standards/mcp/index.json index 0ec1ff3..5971c70 100644 --- a/standards/mcp/index.json +++ b/standards/mcp/index.json @@ -1,6 +1,6 @@ { "project": "standards", "type": "standards", - "modules_count": 14, + "modules_count": 15, "source": "hand-authored" } \ No newline at end of file diff --git a/standards/mcp/modules/drone_pipelines.json b/standards/mcp/modules/drone_pipelines.json index acd5788..ba6e16c 100644 --- a/standards/mcp/modules/drone_pipelines.json +++ b/standards/mcp/modules/drone_pipelines.json @@ -2,7 +2,7 @@ "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 `/.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//`; 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/:` 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/:` and `:latest`.\n - `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.\n - `stop-old` — `docker rm -f `.\n - `run-container` — `docker run -d --name -p :\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| `` | service image / container name | `auth-server` |\n| `aetos/` | image repository on the registry | `aetos/auth-server` |\n| `` / `` | published : in-image port | `9003:8000` |\n| `` | site image / container name | `docs` |\n| `` | 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", + "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 `/.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` — **guard**: fail unless `$DRONE_TAG` equals the version in\n `pyproject.toml`; then read `name` / `version` and query\n `$PIP_REPO_URL/simple//`; if that version already exists, exit\n `78` and skip the whole build (see [11 – Versioning & Changelog](11_versioning_and_changelog.md)).\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, verify `tag == pyproject\n version` at that commit, build, and upload. This is the disaster-recovery /\n 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-version` — **guard**: fail unless `$DRONE_TAG` equals the repo-root\n `VERSION` file (see [11 – Versioning & Changelog](11_versioning_and_changelog.md)).\n - `check-remote-image` — if `aetos/:` 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/:` and `:latest`.\n - `push-image` — login, re-tag with `$REGISTRY_HOST`, push both tags.\n - `stop-old` — `docker rm -f `.\n - `run-container` — `docker run -d --name -p :\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, no quality gate, and no version guard (it ships\n `:latest` only).\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| `` | service image / container name | `auth-server` |\n| `aetos/` | image repository on the registry | `aetos/auth-server` |\n| `` / `` | published : in-image port | `9003:8000` |\n| `` | site image / container name | `docs` |\n| `` | 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 drop the tag↔version guard. A package needs its `pyproject.toml`\n version to equal the tag; a service needs a root `VERSION` file that equals\n the tag. See [11 – Versioning & Changelog](11_versioning_and_changelog.md).\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) · [11 – Versioning & Changelog](11_versioning_and_changelog.md) · [13 – Library Registry](13_library_registry.md)\n", "objects": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/index.json b/standards/mcp/modules/index.json index 06d1e45..4e1b85d 100644 --- a/standards/mcp/modules/index.json +++ b/standards/mcp/modules/index.json @@ -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- **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", + "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- **Versioning & Changelog** — SemVer, the tag↔version contract, and the release\n changelog.\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": {} } } \ No newline at end of file diff --git a/standards/mcp/modules/versioning_and_changelog.json b/standards/mcp/modules/versioning_and_changelog.json new file mode 100644 index 0000000..8541d00 --- /dev/null +++ b/standards/mcp/modules/versioning_and_changelog.json @@ -0,0 +1,8 @@ +{ + "module": "versioning_and_changelog", + "content": { + "path": "versioning_and_changelog", + "docstring": "# Versioning & Changelog\n\n## Summary\n\nEvery Aetos artifact — library, service, or site — is versioned with\n[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and documents its\nreleases in a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) file.\nThe **version file is the source of truth**, the **git tag must equal it**, and\na Drone guard enforces that equality before anything is published. Releases are\ncut manually; a tag is what triggers the pipeline.\n\nThis page pairs with [10 – Drone Pipelines](10_drone_pipelines.md): the version\nand tag contract is exactly what those two CI shapes consume.\n\n---\n\n## The version contract\n\n| Artifact | Source of truth | Notes |\n|----------|-----------------|-------|\n| Library (PyPI package) | `version` in `pyproject.toml` | `check-version` also reads it to skip duplicates |\n| Service / site (Docker) | `VERSION` file at the repo root | one line, `x.y.z` |\n\nRules:\n\n1. **One version per repository.** No per-module versions.\n2. **Tag = version.** The git tag is bare SemVer (`0.0.6`), no `v` prefix, and\n it MUST match the version file byte-for-byte. The Drone guard fails the\n build otherwise — this is what stops a tag from silently publishing nothing.\n3. **A release is a tag.** Bump the version file, promote the changelog, commit,\n tag, push. Everything else is automated by Drone.\n4. **Never reuse a tag.** If a release is wrong, cut a new patch version.\n\n### Pre-1.0 (0.y.z)\n\nWhile a package is `0.y.z`, **`0.y` is treated as the major line**:\n\n- breaking change → `0.(y+1).0`\n- new feature → `0.y.(z+1)`\n- bug fix → `0.y.(z+1)`\n\nOnce a package reaches `1.0.0`, normal SemVer applies. Do not ship `1.0.0`\nuntil the public API is considered stable.\n\n---\n\n## The changelog\n\n`CHANGELOG.md` at the repo root, in Keep a Changelog form. It always has exactly\none open `[Unreleased]` section with the change categories that apply:\n\n```markdown\n## [Unreleased]\n\n### Added\n### Changed\n### Deprecated\n### Removed\n### Fixed\n### Security\n```\n\nOn release you **promote** it — never delete history:\n\n```markdown\n## [0.0.6] - 2026-03-07\n\n### Added\n- ...\n```\n\nand add a link reference at the bottom:\n\n```markdown\n[Unreleased]: https://git.aetoskia.com/aetos//compare/0.0.6...HEAD\n[0.0.6]: https://git.aetoskia.com/aetos//compare/0.0.5...0.0.6\n```\n\nConventions:\n\n- Write entries for humans: one line each, imperative, referencing the user\n impact, not the commit hash.\n- Categories: **Added**, **Changed**, **Deprecated**, **Removed**, **Fixed**,\n **Security**. Omit empty categories.\n- Security fixes get their own entry even if the change is otherwise a bug fix.\n- The `[Unreleased]` heading is never renamed; copy its content into the new\n version heading and clear it.\n\n---\n\n## Release checklist\n\n1. Confirm the quality gate is green on `main`.\n2. Move every `[Unreleased]` entry under the new version heading with today's\n date (`## [x.y.z] - YYYY-MM-DD`); restore an empty `[Unreleased]` on top.\n3. Add the two compare-link references at the bottom of the changelog.\n4. Set the version file: `pyproject.toml` (`version = \"x.y.z\"`) or `VERSION`.\n5. Commit: `chore(release): x.y.z`.\n6. Tag: `git tag x.y.z` and push the tag (`git push origin x.y.z`).\n7. Watch Drone: Type A publishes the wheel/sdist, Type B builds and deploys the\n image. Both first assert `tag == version`.\n8. Do not reuse the tag if the run fails — fix and cut `x.y.(z+1)`.\n\n---\n\n## Why the guard matters\n\nThe version file and the tag are two facts that must agree; nothing in git\nenforces that by itself. A mismatch is not a theoretical risk — a package can be\ntagged `0.0.2` while its `pyproject.toml` still says `0.0.1`, and the publish\nstep (which compares the **file** against the index) then skips the build\nentirely, so `0.0.2` never reaches the index while looking like a release. The\nDrone guard turns that silent no-op into a hard failure.\n\n---\n\n## Related\n\n- [10 – Drone Pipelines](10_drone_pipelines.md) · [01 – Build Recipe](01_build_recipe.md) · [13 – Library Registry](13_library_registry.md)\n", + "objects": {} + } +} \ No newline at end of file diff --git a/standards/mcp/nav.json b/standards/mcp/nav.json index ceb5833..b0cbab2 100644 --- a/standards/mcp/nav.json +++ b/standards/mcp/nav.json @@ -49,6 +49,11 @@ "resource": "docs://modules/drone_pipelines", "title": "Drone Pipelines" }, + { + "module": "versioning_and_changelog", + "resource": "docs://modules/versioning_and_changelog", + "title": "Versioning & Changelog" + }, { "module": "genericity_matrix", "resource": "docs://modules/genericity_matrix", diff --git a/standards/mcp/release.json b/standards/mcp/release.json new file mode 100644 index 0000000..2ac4ec8 --- /dev/null +++ b/standards/mcp/release.json @@ -0,0 +1,19 @@ +{ + "project": "standards", + "template": "templates/release", + "invariant": "git tag == version file (enforced by the Drone guard)", + "components": [ + { + "kind": "changelog", + "path": "CHANGELOG.md", + "placeholder": "", + "description": "Keep a Changelog skeleton: one [Unreleased] section + compare links." + }, + { + "kind": "version-file", + "path": "VERSION", + "placeholder": "", + "description": "Single-line SemVer source of truth for services and sites." + } + ] +} \ No newline at end of file diff --git a/standards/mcp/templates/app/CHANGELOG.md b/standards/mcp/templates/app/CHANGELOG.md new file mode 100644 index 0000000..c9f0cbd --- /dev/null +++ b/standards/mcp/templates/app/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +## [0.1.0] - YYYY-MM-DD + +### Added +- Initial blueprint app scaffolded from the Aetos standard. + +[Unreleased]: /compare/0.1.0...HEAD +[0.1.0]: /releases/tag/0.1.0 diff --git a/standards/mcp/templates/drone/python-lib.yaml b/standards/mcp/templates/drone/python-lib.yaml index a0ee7ee..122d0f6 100644 --- a/standards/mcp/templates/drone/python-lib.yaml +++ b/standards/mcp/templates/drone/python-lib.yaml @@ -12,6 +12,10 @@ # Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD. # Copy to /.drone.yml unchanged; the package name and version are read # from pyproject.toml, so no per-repo edits are required. +# +# Version guard: the tag build fails unless the git tag equals the version in +# pyproject.toml (see the versioning standard). This stops a tag from looking +# like a release while silently publishing nothing. --- kind: pipeline type: docker @@ -37,6 +41,11 @@ steps: 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) + - | + if [ "$DRONE_TAG" != "$VERSION" ]; then + echo "Tag '$DRONE_TAG' does not match pyproject version '$VERSION'; refusing to publish." + exit 1 + fi - 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 @@ -147,6 +156,12 @@ steps: git checkout --force "$TAG" + TAG_VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2) + if [ "$TAG" != "$TAG_VERSION" ]; then + echo "Tag $TAG != pyproject version $TAG_VERSION at that commit; skipping" + continue + fi + echo "Building $VERSION" rm -rf dist python -m build diff --git a/standards/mcp/templates/drone/service-docker.yaml b/standards/mcp/templates/drone/service-docker.yaml index 4ea17d1..9b7fc6c 100644 --- a/standards/mcp/templates/drone/service-docker.yaml +++ b/standards/mcp/templates/drone/service-docker.yaml @@ -20,6 +20,10 @@ # published host port; the in-image port. # site image name for the site variant (e.g. docs, mcp). # runtime environment variable consumed by the service. +# +# Version guard: the tag-triggered service variant fails unless the git tag +# equals the repo-root VERSION file (see the versioning standard). The +# push-triggered site variant ships :latest only and has no version to guard. --- kind: pipeline type: docker @@ -64,6 +68,22 @@ steps: exit 1 fi + - name: check-version + image: alpine:3.20 + commands: + - VERSION=$(cat VERSION 2>/dev/null | tr -d '\n') + - echo "tag=$DRONE_TAG version=$VERSION" + - | + if [ -z "$VERSION" ]; then + echo "VERSION file missing or empty; add it (see versioning standard)." + exit 1 + fi + - | + if [ "$DRONE_TAG" != "$VERSION" ]; then + echo "Tag '$DRONE_TAG' does not match VERSION '$VERSION'; refusing to deploy." + exit 1 + fi + - name: check-remote-image image: docker:24 volumes: diff --git a/standards/mcp/templates/release/CHANGELOG.md b/standards/mcp/templates/release/CHANGELOG.md new file mode 100644 index 0000000..b778577 --- /dev/null +++ b/standards/mcp/templates/release/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + + + +[Unreleased]: /compare/0.1.0...HEAD +[0.1.0]: /releases/tag/0.1.0 diff --git a/standards/mcp/templates/release/VERSION b/standards/mcp/templates/release/VERSION new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/standards/mcp/templates/release/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/standards/src/lib/10_drone_pipelines.md b/standards/src/lib/10_drone_pipelines.md index ca0e4aa..ba559ab 100644 --- a/standards/src/lib/10_drone_pipelines.md +++ b/standards/src/lib/10_drone_pipelines.md @@ -25,9 +25,10 @@ needs no edits, the Type B file needs its placeholders filled in (below). 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 + - `check-version` — **guard**: fail unless `$DRONE_TAG` equals the version in + `pyproject.toml`; then read `name` / `version` and query `$PIP_REPO_URL/simple//`; if that version already exists, exit - `78` and skip the whole build. + `78` and skip the whole build (see [11 – Versioning & Changelog](11_versioning_and_changelog.md)). - `quality-gate` — install the package with its `[dev]` extras from the private index, then `black --check`, `ruff check`, `mypy`, `pydoclint`, `pytest`. @@ -36,8 +37,9 @@ For internal libraries. The file contains **two pipelines**: 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. + already on the index, `git checkout` the rest, verify `tag == pyproject + version` at that commit, build, and upload. This is the disaster-recovery / + replay path. Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD`. @@ -55,6 +57,8 @@ pipelines**, each mirroring a repo family: API/worker service, e.g. `auth-server`): - `fetch-tags` — resolve the latest tag into `LATEST_TAG.txt`; fail if there are no tags. + - `check-version` — **guard**: fail unless `$DRONE_TAG` equals the repo-root + `VERSION` file (see [11 – Versioning & Changelog](11_versioning_and_changelog.md)). - `check-remote-image` — if `aetos/:` already exists locally, exit `78` and skip the build. - `quality-gate` — install from `requirements.txt` and run @@ -67,7 +71,8 @@ pipelines**, each mirroring a repo family: --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. + with no tag resolution, no quality gate, and no version guard (it ships + `:latest` only). Secrets: `PIP_REPO_URL`, `PIP_USERNAME`, `PIP_PASSWORD` (service build args), `REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASS`, plus the service runtime env @@ -91,6 +96,9 @@ Placeholders to fill in per repo: - 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 drop the tag↔version guard. A package needs its `pyproject.toml` + version to equal the tag; a service needs a root `VERSION` file that equals + the tag. See [11 – Versioning & Changelog](11_versioning_and_changelog.md). - Do not rename the pipelines; tooling and runbooks key off `build-and-publish-pypi`, `build-and-deploy-service`, and `build-and-deploy-site`. @@ -99,4 +107,4 @@ Placeholders to fill in per repo: ## Related -- [01 – Build Recipe](01_build_recipe.md) · [09 – Infra Adapters](09_infra_adaptors.md) · [13 – Library Registry](13_library_registry.md) +- [01 – Build Recipe](01_build_recipe.md) · [09 – Infra Adapters](09_infra_adaptors.md) · [11 – Versioning & Changelog](11_versioning_and_changelog.md) · [13 – Library Registry](13_library_registry.md) diff --git a/standards/src/lib/11_versioning_and_changelog.md b/standards/src/lib/11_versioning_and_changelog.md new file mode 100644 index 0000000..fb36204 --- /dev/null +++ b/standards/src/lib/11_versioning_and_changelog.md @@ -0,0 +1,119 @@ +# Versioning & Changelog + +## Summary + +Every Aetos artifact — library, service, or site — is versioned with +[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and documents its +releases in a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) file. +The **version file is the source of truth**, the **git tag must equal it**, and +a Drone guard enforces that equality before anything is published. Releases are +cut manually; a tag is what triggers the pipeline. + +This page pairs with [10 – Drone Pipelines](10_drone_pipelines.md): the version +and tag contract is exactly what those two CI shapes consume. + +--- + +## The version contract + +| Artifact | Source of truth | Notes | +|----------|-----------------|-------| +| Library (PyPI package) | `version` in `pyproject.toml` | `check-version` also reads it to skip duplicates | +| Service / site (Docker) | `VERSION` file at the repo root | one line, `x.y.z` | + +Rules: + +1. **One version per repository.** No per-module versions. +2. **Tag = version.** The git tag is bare SemVer (`0.0.6`), no `v` prefix, and + it MUST match the version file byte-for-byte. The Drone guard fails the + build otherwise — this is what stops a tag from silently publishing nothing. +3. **A release is a tag.** Bump the version file, promote the changelog, commit, + tag, push. Everything else is automated by Drone. +4. **Never reuse a tag.** If a release is wrong, cut a new patch version. + +### Pre-1.0 (0.y.z) + +While a package is `0.y.z`, **`0.y` is treated as the major line**: + +- breaking change → `0.(y+1).0` +- new feature → `0.y.(z+1)` +- bug fix → `0.y.(z+1)` + +Once a package reaches `1.0.0`, normal SemVer applies. Do not ship `1.0.0` +until the public API is considered stable. + +--- + +## The changelog + +`CHANGELOG.md` at the repo root, in Keep a Changelog form. It always has exactly +one open `[Unreleased]` section with the change categories that apply: + +```markdown +## [Unreleased] + +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security +``` + +On release you **promote** it — never delete history: + +```markdown +## [0.0.6] - 2026-03-07 + +### Added +- ... +``` + +and add a link reference at the bottom: + +```markdown +[Unreleased]: https://git.aetoskia.com/aetos//compare/0.0.6...HEAD +[0.0.6]: https://git.aetoskia.com/aetos//compare/0.0.5...0.0.6 +``` + +Conventions: + +- Write entries for humans: one line each, imperative, referencing the user + impact, not the commit hash. +- Categories: **Added**, **Changed**, **Deprecated**, **Removed**, **Fixed**, + **Security**. Omit empty categories. +- Security fixes get their own entry even if the change is otherwise a bug fix. +- The `[Unreleased]` heading is never renamed; copy its content into the new + version heading and clear it. + +--- + +## Release checklist + +1. Confirm the quality gate is green on `main`. +2. Move every `[Unreleased]` entry under the new version heading with today's + date (`## [x.y.z] - YYYY-MM-DD`); restore an empty `[Unreleased]` on top. +3. Add the two compare-link references at the bottom of the changelog. +4. Set the version file: `pyproject.toml` (`version = "x.y.z"`) or `VERSION`. +5. Commit: `chore(release): x.y.z`. +6. Tag: `git tag x.y.z` and push the tag (`git push origin x.y.z`). +7. Watch Drone: Type A publishes the wheel/sdist, Type B builds and deploys the + image. Both first assert `tag == version`. +8. Do not reuse the tag if the run fails — fix and cut `x.y.(z+1)`. + +--- + +## Why the guard matters + +The version file and the tag are two facts that must agree; nothing in git +enforces that by itself. A mismatch is not a theoretical risk — a package can be +tagged `0.0.2` while its `pyproject.toml` still says `0.0.1`, and the publish +step (which compares the **file** against the index) then skips the build +entirely, so `0.0.2` never reaches the index while looking like a release. The +Drone guard turns that silent no-op into a hard failure. + +--- + +## Related + +- [10 – Drone Pipelines](10_drone_pipelines.md) · [01 – Build Recipe](01_build_recipe.md) · [13 – Library Registry](13_library_registry.md) diff --git a/standards/src/lib/index.md b/standards/src/lib/index.md index d8f1141..9817442 100644 --- a/standards/src/lib/index.md +++ b/standards/src/lib/index.md @@ -15,6 +15,8 @@ patterns, and the internal library catalog that agents consult while coding. - **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. +- **Versioning & Changelog** — SemVer, the tag↔version contract, and the release + changelog. - **Genericity Matrix** — what is library-backed, template-provided, or authored. - **Auth Integration** — consuming the shared auth-server service (spec-driven, no auth code). diff --git a/standards/src/mkdocs.lib.yml b/standards/src/mkdocs.lib.yml index a99036d..0ab9c73 100644 --- a/standards/src/mkdocs.lib.yml +++ b/standards/src/mkdocs.lib.yml @@ -64,6 +64,7 @@ nav: - Human-in-the-Loop: 08_human_in_loop.md - Infra Adapters: 09_infra_adaptors.md - Drone Pipelines: 10_drone_pipelines.md + - Versioning & Changelog: 11_versioning_and_changelog.md - Genericity Matrix: 12_genericity_matrix.md - Auth Integration: 14_auth_integration.md - Library Registry: 13_library_registry.md \ No newline at end of file diff --git a/standards/src/mkdocs.wiki.yml b/standards/src/mkdocs.wiki.yml index 1c9d338..7ccab5e 100644 --- a/standards/src/mkdocs.wiki.yml +++ b/standards/src/mkdocs.wiki.yml @@ -67,4 +67,5 @@ nav: - Overview: 01_overview.md - App Components: 02_components.md - Start an App: 03_start_an_app.md - - Testing & Quality: 04_development/01_testing.md \ No newline at end of file + - Testing & Quality: 04_development/01_testing.md + - Releasing & Versioning: 04_development/02_releasing.md \ No newline at end of file diff --git a/standards/src/templates/app/CHANGELOG.md b/standards/src/templates/app/CHANGELOG.md new file mode 100644 index 0000000..c9f0cbd --- /dev/null +++ b/standards/src/templates/app/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +## [0.1.0] - YYYY-MM-DD + +### Added +- Initial blueprint app scaffolded from the Aetos standard. + +[Unreleased]: /compare/0.1.0...HEAD +[0.1.0]: /releases/tag/0.1.0 diff --git a/standards/src/templates/drone/python-lib.yaml b/standards/src/templates/drone/python-lib.yaml index a0ee7ee..122d0f6 100644 --- a/standards/src/templates/drone/python-lib.yaml +++ b/standards/src/templates/drone/python-lib.yaml @@ -12,6 +12,10 @@ # Secrets (Drone): PIP_REPO_URL, PIP_USERNAME, PIP_PASSWORD. # Copy to /.drone.yml unchanged; the package name and version are read # from pyproject.toml, so no per-repo edits are required. +# +# Version guard: the tag build fails unless the git tag equals the version in +# pyproject.toml (see the versioning standard). This stops a tag from looking +# like a release while silently publishing nothing. --- kind: pipeline type: docker @@ -37,6 +41,11 @@ steps: 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) + - | + if [ "$DRONE_TAG" != "$VERSION" ]; then + echo "Tag '$DRONE_TAG' does not match pyproject version '$VERSION'; refusing to publish." + exit 1 + fi - 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 @@ -147,6 +156,12 @@ steps: git checkout --force "$TAG" + TAG_VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2) + if [ "$TAG" != "$TAG_VERSION" ]; then + echo "Tag $TAG != pyproject version $TAG_VERSION at that commit; skipping" + continue + fi + echo "Building $VERSION" rm -rf dist python -m build diff --git a/standards/src/templates/drone/service-docker.yaml b/standards/src/templates/drone/service-docker.yaml index 4ea17d1..9b7fc6c 100644 --- a/standards/src/templates/drone/service-docker.yaml +++ b/standards/src/templates/drone/service-docker.yaml @@ -20,6 +20,10 @@ # published host port; the in-image port. # site image name for the site variant (e.g. docs, mcp). # runtime environment variable consumed by the service. +# +# Version guard: the tag-triggered service variant fails unless the git tag +# equals the repo-root VERSION file (see the versioning standard). The +# push-triggered site variant ships :latest only and has no version to guard. --- kind: pipeline type: docker @@ -64,6 +68,22 @@ steps: exit 1 fi + - name: check-version + image: alpine:3.20 + commands: + - VERSION=$(cat VERSION 2>/dev/null | tr -d '\n') + - echo "tag=$DRONE_TAG version=$VERSION" + - | + if [ -z "$VERSION" ]; then + echo "VERSION file missing or empty; add it (see versioning standard)." + exit 1 + fi + - | + if [ "$DRONE_TAG" != "$VERSION" ]; then + echo "Tag '$DRONE_TAG' does not match VERSION '$VERSION'; refusing to deploy." + exit 1 + fi + - name: check-remote-image image: docker:24 volumes: diff --git a/standards/src/templates/release/CHANGELOG.md b/standards/src/templates/release/CHANGELOG.md new file mode 100644 index 0000000..b778577 --- /dev/null +++ b/standards/src/templates/release/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + + + +[Unreleased]: /compare/0.1.0...HEAD +[0.1.0]: /releases/tag/0.1.0 diff --git a/standards/src/templates/release/VERSION b/standards/src/templates/release/VERSION new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/standards/src/templates/release/VERSION @@ -0,0 +1 @@ +0.0.0 diff --git a/standards/src/wiki/04_development/02_releasing.md b/standards/src/wiki/04_development/02_releasing.md new file mode 100644 index 0000000..839d4cf --- /dev/null +++ b/standards/src/wiki/04_development/02_releasing.md @@ -0,0 +1,107 @@ +# Releasing & Versioning + +How to cut a release of any Aetos repository. The rule lives in the reference +page [Versioning & Changelog](../lib/11_versioning_and_changelog/); this is the +runbook you follow. + +Every repository is one of two Drone shapes — a **PyPI package** or a **Docker +deployment** ([Drone Pipelines](../lib/10_drone_pipelines/)). Both are +released the same way: bump the version file, promote the changelog, tag. + +--- + +## 🧾 Before you start + +- `main` is green (the Drone quality gate passed on the last run). +- You know whether this is a **breaking**, **feature**, or **fix** release. +- The version file exists: + - package → `version` in `pyproject.toml` + - service / site → `VERSION` at the repo root + +--- + +## 🚦 Pick the next version + +The version file and the tag must be identical. For `0.y.z`, `0.y` is the major +line (see the reference page); once at `1.0.0`, normal SemVer applies. + +| Change | `0.y.z` → | `1.0.0+` → | +|--------|-----------|------------| +| Breaking | `0.(y+1).0` | `(x+1).0.0` | +| Feature | `0.y.(z+1)` | `x.(y+1).0` | +| Fix | `0.y.(z+1)` | `x.y.(z+1)` | + +--- + +## ✍️ Promote the changelog + +In `CHANGELOG.md`: + +1. Rename the content under `[Unreleased]` into a new + `## [x.y.z] - YYYY-MM-DD` heading (keep the sections that apply). +2. Leave a fresh, empty `## [Unreleased]` on top. +3. Add the compare links at the bottom: + + ```markdown + [Unreleased]: https://git.aetoskia.com/aetos//compare/x.y.z...HEAD + [x.y.z]: https://git.aetoskia.com/aetos//compare/x.y.(z-1)...x.y.z + ``` + +--- + +## 🔢 Bump the version file + +- **Package:** edit `version = "x.y.z"` in `pyproject.toml`. +- **Service / site:** edit the `VERSION` file. + +Then commit and tag: + +```bash +git add CHANGELOG.md pyproject.toml # or VERSION +git commit -m "chore(release): x.y.z" +git tag x.y.z +git push origin HEAD +git push origin x.y.z +``` + +--- + +## 🤖 What Drone does + +- **Type A (package):** asserts `tag == pyproject version`, then skips if that + version already exists on the index, runs the quality gate, builds, and + uploads the wheel + sdist. +- **Type B (service):** asserts `tag == VERSION`, resolves the tag, skips if the + image already exists, runs the quality gate, builds, pushes, and restarts the + container. +- **Type B (site):** triggered on push, ships `:latest` and has no tag guard. + +If the guard fails, nothing is published — fix the version file, amend the +commit, and move the tag **only** if it was never pushed. + +--- + +## 🛟 Backfill / recovery + +If an older tag never published (a past version-file mismatch, an index +outage): + +- **Package:** run the `backfill-pypi-from-tags` pipeline (custom event). It + checks out every tag, verifies `tag == pyproject version` at that commit, + and uploads the ones missing from the index. Already-fixed history is never + rewritten. +- **Service:** rebuild the image for the missing tag with a fresh commit and a + new patch version; do not move a published tag. + +--- + +## 🔁 If a release is wrong + +- Never reuse or force-move a pushed tag. +- Fix forward: bump to the next patch, promote the changelog, tag again. + +--- + +## Related + +- [Testing & Quality Gates](01_testing.md) · [Versioning & Changelog](../lib/11_versioning_and_changelog/) · [Drone Pipelines](../lib/10_drone_pipelines/)