diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f7d8d..ae2b943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,9 @@ jobs: PY - name: Distributions bundle the exact event ABI corpus (#144) run: | - for f in trace-v9-execution.ndjson trace-v9-lifecycle.ndjson runner-v10-refusal.ndjson; do + fixtures="$(cd src/droste/testing/fixtures && ls *.ndjson | sort)" + [ -n "$fixtures" ] || { echo "conformance corpus is empty"; exit 1; } + for f in $fixtures; do unzip -l dist/*.whl | grep -q "droste/testing/fixtures/$f" || { echo "wheel is missing droste/testing/fixtures/$f"; exit 1; } @@ -172,6 +174,39 @@ jobs: cmp src/droste/testing/fixtures/runner-v10-refusal.ndjson \ "$tmp/$sdist_root/src/droste/testing/fixtures/runner-v10-refusal.ndjson" + release-dryrun: + name: release path (dry run) + runs-on: [self-hosted, macOS, ARM64] + # The release workflow used to run only on a tag, so a step that broke was + # discoverable only by breaking a release -- which is how a fixture rename + # shipped. Exercise the same scripts here, on every pull request, with the + # single irreversible action stubbed out. + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - name: Sync (locked) + run: uv sync --locked + - name: Build sdist + wheel + run: uv build + - name: Bundle the Pyodide relay assets + run: scripts/bundle-relay.sh "v0.0.0-dryrun" "$GITHUB_SHA" + - name: Relay tarball carries the whole conformance corpus + run: | + fixtures="$(cd src/droste/testing/fixtures && ls *.ndjson | sort)" + [ -n "$fixtures" ] || { echo "conformance corpus is empty"; exit 1; } + for f in $fixtures; do + tar tzf relay-dist/droste-relay-v0.0.0-dryrun.tar.gz \ + | grep -q "conformance/$f" || { + echo "relay tarball is missing conformance/$f"; exit 1; + } + done + - name: Publish is validated, never performed + # The version actually built: publish-pypi.sh refuses a dist/ holding + # anything else, which is the guard being exercised, not bypassed. + run: | + version="$(uv run --no-project --python 3.11 python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + scripts/publish-pypi.sh "$version" --dry-run + deno: name: deno (pyodide substrate) runs-on: [self-hosted, macOS, ARM64] @@ -319,7 +354,9 @@ jobs: PY - name: Distributions bundle the exact event ABI corpus (#144) run: | - for f in trace-v9-execution.ndjson trace-v9-lifecycle.ndjson runner-v10-refusal.ndjson; do + fixtures="$(cd src/droste/testing/fixtures && ls *.ndjson | sort)" + [ -n "$fixtures" ] || { echo "conformance corpus is empty"; exit 1; } + for f in $fixtures; do unzip -l dist/*.whl | grep -q "droste/testing/fixtures/$f" || { echo "wheel is missing droste/testing/fixtures/$f"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcc4cac..3bb8722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,29 +63,17 @@ jobs: run: uv run pytest -q - name: Build sdist + wheel run: uv build - - name: Publish to PyPI (trusted publishing) - # --check-url makes re-runs idempotent: if a previous attempt uploaded - # some artifacts before failing (or the release step after this one - # failed), the retry verifies existing files against the index and - # skips them instead of erroring on duplicate filenames. - run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/droste/ - name: Bundle the Pyodide relay assets - # Convenience artifact for non-Python consumers (#33): the EMBEDDER - # path is now the wheel itself — the relay ships as package data - # under droste/substrates/_relay (see `droste relay-path`), so wheel - # and relay are version-locked by construction. Deliberately NOT in - # dist/ — uv publish uploads dist/* to PyPI and would choke on a - # tarball that isn't an sdist. - run: | - staging="droste-relay-$GITHUB_REF_NAME" - mkdir -p "$staging/conformance" relay-dist - cp src/droste/substrates/_relay/*.ts pyodide/README.md "$staging/" - cp src/droste/testing/fixtures/trace-v9-execution.ndjson \ - src/droste/testing/fixtures/trace-v9-lifecycle.ndjson \ - src/droste/testing/fixtures/runner-v10-refusal.ndjson \ - "$staging/conformance/" - printf '%s %s\n' "$GITHUB_REF_NAME" "$GITHUB_SHA" > "$staging/DROSTE_VERSION" - tar czf "relay-dist/$staging.tar.gz" "$staging" + # Ordered BEFORE the publish: every fallible local step must finish + # before the one irreversible byte leaves the runner. This step failing + # after a successful upload is what stranded 0.24.0 -- published to + # PyPI, absent from the GitHub release, and impossible to re-tag. + run: scripts/bundle-relay.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + - name: Publish to PyPI (trusted publishing) + # The only irreversible step, and now the last thing that can fail in a + # way that matters. Converges: a re-run of an already-published version + # is a no-op rather than a duplicate-filename error. + run: scripts/publish-pypi.sh "${GITHUB_REF_NAME#v}" - name: GitHub release with artifacts # Retry-safe like the PyPI step: create only when absent, and upload # assets with replacement semantics so a rerun completes a partial diff --git a/scripts/bundle-relay.sh b/scripts/bundle-relay.sh new file mode 100755 index 0000000..1b9b4b8 --- /dev/null +++ b/scripts/bundle-relay.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Assemble the relay convenience tarball for non-Python consumers (#33). +# +# The EMBEDDER path is the wheel itself — the relay ships as package data under +# droste/substrates/_relay (see `droste relay-path`), so wheel and relay are +# version-locked by construction. This tarball exists for consumers that cannot +# install a Python package. +# +# Lives in a script rather than inline in release.yml so CI can run the exact +# same code on every pull request. Inline, it ran only on a tag, which is how a +# fixture rename shipped a broken release: nothing exercised it beforehand. +# +# Usage: bundle-relay.sh [output-dir] +set -euo pipefail + +VERSION_LABEL="${1:?usage: bundle-relay.sh [output-dir]}" +COMMIT_SHA="${2:?usage: bundle-relay.sh [output-dir]}" +OUT_DIR="${3:-relay-dist}" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +staging="droste-relay-$VERSION_LABEL" +rm -rf "$staging" +mkdir -p "$staging/conformance" "$OUT_DIR" + +cp src/droste/substrates/_relay/*.ts pyodide/README.md "$staging/" + +# Enumerated from the package, never listed here: a corpus rename must not +# require finding this file. Fails loudly if the corpus is empty rather than +# shipping a tarball with a silently missing conformance directory. +fixtures="$(cd src/droste/testing/fixtures && ls *.ndjson | sort)" # single source: the corpus directory itself +if [ -z "$fixtures" ]; then + echo "::error::conformance corpus is empty — refusing to bundle a relay tarball without it" >&2 + exit 1 +fi +while IFS= read -r fixture; do + cp "src/droste/testing/fixtures/$fixture" "$staging/conformance/" +done <<< "$fixtures" + +printf '%s %s\n' "$VERSION_LABEL" "$COMMIT_SHA" > "$staging/DROSTE_VERSION" +tar czf "$OUT_DIR/$staging.tar.gz" "$staging" +rm -rf "$staging" +echo "bundled $OUT_DIR/$staging.tar.gz" diff --git a/scripts/publish-pypi.sh b/scripts/publish-pypi.sh new file mode 100755 index 0000000..20a8226 --- /dev/null +++ b/scripts/publish-pypi.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Publish dist/* to PyPI, converging rather than executing once. +# +# A release is retried by re-running the workflow or re-pushing the tag, so +# every step has to be safe to run twice. Publishing is the one step that +# cannot be undone, which makes it the one that most needs to be re-runnable. +# +# `uv publish --check-url` was supposed to provide that and does not. It +# predicts from the Simple index, which is CDN-cached, and it compares against +# freshly built artifacts — but sdists are not byte-reproducible, so a rebuild +# is a *different* file with the same name. Either way the upload is attempted +# and PyPI rejects it, so a release whose later step failed could never be +# retried at the same version. +# +# The authoritative question is not "does the index list files that match the +# ones I just built" but "does this version exist on PyPI". Ask that, of the +# API rather than the cache, and only after the upload has actually been tried. +# +# Usage: publish-pypi.sh [--dry-run] +set -euo pipefail + +VERSION="${1:?usage: publish-pypi.sh [--dry-run]}" +DRY_RUN="${2:-}" + +# `uv publish` uploads dist/* wholesale, so anything stale left in that +# directory is published alongside the release. A workspace that is not +# scrubbed between builds — a self-hosted runner, a developer's checkout — +# turns a leftover artifact into an unintended upload of some older version. +# Refuse rather than trust the directory to hold only what this release built. +unexpected="" +for artifact in dist/*; do + [ -e "$artifact" ] || continue + case "$(basename "$artifact")" in + "droste-$VERSION.tar.gz" | "droste-$VERSION-"*.whl) ;; + *) unexpected="$unexpected $(basename "$artifact")" ;; + esac +done +if [ -n "$unexpected" ]; then + echo "::error::dist/ holds artifacts that are not droste $VERSION:$unexpected" >&2 + echo "::error::clear dist/ and rebuild — publishing would upload them too" >&2 + exit 1 +fi + +if [ "$DRY_RUN" = "--dry-run" ]; then + echo "dry run: validating dist/ without uploading" + uv publish --dry-run + exit 0 +fi + +version_is_published() { + # Query the JSON API for the exact version. 404 means absent; any other + # failure is inconclusive and must not be read as "already published". + local status + status="$(curl -s -o /dev/null -w '%{http_code}' \ + "https://pypi.org/pypi/droste/$VERSION/json")" + case "$status" in + 200) return 0 ;; + 404) return 1 ;; + *) + echo "::error::PyPI returned $status for droste $VERSION — cannot tell whether it is published" >&2 + exit 1 + ;; + esac +} + +if version_is_published; then + echo "droste $VERSION is already on PyPI — nothing to publish" + exit 0 +fi + +if uv publish --trusted-publishing always; then + echo "published droste $VERSION" + exit 0 +fi + +# The upload failed. That is only acceptable if the version is now present, +# which happens when a previous attempt uploaded it (or this attempt partially +# succeeded before erroring on a duplicate filename). Re-ask the authority. +if version_is_published; then + echo "publish reported an error but droste $VERSION is on PyPI — treating as already published" + exit 0 +fi + +echo "::error::publish failed and droste $VERSION is not on PyPI" >&2 +exit 1 diff --git a/src/droste/testing/__init__.py b/src/droste/testing/__init__.py index f1b5f26..8b2c195 100644 --- a/src/droste/testing/__init__.py +++ b/src/droste/testing/__init__.py @@ -16,6 +16,28 @@ from .subcall_client import MockSubcallClient +def conformance_fixture_names() -> tuple[str, ...]: + """Every NDJSON fixture in the shipped conformance corpus, enumerated. + + The corpus is named in packaging checks, the release tarball, and + downstream suites in other repositories. Listing the filenames in each of + those means an ABI rename has to be found in all of them, and it will be + missed in whichever one has no pull request to exercise it -- that is + exactly how the v8 -> v9 rename reached a release and broke it, in the one + workflow that only ever runs on a tag. + + Ask here instead, and a rename is invisible to every consumer. + """ + + return tuple( + sorted( + path.name + for path in files(__package__).joinpath("fixtures").iterdir() + if path.name.endswith(".ndjson") + ) + ) + + def trace_v9_lifecycle_ndjson() -> bytes: """Return the shared Trace ABI v7 lifecycle conformance corpus.""" @@ -50,6 +72,7 @@ def runner_v10_refusal_ndjson() -> bytes: "require_ordered_terminal_events", "require_unknown_completion", "run_while_blocked", + "conformance_fixture_names", "trace_v9_execution_ndjson", "trace_v9_lifecycle_ndjson", ] diff --git a/tests/test_trace_abi.py b/tests/test_trace_abi.py index a4e9720..aa31413 100644 --- a/tests/test_trace_abi.py +++ b/tests/test_trace_abi.py @@ -1348,3 +1348,38 @@ def test_startup_reports_the_ready_gates_a_run_armed() -> None: "ready_gates": ["policy_hints", "ready_answer_validator"], }, ) + + +def test_conformance_corpus_is_enumerated_not_listed() -> None: + """A corpus rename must not require editing every consumer. + + The filenames were duplicated across two workflows, the packaging check, + the release tarball, and downstream suites; the v8 -> v9 rename was missed + in the one workflow that only runs on a tag, and it broke a release. + """ + + from droste.testing import ( + conformance_fixture_names, + runner_v10_refusal_ndjson, + trace_v9_execution_ndjson, + trace_v9_lifecycle_ndjson, + ) + + names = conformance_fixture_names() + + assert names == tuple(sorted(names)), "enumeration must be deterministic" + assert names, "an empty corpus would silently pass every consumer's check" + assert all(name.endswith(".ndjson") for name in names) + # Every named accessor's fixture is in the enumeration, so a consumer + # driven by it stages exactly what the helpers can read. + assert set(names) == { + "runner-v10-refusal.ndjson", + "trace-v9-execution.ndjson", + "trace-v9-lifecycle.ndjson", + } + for reader in ( + runner_v10_refusal_ndjson, + trace_v9_execution_ndjson, + trace_v9_lifecycle_ndjson, + ): + assert reader(), "every enumerated fixture must be non-empty"