Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 0 additions & 84 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,87 +50,3 @@ uvx pre-commit==4.6.0 run --all-files --show-diff-on-failure
[the UI compact dashboard](docs/architecture/ui_compact_dashboard.md) for the
intended graph behavior and add or update the relevant layout fixture under
[tests/unit/observability/fixtures/](tests/unit/observability/fixtures/).

## Release Surface Checks

After manually updating `pyproject.toml` and `CHANGELOG.md`, run:

```bash
make release-prepare
make release-check
```

Optional tools such as `pipx`, `npm`, and Homebrew may skip locally when absent,
but static release-surface tests should still pass.

To publish PyPI and npm after the checks pass and registry credentials are
configured:

```bash
make release
```

`make release-prepare` synchronizes generated release metadata from
`pyproject.toml`, refreshes `uv.lock`, builds local PyPI and npm artifacts plus
the offline runtime wheelhouse, writes release manifests, and prepares the
Homebrew formula candidate. It fails if the target version already exists on
PyPI or npm.

The exact Hatchling build-system pin keeps the manual release and its immediately
following GitHub Release rebuild on the same backend version. The manually
dispatched `release-artifacts` command rebuilds the registry artifacts needed for
verification but skips the offline wheelhouse, which stays local to release
preparation and explicit wheelhouse checks.

`make release-check` is state-aware. For unpublished versions it verifies
generated metadata and runs lint, format-check, tests, package checks, and
install smokes. For already completed releases it verifies PyPI, npm, npm
`latest`, Homebrew formula metadata, and the Git tag, then exits successfully
without rerunning pre-publish smokes. It prints a final reminder to verify the
changelog because changelog content is still reviewed manually.

`make release` asks for exact version confirmation, reruns `make release-check`,
publishes PyPI first, publishes npm with the `latest` dist-tag, reconciles npm
`latest`, and creates/pushes the annotated Git tag after both registries verify.
If a release is interrupted after only one registry is updated, fix the issue and
run `make release-pypi` or `make release-npm` to complete the missing side. In
that registry-recovery path, `make release` and the publish targets allow local
worktree and branch state to differ while still verifying generated metadata,
release-manifest artifacts, registry artifacts, and tag conflicts. In
non-TTY npm two-factor flows, use `NPM_PUBLISH_OTP` and `NPM_DIST_TAG_OTP` so
`npm publish` and `npm dist-tag add` each receive a fresh OTP. Homebrew tap
publishing is still a separate maintainer step: copy the prepared formula into
the tap, run audit/test there, and push the tap update.

Immediately after the manual `make release` flow creates and pushes the tag,
dispatch `.github/workflows/release.yml` from `master` and provide that tag as
the required `tag` input, before `master` advances. The workflow rejects any
other dispatch ref. At the start of verification, it checks out the requested
tag, resolves its commit, and requires it to match the master commit that
dispatched the workflow exactly. Historical-tag backfills and new dispatches
after `master` advances are unsupported. It rebuilds from that tagged source
with `scripts/release.py release-artifacts`, verifies PyPI, npm, Homebrew,
manifest, local artifact, and tag state with
`scripts/release.py github-release-plan`, and transfers the verified artifacts
and manifest to the write-authorized job. The publishing job checks out the
exact verified commit, verifies fresh external state before mutating GitHub,
repeats the plan, and supplies the verified predecessor tag explicitly when
GitHub generates release notes. Every plan evaluation re-fetches
`origin/master` and requires the release commit to remain reachable from it. The
publisher then reloads and re-verifies draft assets immediately before
publication. The GitHub Release contains `dist/*`; the npm tarball is an
integrity input, not a GitHub Release asset. It verifies exact asset names,
sizes, and GitHub SHA-256 digests before and after publication, rejects
unexpected draft assets, and never mutates an already-published mismatch. The
release title must match the tag, and generated notes carry a hidden, versioned
automation marker; existing drafts without both are rejected before asset
mutation.
Prereleases can never be GitHub `Latest`. A stable release is `Latest` only when
it is the highest published stable version on PyPI. A repository-wide
non-dropping queue serializes publication workflows around those checks and
mutations. PyPI and npm remain the production publishing sources of truth; the
workflow does not publish production packages.
`.github/workflows/testpypi.yml` is the separate TestPyPI Trusted Publishing
workflow. Maintainers may intentionally dispatch it from any selected ref; it is
not restricted to `master`. It fails if the package version already exists on
TestPyPI.
139 changes: 77 additions & 62 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,65 +68,9 @@ Current CI policy:
- Default branch: `master`.
- The supported platform matrix is defined in
[Supported Platforms](#supported-platforms).
- Production publishing is local-only. Maintainers run `make release` to
publish PyPI, publish npm with the `latest` dist-tag, and create/push the
final Git tag. GitHub Actions does not publish production PyPI or npm
packages and does not need PyPI or npm credentials.
- `.github/workflows/release.yml` provides manually dispatched GitHub Release
automation as the second phase of the same release operation:

1. **Create the production release locally.** The manual `make release` flow
is the production source of truth.
2. **Dispatch GitHub publication from the same commit.** Immediately after
`make release` creates and pushes the final tag, dispatch the workflow from
`master` with the required `tag` input, before `master` advances. The
workflow rejects dispatches outside `refs/heads/master`. It checks out the
requested tag, resolves its commit, and requires it to match the master
commit that dispatched the workflow exactly. Historical-tag backfills and
new dispatches after `master` advances are unsupported.
3. **Verify the tagged source.** The workflow rebuilds the artifacts and
verifies their manifest, registry state, Homebrew formula metadata, and tag
state through `scripts/release.py`. It transfers the verified sdist, wheel,
npm tarball, and manifest to the write-authorized job.
4. **Publish the GitHub Release.** The publisher checks out the exact verified
commit and verifies fresh registry and tag state before mutating GitHub. It
repeats the plan after draft-asset verification immediately before
publication and supplies the verified predecessor tag when GitHub generates
release notes. The GitHub Release itself contains only `dist/*`.

- GitHub Release publication fails closed:

- Every plan evaluation re-fetches `origin/master` and requires the release
commit to remain reachable from it.
- Draft and published assets are checked by exact name, size, and GitHub's
immutable upload-time SHA-256 digest. API failures, truncated responses, and
unexpected assets fail closed.
- Release titles must exactly match their tags, and generated notes carry a
hidden, versioned automation marker. Existing drafts without both are
rejected before any asset mutation.
- New releases are built as drafts, verified, published, and verified again.
Prereleases can never be GitHub `Latest`; a stable release is `Latest` only
when it is the highest published stable version on PyPI.
- A repository-wide non-dropping concurrency queue serializes publication
workflows around those checks and mutations. PyPI and npm remain the
production publishing sources of truth.

- Release artifact builds are reproducible:

- `pyproject.toml` uses an exact Hatchling build-system pin so the manual
release and its immediately following GitHub Release rebuild use the same
backend version.
- The manually dispatched job rebuilds only the registry artifacts needed for
verification. `make release-prepare` owns the offline runtime wheelhouse used
by maintainer checks.
- The release workflow verifies the Homebrew formula metadata in this repo, but
does not update the Homebrew tap; tap publishing remains a separate maintainer
step.
- `.github/workflows/testpypi.yml` remains the separate TestPyPI Trusted
Publishing workflow. Maintainers may intentionally dispatch it from any
selected ref; it is not restricted to `master`. It fails if the package
version already exists on TestPyPI. Trusted Publishing is free and uses
short-lived OIDC credentials instead of a stored package token.
- Production publishing is local-only. Follow the
[Release Workflow](#release-workflow). GitHub Actions does not publish
production PyPI or npm packages and does not need their credentials.
- Workflow actions are pinned to immutable commits, with version comments kept
beside each pin for dependency automation and review. Workflow `uv`
installations are also version-pinned.
Expand Down Expand Up @@ -199,10 +143,81 @@ See [ADR 0013](docs/architecture/adr/0013-version-source-of-truth-and-documentat

During the public-alpha `0.x` period, support the current schema only. Persisted run artifacts are disposable audit outputs, not migration targets; stale preflight plans may be rejected by explicit shape validation even when they carry the current `SCHEMA_VERSION`.

## Release Workflows
## Release Workflow

Use `make help` for target details and [CONTRIBUTING.md](CONTRIBUTING.md) for
the maintainer release flow.
Production releases have two publication phases: publish the packages and Git
tag locally, then publish the GitHub Release. Use `make help` for target
details.

### 1. Prepare and validate

Update the version in `pyproject.toml` and add the matching section to
`CHANGELOG.md`. Review the changelog content manually, then run:

```bash
make release-prepare
make release-check
```

Both commands must pass before publication. Preparation stops if the target
version already exists on PyPI or npm. Some install checks may be skipped when
optional local tools such as `pipx`, npm, or Homebrew are unavailable; review
the skip messages before continuing.

### 2. Publish packages and the Git tag

Configure the PyPI and npm credentials, then run:

```bash
make release
```

Confirm the exact version when prompted. A successful run publishes PyPI,
publishes npm with the `latest` dist-tag, and pushes the annotated Git tag.

For a non-interactive npm release that requires two-factor authentication, set
`NPM_PUBLISH_OTP` or `NPM_OTP`. A separate npm `latest` recovery uses
`NPM_DIST_TAG_OTP` or `NPM_OTP`.

### 3. Publish the GitHub Release

Immediately after `make release` pushes the tag, dispatch
`.github/workflows/release.yml` from `master`. Use the full Git tag, including
the `v` prefix, as the required `tag` input: for package version `0.1.4`, enter
`v0.1.4`, not `0.1.4`. Do this before `master` advances: dispatching from
another ref, backfilling a historical tag, or dispatching after a newer commit
reaches `master` is unsupported.

The workflow verifies the tagged release and publishes the GitHub Release. It
does not publish the production PyPI or npm packages. Prereleases are never
marked as GitHub `Latest`; a stable release is marked `Latest` only when it is
the highest published stable version on PyPI.

### 4. Publish Homebrew separately

Copy the prepared formula into the Homebrew tap, run the tap's audit and test
steps, and push the tap update.

### Recover an interrupted release

If only part of PyPI or one registry was published, fix the reported problem
and rerun the corresponding target:

```bash
make release-pypi
make release-npm
```

These targets verify anything already published and complete only the missing
work. Use `make release-npm` when the npm package exists but its `latest`
dist-tag is stale. Once both registries are complete, rerun `make release` to
finish the Git tag.

### TestPyPI

Use `.github/workflows/testpypi.yml` for TestPyPI Trusted Publishing. It may be
dispatched from any selected ref and stops if that package version already
exists on TestPyPI.

## Key Modules

Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ help:
' package-build Build wheel and sdist into dist/' \
' package-check Build artifacts, run twine check, verify Homebrew sdist SHA' \
' install-smoke Exercise pip, uv tool, and pipx installs where available' \
' install-check Run package checks plus installer, npm, and Homebrew smokes' \
' install-check Build once, then run package, installer, npm, and Homebrew smokes' \
'' \
'Normal release flow:' \
' release-prepare Sync generated metadata and prepare local release artifacts' \
' release-check Run pre-publish checks, or verify a completed release no-op' \
' release-check Verify changelog and pre-publish checks, or a completed release' \
' release Confirm, check, publish PyPI, publish npm latest, and tag' \
'' \
'Partial completion:' \
Expand Down
26 changes: 19 additions & 7 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import argparse
import re
import subprocess
import sys
from pathlib import Path

from markdown_it import MarkdownIt
from release import build, publish, smoke
from release.state import (
CommandRunner,
Expand Down Expand Up @@ -161,6 +163,7 @@ def release_check(root: Path, runner: CommandRunner) -> int:
and npm.latest == context.version.npm
and not formula_issues
and is_tag_only_missing_error(tag_issues)
and is_tag_only_missing_error(list(state.reasons))
):
print(
"Release registries are published and Git tag is missing; pre-publish checks are not needed."
Expand All @@ -169,10 +172,8 @@ def release_check(root: Path, runner: CommandRunner) -> int:
if state.status in {ReleaseStatus.PARTIAL, ReleaseStatus.BLOCKED}:
return 1
fail_if_generated_metadata_stale(context, manifest)
changelog_check(root)
run_pre_publish_checks(root, runner)
print(
"Verify CHANGELOG.md describes the pyproject.toml version before running make release."
)
return 0


Expand All @@ -186,10 +187,21 @@ def run_pre_publish_checks(root: Path, runner: CommandRunner) -> None:
def changelog_check(root: Path) -> None:
context = read_release_context(root)
text = (root / "CHANGELOG.md").read_text(encoding="utf-8")
if (
f"## [{context.version.project}]" not in text
and f"## {context.version.project}" not in text
):
version = re.escape(context.version.project)
version_heading = re.compile(rf"^(?:\[{version}\]|{version})(?:[ \t]+.*)?$")
tokens = MarkdownIt("commonmark").parse(text)
has_version_heading = any(
opening.type == "heading_open"
and opening.tag == "h2"
and opening.level == 0
and opening.markup == "##"
and inline.type == "inline"
and version_heading.fullmatch(
"".join(child.content for child in inline.children or ())
)
for opening, inline in zip(tokens, tokens[1:], strict=False)
)
if not has_version_heading:
raise ReleaseError(
f"CHANGELOG.md does not contain a section for {context.version.project}. "
"Changelog content is still reviewed manually."
Expand Down
Loading