From 49726d9e79688b1e29c29d3fbe724a796f891263 Mon Sep 17 00:00:00 2001 From: Shane Vitarana Date: Tue, 4 Aug 2026 12:12:24 -0400 Subject: [PATCH 1/2] Converge the release instead of executing it once A release is retried by re-running the workflow or re-pushing the tag, and both assume every step is safe to run twice. Publishing is not, so the recovery an operator reaches for first was guaranteed to fail. That is how 0.24.0 ended up published to PyPI, missing from its GitHub release, and impossible to re-tag. Four changes, in order of what actually caused it. The conformance corpus is enumerated, not listed. Its filenames were spelled out in two workflows, the packaging check, the release tarball, and suites in another repository; the v8 -> v9 rename was missed in release.yml, the one place no pull request exercises. conformance_fixture_names() answers the question instead, so a rename is invisible to every consumer. The release path runs on every pull request. Bundling moved out of inline YAML into scripts/bundle-relay.sh so CI can run the identical code with the single irreversible action stubbed out. A step that only ever executes on a tag is discoverable only by breaking a release. Publishing is last. Every fallible local step -- build, bundle -- now finishes before the first irreversible byte leaves the runner, so a later failure can no longer strand a completed upload. Publishing converges. --check-url was documented as making re-runs idempotent and does not: it predicts from a CDN-cached index and compares against freshly built artifacts, but sdists are not byte-reproducible, so a rebuild is a different file under the same name and the upload is attempted regardless. Ask the authority instead -- is this version on PyPI -- both before uploading and after a failed upload. An already-published version is now a no-op. The dry run earned itself immediately by catching eight stale artifacts in a local dist/, which uv publish would have uploaded alongside the release; the publish script now refuses any artifact that is not the version being cut. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 37 ++++++++++++++- .github/workflows/release.yml | 32 ++++--------- scripts/bundle-relay.sh | 47 +++++++++++++++++++ scripts/publish-pypi.sh | 85 ++++++++++++++++++++++++++++++++++ src/droste/testing/__init__.py | 23 +++++++++ tests/test_trace_abi.py | 35 ++++++++++++++ 6 files changed, 235 insertions(+), 24 deletions(-) create mode 100755 scripts/bundle-relay.sh create mode 100755 scripts/publish-pypi.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f7d8d..90a0e9f 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="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + [ -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,35 @@ 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="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + [ -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 + run: scripts/publish-pypi.sh "0.0.0" --dry-run + deno: name: deno (pyodide substrate) runs-on: [self-hosted, macOS, ARM64] @@ -319,7 +350,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="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + [ -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..7d35823 --- /dev/null +++ b/scripts/bundle-relay.sh @@ -0,0 +1,47 @@ +#!/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="$(uv run --no-sync python -c ' +from droste.testing import conformance_fixture_names +print("\n".join(conformance_fixture_names())) +')" +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" From ca35cf83f27d77a93aab3f082b0b86d7f17248cc Mon Sep 17 00:00:00 2001 From: Shane Vitarana Date: Tue, 4 Aug 2026 12:16:42 -0400 Subject: [PATCH 2/2] Enumerate the corpus without needing an installed droste The packaging job builds a wheel and never syncs an environment, so importing droste.testing to list the corpus failed there. The directory is the single source either way, so glob it: no import, no dependency on job ordering, and a rename still reaches every consumer. conformance_fixture_names() stays for library callers. Pass the built version to the dry-run publish too. It was handed a placeholder, which the stale-artifact guard correctly rejected against a dist/ holding the real build -- the guard working, exercised wrongly. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 ++++++++---- scripts/bundle-relay.sh | 5 +---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90a0e9f..ae2b943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: PY - name: Distributions bundle the exact event ABI corpus (#144) run: | - fixtures="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + 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" || { @@ -192,7 +192,7 @@ jobs: run: scripts/bundle-relay.sh "v0.0.0-dryrun" "$GITHUB_SHA" - name: Relay tarball carries the whole conformance corpus run: | - fixtures="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + 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 \ @@ -201,7 +201,11 @@ jobs: } done - name: Publish is validated, never performed - run: scripts/publish-pypi.sh "0.0.0" --dry-run + # 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) @@ -350,7 +354,7 @@ jobs: PY - name: Distributions bundle the exact event ABI corpus (#144) run: | - fixtures="$(uv run --no-sync python -c 'from droste.testing import conformance_fixture_names; print("\n".join(conformance_fixture_names()))')" + 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" || { diff --git a/scripts/bundle-relay.sh b/scripts/bundle-relay.sh index 7d35823..1b9b4b8 100755 --- a/scripts/bundle-relay.sh +++ b/scripts/bundle-relay.sh @@ -29,10 +29,7 @@ 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="$(uv run --no-sync python -c ' -from droste.testing import conformance_fixture_names -print("\n".join(conformance_fixture_names())) -')" +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