Proxy dev releases phase 1: the publish workflow¶
Master plan: PLAN-proxy-dev-releases.md. This phase builds the
path-filtered GitHub Actions workflow that publishes PEP 440 dev
releases of the kerbside-proxy wheel to PyPI, plus the version
stamping helper and the one-time setup documentation. Phases 2-5
(committed dev specifier, contract handshake, docs/downstream,
pruning) are out of scope here.
Planning effort: high (per the master plan) — PyPI trusted publishing, maturin version semantics and workflow security posture all involve judgment calls. Review effort for implementation steps: per the step table below.
Scope¶
In scope:
.github/workflows/dev-proxy-wheel.yml— the publish workflow.tools/stamp-dev-proxy-version.sh— dev version stamping helper.- RELEASE-SETUP.md and AGENTS.md updates for the new workflow and
its one-time setup (GitHub
dev-releaseenvironment, PyPI trusted publisher registration). - Pre-merge verification that the stamped wheel carries the expected PEP 440 dev version (dockerized native build).
Out of scope (explicitly):
- The committed
kerbside-proxy>=0.4.0.dev0specifier inpyproject.tomland the stamp-script changes — phase 2. Until phase 2 lands, dev wheels published by this phase are resolvable but nothing depends on them. - The contract handshake — phase 3.
- Any change to
tools/build-proxy-wheel.sh,release.yml, ortools/stamp-proxy-version.sh(release behaviour is unchanged). - Pruning — phase 5.
What the survey found¶
No prior phase exists; this is phase 1. The master plan's phase 1 section was verified against the tree on 2026-08-14 and no false claims were found. Specific verifications, so the reader can check:
- Version derivation:
git describe --dirty --tags --match 'v*' --first-parent(the exact command configured in[tool.setuptools_scm], pyproject.toml:135) yieldsv0.4.0-159-g<sha>on the planning branch, so setuptools_scm derives0.4.1.dev159+g<sha>and stripping the local segment gives the monotonic0.4.1.devNscheme the master plan assumes. rust/kerbside-proxy/Cargo.lockexists and is the only Cargo.lock in the tree, so therust/**path filter covers both Cargo files; no separate filter entries are needed.rust/kerbside-proxy/pyproject.toml:31isdynamic = ["version"]— the line decision 1 replaces at stamp time.tools/build-proxy-wheel.shtakesx86_64,aarch64or--native, writes wheels to${WHEEL_OUT:-dist/proxy-wheels}, and its prerequisites (maturin, ziglang, rustup) are installed by the caller — matching the release workflow's wheel matrix (release.yml:98-109), which this workflow mirrors.- The release wheel matrix runs on
[self-hosted, vm, debian-12, xl](release.yml:78); no new runner class is required, and aarch64 is zig-cross-compiled so no aarch64 runner is needed. RELEASE-SETUP.mdhas a "One-Time Setup Steps" section (line 17) where the dev-release environment and trusted publisher registration steps belong..github/workflows/rust.yml:12-22models the path set (rust/**pluskerbside/rpc/kerbside.protoplus the workflow file).- Branch/PR structure precedent: both prior master plans landed with their phase 1 plan stacked as consecutive commits on the same branch (sfui-conversion: 88c8ba7 then c998858; two-tier-ci: 14f4f80 then 9c2d46e). This plan follows that precedent — see decision 2.
Since nothing was false, no corrections to the master plan's phase
section or index.md description were needed.
Decisions¶
- Wheel dev versions come from a static
[project] versionin the crate's pyproject.toml, stamped by a newtools/stamp-dev-proxy-version.sh, not from Cargo semver pre-release translation. The script derives the version via setuptools_scm, strips the local segment (0.4.1.dev159+g<sha>→0.4.1.dev159), and replacesdynamic = ["version"]inrust/kerbside-proxy/pyproject.tomlwithversion = "0.4.1.dev159". Reasoning: the version is then exact PEP 440 by construction, with no reliance on maturin's semver→PEP 440 translation of a0.4.1-dev.159Cargo pre-release; Cargo.toml is untouched (its0.1.0placeholder stays); and the release path (stamp-proxy-version.sh, which stamps Cargo.toml) is entirely unaffected. This is the decision a reviewer is most likely to argue with, because it assumes maturin prefers a static[project] versionover the Cargo version whendynamicis absent. That assumption is verified before anything reaches PyPI: step 1a's done-check builds a wheel in Docker and asserts the filename carries the dev version. - The phase plan and implementation stack on the existing
proxy-dev-releasesbranch and worktree instead of a new worktree cut from develop. The master plan commit (bdcd8dc) exists only on this branch, so a develop-cut worktree would not contain the plan this phase executes; stacking matches the repository's established first-phase precedent (survey, last bullet). Later phases, planned after this branch merges, should revert to per-phase branches (proxy-dev-releases-phase-NN-*). - Workflow shape: two jobs.
buildis a[x86_64, aarch64]matrix on[self-hosted, vm, debian-12, xl]withfail-fast: false, mirroringrelease.yml: checkout withfetch-depth: 0, install maturin+ziglang in a venv, runtools/stamp-dev-proxy-version.sh, runtools/build-proxy-wheel.sh <arch>, upload the wheel artifact.publishdownloads both artifacts, generates build provenance attestations, and publishes withpypa/gh-action-pypi-publish@release/v1andskip-existing: true. - Triggers:
pushto develop filtered to the decision-7 paths, andworkflow_dispatchwith adry_runboolean input defaulting to true. A dry run builds both wheels but skips the publish job, so workflow testing can never publish by accident; the bootstrap publish is an explicitdry_run: falsedispatch. There is deliberately NOpull_requesttrigger: PR-triggered runs would execute untrusted forks' code in a workflow whose identity a PyPI trusted publisher accepts. (The publish job's environment gate also protects this, but not having the trigger is the simpler invariant.) - Trust boundaries: only the
publishjob runs in thedev-releaseenvironment and only it getsid-token: write+attestations: write; build jobs run withcontents: read. Thedev-releaseenvironment has no required reviewers but is restricted to the develop branch, so a compromised feature branch cannot dispatch a publishing run. - Concurrency: group
dev-proxy-wheel,cancel-in-progress: false, so racing merges serialise. Versions are per-commit distinct (commit-count-based), andskip-existing: truemakes any replay idempotent. - Path filter (push trigger):
rust/**,kerbside/rpc/kerbside.proto,tools/build-proxy-wheel.sh,tools/stamp-dev-proxy-version.sh,tools/gen-protos.sh(added for symmetry during PR #314 review),.github/workflows/dev-proxy-wheel.yml. This is the master plan's list plus the scripts whose changes alter the built artifact or the publish behaviour. - Operator setup happens before merge: the
dev-releaseenvironment (no reviewers, develop-branch-restricted) and the PyPI trusted publisher fordev-proxy-wheel.yml+dev-releaseenvironment on thekerbside-proxyproject are created while the PR is in review, so the first real triggering merge publishes rather than failing. If setup slips, the failure mode is a loud red run, not silence.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | medium | sonnet | none | Write tools/stamp-dev-proxy-version.sh modelled on tools/stamp-proxy-version.sh (same header-comment style, set -euo pipefail, repo-root derivation). It must: derive the version with python3 -m setuptools_scm (document that the caller needs setuptools_scm importable and full git history), strip the PEP 440 local segment (${version%%+*}), validate the result matches ^[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ (refuse final release versions — this tool is the dev-side mirror of stamp-proxy-version.sh's refusal of dev versions), and replace the dynamic = ["version"] line in rust/kerbside-proxy/pyproject.toml with version = "<ver>", erroring if the line is absent (already-stamped trees must fail loudly, not double-stamp). Also accept an explicit version as $1 for testability. shellcheck (via pre-commit) must pass. |
| 1b | high | opus | none | Write .github/workflows/dev-proxy-wheel.yml implementing decisions 3-7 exactly. Mirror release.yml's build-proxy-wheels job for the build matrix (checkout fetch-depth: 0, venv with maturin+ziglang, dtolnay/rust-toolchain@stable, apt prerequisites) plus pip install setuptools_scm for the stamp step, and its publish jobs for attestation + pypa/gh-action-pypi-publish@release/v1 with skip-existing: true. Header comment must state: why there is no pull_request trigger (untrusted code vs trusted publisher identity), why dev wheels are attested but not tag-signed (no tag exists), and that the dev-release environment + trusted publisher are documented in RELEASE-SETUP.md. The publish job condition: run when the event is a push, or a dispatch with dry_run == 'false' (dispatch inputs are strings). actionlint (via pre-commit) must pass. |
| 1c | medium | sonnet | none | Update RELEASE-SETUP.md "One-Time Setup Steps": a new subsection for dev releases covering creating the dev-release GitHub environment (no required reviewers, deployment branch restricted to develop) and registering a second trusted publisher on the PyPI kerbside-proxy project (workflow dev-proxy-wheel.yml, environment dev-release), and extend "How Releases Work" with a short "Dev releases" paragraph (when they publish, version scheme, attestation-but-no-tag-signing). Update AGENTS.md's release/build notes to mention the dev release lane and tools/stamp-dev-proxy-version.sh. Keep both documents' existing tone and heading style. |
| 1d | medium | sonnet | none | Verification, pre-merge, per the operator's no-native-Rust preference: inside the official rust Docker image (mounting the repo, matching the documented local-build pattern), pip-install maturin + setuptools_scm in a venv, run tools/stamp-dev-proxy-version.sh, then tools/build-proxy-wheel.sh --native, and assert the produced wheel filename matches kerbside_proxy-[0-9.]*\.dev[0-9]*-py3-none-.*\.whl. Report the exact filename. Then git checkout -- rust/kerbside-proxy/pyproject.toml to unstamp. This is the check that retires decision 1's maturin-precedence risk before any PyPI interaction. |
Each step is its own commit (1d amends nothing — it is evidence, recorded in the PR description, not a tree change).
Risks and mitigations¶
- Maturin ignores or rejects a static
[project] versionfor bin bindings. Retired by step 1d before merge (management session checks the reported wheel filename). Fallback if it fails: stamp a Cargo semver pre-release (X.Y.Z-dev.N) instead and verify maturin's translation the same way; the workflow interface does not change either way. - A newly added workflow may not be dispatchable until it exists on the default branch (GitHub limitation). Mitigation: the dry-run dispatch happens after merge; pre-merge confidence comes from step 1d plus actionlint. The bootstrap publish was always going to be post-merge.
- Trusted publisher not registered when the first push trigger
fires. Mitigated by decision 8 (setup during PR review); the
residual failure mode is a red publish job, which
skip-existing-safe re-running (or a dispatch) recovers once registration lands. The management session checks the first triggered run. - Publishing runs on self-hosted runners. Already the accepted
posture for real releases (
release.ymlpublishes from[self-hosted, static]with the same OIDC action); dev wheels add no new runner exposure. Noted for the reviewer rather than mitigated further. - Two merges racing to publish. Serialised by the concurrency
group; distinct versions per commit;
skip-existingmakes replays harmless.
Definition of done¶
tools/stamp-dev-proxy-version.shexists;pre-commit run --all-filespasses (shellcheck, actionlint included).- Running the stamp script twice in a row fails the second time with a message naming the already-stamped line.
- Step 1d's dockerized build produced a wheel whose filename
matches
kerbside_proxy-*.dev*-py3-none-*.whl, and the filename is recorded in the PR description. .github/workflows/dev-proxy-wheel.yml's push paths are exactly the decision-7 list (git diff-able against this plan), it has nopull_requesttrigger, and only the publish job references thedev-releaseenvironment.- RELEASE-SETUP.md documents the
dev-releaseenvironment and the second trusted publisher; AGENTS.md mentions the dev release lane. - Post-merge, in order: a
dry_run: truedispatch builds both wheels and skips publish; adry_run: falsedispatch (the bootstrap) publishes, after whichpip index versions kerbside-proxy --prelists a0.4.1.devNversion; and the next Python-only merge to develop does NOT trigger the workflow (gh run list --workflow=dev-proxy-wheel.ymlshows no run for that merge SHA). - The master plan's Execution table and
docs/plans/index.mdreflect this phase as planned (done in the planning commit).
Back brief¶
Before executing any step of this plan, back brief the operator on the plan and how the intended work aligns with it. Additional gate: after step 1b lands in review, pause for the operator to perform the decision-8 one-time setup (GitHub environment + PyPI trusted publisher) before merge; steps 1c/1d do not depend on that setup and may proceed meanwhile.