diff --git a/README.md b/README.md index 2907092..4d7f7d6 100644 --- a/README.md +++ b/README.md @@ -1056,7 +1056,7 @@ lifecycle on top. * **[vintasend-managed-templates](https://github.com/vintasoftware/vintasend-managed-templates/)**: The template management layer itself -- the `BaseTemplateManagerBackend` seam, `ManagedTemplateService` for creating/versioning/publishing templates, and `ManagedTemplateEmailRenderer` / `ManagedTemplateSMSRenderer`, which wrap any existing `BaseNotificationTemplateRenderer` and feed it the stored template body instead of a template path. Storage-agnostic on its own; pair it with a manager backend. * **[vintasend-django-templates-manager](https://github.com/vintasoftware/vintasend-django-templates-manager/)**: Stores managed templates in the database through the Django ORM, with `ManagedTemplate` / `ManagedTemplateStatusRecord` models, an admin, and filtering + pagination over template versions and their status history. -* **[vintasend-templates-management-api](https://github.com/vintasoftware/vintasend-templates-management-api/)**: A django-ninja REST API over a template manager backend, for a UI where non-technical people create, version, publish and preview templates. Tracked here as a submodule under `tools/`. +* **[vintasend-templates-management-api](https://github.com/vintasoftware/vintasend-templates-management-api/)**: A django-ninja REST API over a template manager backend, for a UI where non-technical people create, version, publish and preview templates. Install it from PyPI (`pip install vintasend-templates-management-api`) and mount it in your own Django project, or run it on its own. Tracked here as a submodule under `tools/`. Because the renderer wraps another renderer rather than replacing it, a notification's `body_template` stops being a path and becomes a managed template's key -- everything else about diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 48ecd2e..1f6dde3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,49 @@ # Release Notes +## Unreleased + +### Features + +#### `vintasend-api` and `vintasend-templates-management-api` are on PyPI + +Both APIs are published to PyPI from now on, like every other package in the family, and each +can be mounted in a host's own Django project as well as run on its own. + +* **Install and mount.** `pip install vintasend-api` (or `vintasend-templates-management-api`), + add the app to `INSTALLED_APPS` (`vintasend_api.dashboard` / + `vintasend_templates_management_api.templates_manager`), and `include()` its URLconf + (`vintasend_api.dashboard.urls` / `vintasend_templates_management_api.templates_manager.urls`) + under any prefix. Each serves `health` and `api/v1/` there. Each API has its own URL namespace, + so one project can mount both. +* **`VINTASEND_API_AUTHENTICATOR`**: a callable `(request) -> None`, or its dotted path, that + replaces the shared-key check. It refuses a caller by raising `ApiError.unauthorized(...)` (401) + or `ApiError.forbidden(...)` (403), and it may be `async`. With it set, `VINTASEND_API_KEY` is + not required. The setting has the same name in both APIs, so one function can serve both, and it + may raise either package's `ApiError`: a refusal is recognised by its class name and code, as the + TypeScript packages do. +* **`bearer_token(request_or_header)`** in each app's `auth` module reads an + `Authorization: Bearer` token, or returns `None`, for an authenticator built around a token the + host verifies itself. +* Every setting either app reads now has a default, so a host sets only what it uses. The CORS + middleware and the JSON 404 handler stay optional, and the CORS middleware follows the API's + routes under whatever prefix they are mounted at. +* The bundled project still runs on its own (`DJANGO_SETTINGS_MODULE=vintasend_api.settings` or + `vintasend_templates_management_api.settings`, with gunicorn installed separately). It now reads + `.env` from the working directory rather than from the package's. + +### Release tooling + +* `scripts/lock_subpackages.py` retries a `poetry lock` that cannot see a just-published version, + clearing Poetry's PyPI cache between attempts. In 3.4.0 the whole of wave 2 failed that way two + minutes after `vintasend` went live, because the CDN edge Poetry reached still served the old + index page. + +### Backwards compatibility + +**Nothing changes for a client, or for a deployment of the standalone project.** The HTTP +contract and `openapi.yaml` are unchanged. A deployment that kept its `.env` beside the package +rather than in its working directory needs to move it, or set the variables in the environment. + ## Version 3.4.0 (2026-10-09) One new setting in `vintasend-templates-management-api`. Every other package is released at 3.4.0 diff --git a/ai-tools/AGENTS.md b/ai-tools/AGENTS.md index 90db862..0ecfc61 100644 --- a/ai-tools/AGENTS.md +++ b/ai-tools/AGENTS.md @@ -409,7 +409,7 @@ submodule directory, and one PR per repository. | Tool | Path | Purpose | |---|---|---| -| `vintasend-api` | `tools/vintasend-api` | Django + django-ninja REST API serving the dashboard | +| `vintasend-api` | `tools/vintasend-api` | Django + django-ninja REST API serving the dashboard; on PyPI, embeddable in a host Django project or run on its own | | `vintasend-dashboard` | `tools/vintasend-dashboard` | Next.js UI for browsing, previewing, resending and cancelling notifications | The two are separated by one HTTP contract, `openapi.yaml`, which is the normative document diff --git a/ai-tools/skills/release-package/SKILL.md b/ai-tools/skills/release-package/SKILL.md index 7b45472..7a95770 100644 --- a/ai-tools/skills/release-package/SKILL.md +++ b/ai-tools/skills/release-package/SKILL.md @@ -146,9 +146,14 @@ Follow that precedent rather than unilaterally switching to major bumps. But: half-published. A package already tagged on origin is reported as done, which is what makes the second and third runs safe. - `vintasend-api` and `vintasend-templates-management-api` are applications: they have no - `.github/workflows/publish.yml`, so their tag is the whole release and they never appear on - PyPI. The wave map marks them `(tag-only)`. Don't wait for them there. + Every package publishes to PyPI, `vintasend-api` and `vintasend-templates-management-api` + included since 3.5.0. A package without `.github/workflows/publish.yml` would be released by + its tag alone, and the wave map would mark it `(tag-only)`. + + Poetry can lag PyPI too. In 3.4.0 `poetry lock` failed for all of wave 2 with "doesn't match + any versions" two minutes after `vintasend` was live: the CDN edge Poetry reached still served + the old index page. `lock_subpackages.py` now clears Poetry's PyPI cache and retries that + failure for a few minutes before reporting it. A version counts as live only once PyPI's simple index lists it, not just the JSON API. pip reads the simple index, and in 3.2.0 two Python 3.12 publish jobs failed with "No matching diff --git a/scripts/_packages.py b/scripts/_packages.py index 5238e6f..035fcea 100644 --- a/scripts/_packages.py +++ b/scripts/_packages.py @@ -29,8 +29,8 @@ # skipped in silence. NON_PYTHON_SUBMODULES = frozenset({"tools/vintasend-dashboard"}) -# A package with this workflow uploads to PyPI when it is tagged. One without it is an -# application released as a tag alone -- see `Package.publishes`. +# A package with this workflow uploads to PyPI when it is tagged. One without it is released +# as a tag alone -- see `Package.publishes`. PUBLISH_WORKFLOW = Path(".github/workflows/publish.yml") # Tables whose `name`/`version` keys describe the package itself. The repo uses @@ -89,9 +89,9 @@ def rel(self) -> str: def publishes(self) -> bool: """Whether tagging this package uploads it to PyPI, or the tag is the whole release. - `vintasend-api` and `vintasend-templates-management-api` are applications: they have a - CI workflow but no publish workflow, so PyPI never hears of them. A script waiting for - one of them to appear there would wait until its timeout. + A package with a CI workflow but no publish workflow never reaches PyPI, so a script + waiting for it there would wait until its timeout. Every package in the family publishes + today; `vintasend-api` and `vintasend-templates-management-api` were tag-only until 3.5.0. """ return (self.dir / PUBLISH_WORKFLOW).is_file() diff --git a/scripts/lock_subpackages.py b/scripts/lock_subpackages.py index b2d0788..b05b50e 100755 --- a/scripts/lock_subpackages.py +++ b/scripts/lock_subpackages.py @@ -37,6 +37,7 @@ import argparse import subprocess import sys +import time from _git import ( changed_paths, @@ -182,26 +183,61 @@ def poetry_available() -> bool: return result.returncode == 0 +# What Poetry says when the index it read does not list a version yet. +NOT_YET_LISTED = "doesn't match any versions" + +# How often, and how far apart, a lock that cannot see a just-published version is retried. +LOCK_RETRIES = 6 +LOCK_RETRY_DELAY = 60 + + +def run_poetry_lock(pkg: Package) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + ["poetry", "lock"], # noqa: S607 -- poetry off PATH is the normal invocation + cwd=pkg.dir, + capture_output=True, + text=True, + check=False, + timeout=LOCK_TIMEOUT, + ) + + +def clear_poetry_pypi_cache() -> None: + """Drop Poetry's cached PyPI pages, so the next lock reads the index again.""" + subprocess.run( # noqa: S603 + ["poetry", "cache", "clear", "PyPI", "--all", "-n"], # noqa: S607 + capture_output=True, + check=False, + ) + + def relock(pkg: Package) -> tuple[bool, str]: - """Run `poetry lock` in the package directory.""" - try: - result = subprocess.run( # noqa: S603 - ["poetry", "lock"], # noqa: S607 -- poetry off PATH is the normal invocation - cwd=pkg.dir, - capture_output=True, - text=True, - check=False, - timeout=LOCK_TIMEOUT, - ) - except subprocess.TimeoutExpired: - return False, f"poetry lock did not finish within {LOCK_TIMEOUT}s" + """Run `poetry lock` in the package directory. + + A version can be live on PyPI and still missing from the index page Poetry reads: PyPI's + CDN serves a cached page for a few minutes after an upload, and a different edge than the + one the publish wait asked. In 3.4.0 every package in wave 2 failed with "doesn't match any + versions" two minutes after `vintasend` went live. So that one complaint clears Poetry's + cache and retries, a minute apart; any other failure is reported at once. + """ + for attempt in range(1, LOCK_RETRIES + 1): + try: + result = run_poetry_lock(pkg) + except subprocess.TimeoutExpired: + return False, f"poetry lock did not finish within {LOCK_TIMEOUT}s" + + if result.returncode == 0: + return True, "" - if result.returncode != 0: output = ((result.stdout or "") + (result.stderr or "")).strip() - # The tail carries the resolver's actual complaint; the head is progress. - tail = "\n".join(output.splitlines()[-8:]) - return False, f"poetry lock failed:\n {tail}" - return True, "" + if NOT_YET_LISTED not in output or attempt == LOCK_RETRIES: + # The tail carries the resolver's actual complaint; the head is progress. + tail = "\n".join(output.splitlines()[-8:]) + return False, f"poetry lock failed:\n {tail}" + + clear_poetry_pypi_cache() + time.sleep(LOCK_RETRY_DELAY) + raise AssertionError("unreachable") # pragma: no cover def release_package(pkg: Package, version: str, args: argparse.Namespace) -> tuple[str, str]: diff --git a/scripts/release_all.py b/scripts/release_all.py index bea91ba..6c257c3 100755 --- a/scripts/release_all.py +++ b/scripts/release_all.py @@ -10,9 +10,9 @@ 5. wait until every package in the wave is on PyPI 6. back to 3 for the next wave, until nothing is left -A package with no `.github/workflows/publish.yml` -- `vintasend-api` and -`vintasend-templates-management-api`, which are applications -- is released by its -tag alone. It is done once the tag is on origin, and nothing waits for it on PyPI. +A package with no `.github/workflows/publish.yml` is released by its tag alone: it is done +once the tag is on origin, and nothing waits for it on PyPI. Every package publishes today; +`vintasend-api` and `vintasend-templates-management-api` were tag-only until 3.5.0. The waiting is what makes this a script rather than a list. A subpackage pins `vintasend` at the version being released, so `poetry lock` cannot resolve until