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
41 changes: 39 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 10 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions scripts/bundle-relay.sh
Original file line number Diff line number Diff line change
@@ -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 <version-label> <commit-sha> [output-dir]
set -euo pipefail

VERSION_LABEL="${1:?usage: bundle-relay.sh <version-label> <commit-sha> [output-dir]}"
COMMIT_SHA="${2:?usage: bundle-relay.sh <version-label> <commit-sha> [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"
85 changes: 85 additions & 0 deletions scripts/publish-pypi.sh
Original file line number Diff line number Diff line change
@@ -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 <version> [--dry-run]
set -euo pipefail

VERSION="${1:?usage: publish-pypi.sh <version> [--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
23 changes: 23 additions & 0 deletions src/droste/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
]
35 changes: 35 additions & 0 deletions tests/test_trace_abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"