chore: harden the release pipeline — floor guard, release-PR auto-sync, publish gate, stderr fix, exit-code docs - #103
Open
GregHolmes wants to merge 15 commits into
Open
chore: harden the release pipeline — floor guard, release-PR auto-sync, publish gate, stderr fix, exit-code docs#103GregHolmes wants to merge 15 commits into
GregHolmes wants to merge 15 commits into
Conversation
The plugin loader printed load failures through a stdout Console, so one broken plugin corrupted `dg ... -o json` payloads for every remaining command -- the amplifier in the core-floor incident, where an ImportError put 24 lines of error text ahead of the JSON. Diagnostics now go through the shared stderr console (the same one main.py and print_error use), and a test pins both the console identity and the stream split.
…deliver every release The 0.3.0 release raised root floors for the 17 packages publishing new versions, but 13 packages that released in earlier cycles still had floors as low as >=0.0.1 -- the same delivery gap, historical: a user upgrading from an old enough version gets the new deepctl while transcribe, the debug family, shared-utils and friends stay stale, because pip's only-if-needed strategy upgrades nothing the floor does not force. Adds scripts/check_dependency_floors.py, which enforces two rules: root floors must equal workspace versions (the delivery manifest), and sub-package floors must never exceed a sibling's current version (satisfiability). --fix rewrites the root floors; it produced this diff. Every pinned version is already live on PyPI.
Third occurrence of hand-maintained floors going stale (#92, then #102's sweep, then the 13 historical ones fixed alongside this). The guard makes the failure mode impossible to merge instead of something someone remembers: `make floors-check` runs in CI, `make floors-fix` repairs root floors locally, and the release workflow will keep them pinned automatically.
…tically release-please bumps every pyproject version but has no updater for uv.lock or for the root dependency floors. The stale lock failed every CI job at `uv sync --locked` two releases running (0.2.28's 32 red checks, 0.3.0's repeat), and stale floors are the delivery gap #102 fixed by hand. Both are mechanical consequences of the version bumps, so a job now regenerates them on the release branch right after release-please pushes it. GITHUB_TOKEN pushes don't retrigger PR checks; the routine hand-edit of the release notes does, and the caveat is documented in the workflow.
…esolvability A verify-published job polls `pip install --dry-run deepctl==X` in a clean venv until the full dependency closure resolves from PyPI, then the jobs that advertise the release run. Motivated twice over: 0.2.27 published partially and pip silently backtracked to the previous version with exit 0, and the 0.3.0 rollout showed a fresh install backtracking to 0.2.26 during the CDN propagation window. The brew job's own poll checks only the root JSON endpoint; this exercises the actual resolver across every floor.
0.3.0 started enforcing exit codes, and both the changelog and the BREAKING CHANGES entry point at documentation that only existed in web/public/llms-full.txt -- the LLM-consumption artifact. The README's CI/Automation section now carries the table (0 success, 1 error including crashes and usage errors, 2 user interrupt) plus the stdout/stderr split, where a developer with newly-red CI will actually look.
Review of #103 found the guard trustworthy for the case it was written for and quietly wrong outside it. Rule 3 (new): root's dependency list must cover every published package. Rule 1 only validated the floors already listed, so a package that release-please versions and publishes but that nobody added to root's dependencies was invisible -- `pip install --upgrade deepctl` never installs it at all. That is the same delivery gap #100/#102 were about, through the one door the guard left open, and the repo adds command packages regularly. NOT_SHIPPED carries the two deliberate exclusions so the intent is stated in the diff rather than inferred from an omission. --fix no longer reports success after failing. The rewrite matched the literal `"name>=X.Y.Z"` including both quotes, so it silently no-opped on any spec with an upper bound, extra, or environment marker -- and the fix branch never recorded the miss, so the script printed "dependency floors OK" and exited 0 on a file it had not touched. It now rewrites the version inside the matched spec (preserving the rest) and falls through to `problems` when it cannot, which also puts the previously-unused third element of the floors() tuple to work. Rule 3 backstops this: a spec form the regex cannot parse at all now surfaces as a missing root dependency instead of being skipped. vkey() no longer dies on PEP 440 suffixes. A single hand-set 0.4.0rc1 anywhere in the workspace turned `make floors-check` into a bare ValueError traceback naming no package.
The exit-code table added to the README in this branch says declining a confirmation prompt exits 2, and seven commands honour that by returning status="cancelled", which BaseCommand.EXIT_CODES maps. `dg plugin remove` did not: _handle_remove is a group subcommand returning None, so there is no result to map, and its bare `return` on decline exited 0 -- indistinguishable from a successful removal for any script branching on the exit code. Raise click.Abort() instead, which main.py already turns into the documented 2 for user cancellation. Two tests: the declined path aborts and never calls remove_plugin, and --yes still skips the prompt without aborting.
verify-published treated a successful `pip install --dry-run` as proof the release is installable. --dry-run stops after resolution and is satisfied by PyPI metadata -- often via PEP 658, without fetching a single wheel -- so a corrupt artifact or an entry point that cannot import passes it. The build job smoke-tests `deepctl --version`, but against local dist/ artifacts, so nothing checked what PyPI actually serves before three jobs advertise it. Keep the retry loop on --dry-run (cheap, and it is what distinguishes "not propagated yet" from "broken"), then do a real install and run the CLI once it resolves. Also name the recovery path in the timeout message, since the dependents re-evaluate when the job is re-run. Add a concurrency group to sync-release-pr: it pushes to the release branch, so two commits landing on main in quick succession ran two of them against the same branch and the loser failed on a non-fast-forward push.
Two precision fixes to the table added earlier in this branch.
"Errors and status messages go to stderr" was imprecise: the split is between
human-readable diagnostics (stderr) and the structured result, success or
failure (stdout). `dg --badflag` puts its message on stderr with empty
stdout, but `dg transcribe /nonexistent.wav` puts {"status": "error", ...} on
stdout with empty stderr. The promise that matters -- `-o json | jq` stays
parseable on failure -- was already true; say where the reason actually is.
Note that 2 deviates from the shell's conventional 130 for an interrupt. The
reasoning lived only in a comment in base_command.py, so a CI author trapping
130 would write a condition that never fires.
The lint targets scoped to src/ and packages/**/src, so nothing checked scripts/ -- including check_dependency_floors.py, which the release pipeline now depends on for delivery correctness and which will drift. Cleaning scripts/ to pass: build_standalone.py drops an unused `os` import and probes for PyInstaller with importlib.util.find_spec instead of an unused import. The rest is `ruff format` output, mostly in generate_readmes.py; verified semantically inert by comparing token streams before and after -- the only difference is adjacent string literals being joined, which Python does at compile time anyway.
main currently has no required status checks at all -- the ruleset carries only deletion and non_fast_forward -- so the floor guard added in this branch is advisory, and the "unmergeable instead of memorable" claim does not hold as configured. Requiring the matrix contexts directly would mean editing repo settings every time a Python version or OS moves, and a required context that stops reporting blocks every merge until someone notices. This rollup needs test, lint, floors and build-test, so branch protection needs exactly one context. `if: always()` is load-bearing: without it the job is skipped when a prerequisite fails, and a skipped required check never reports failure, it just stalls. Run always and fail on anything that is not success.
The poll exits the moment resolution succeeds, so the ceiling only costs anything when it is hit -- which makes a higher number pure insurance with no happy-path cost. Propagation is normally seconds, but a timeout strands a published release unannounced until someone re-runs the job, and there is no reason to run that close to the edge.
…ifest sync-release-pr pushes with GITHUB_TOKEN, which by design retriggers nothing, so a release PR shows checks from the bot's first commit and sits red on content that is now correct. The fix is a runbook step, not infrastructure: land release-notes edits as a commit on the branch (retriggers) rather than a PR description edit (fires pull_request: edited, outside the default trigger types). This matters more once required checks land -- a check that is routinely red on the PR type that matters most trains override habits. Also state why the root pyproject dependency in "Adding a New Command" is load-bearing rather than bookkeeping, and what verify-published does when it times out.
read_text()/write_text() without an explicit encoding use the locale codec, which is cp1252 on Windows. A single non-ASCII character in any package description or author name would then crash `make floors-check` with a UnicodeDecodeError for a developer on Windows. Latent rather than live -- the floors CI job runs on ubuntu-latest and no pyproject currently holds a non-ASCII byte -- but the same omission in a test added on the stacked branch did fail the Windows matrix, so fix the pattern here too rather than waiting for someone to add an em-dash.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The five follow-ups from the 0.3.0 release review, in one PR. Everything the release taught us, made structural.
What's here
1. Plugin-loader errors → stderr (
fix(core))The loader printed load failures through a stdout
Console, so one broken plugin corrupteddg ... -o jsonfor every remaining command — the amplifier in the core-floor incident. Now uses the shared stderr console; two tests pin the console identity and the stream split.2. Historical floor debt closed (
fix(deps))0.3.0 fixed floors for the 17 packages publishing that day; 13 packages from earlier cycles (transcribe, the debug family, shared-utils, …) still had floors as low as
>=0.0.1— the same delivery gap, dormant. All root floors now equal workspace versions. Every pinned version verified live on PyPI.3. Floor guard in CI (
ci)scripts/check_dependency_floors.pyenforces: root floors == workspace versions (the delivery manifest — pip'sonly-if-neededstrategy delivers nothing a floor doesn't force), and sub-package floors ≤ sibling versions (satisfiability).make floors-check/make floors-fixlocally, afloorsjob on every PR. Third occurrence of hand-maintained floors going stale (#92, #102, this sweep) — now unmergeable instead of memorable.4. Release-PR auto-sync (
chore(release))release-please bumps versions but updates neither
uv.lock(→uv sync --lockedfailed every CI job, two releases running) nor root floors (→ the #102 delivery gap). Async-release-prjob now regenerates both on the release branch immediately after release-please pushes it. Known caveat documented in the workflow: the GITHUB_TOKEN push doesn't retrigger PR checks; the routine hand-edit of release notes does.5. Post-publish resolvability gate (
chore(release))verify-publishedpollspip install --dry-run deepctl==Xin a clean venv until the full dependency closure resolves from PyPI;mark-latest,deploy-web, and the brew bump now wait for it. Motivated twice: 0.2.27 published partially and pip silently backtracked to the previous version with exit 0, and the 0.3.0 rollout showed the same backtracking live during the CDN propagation window. The brew job's poll checks only the root JSON endpoint; this exercises the actual resolver.6. Exit codes in the README (
docs)The changelog and
⚠ BREAKING CHANGESpointed at a contract documented only inweb/public/llms-full.txt. The CI/Automation section now carries the table (0 / 1 incl. crashes and usage errors / 2 interrupt) and the stdout/stderr split.Verification
make floors-check: OK after the sweep; correctly failed with 13 findings before ituv lock --check: clean (floor edits don't invalidate the lock)pip install --dry-run deepctl==0.3.0still resolves from live PyPI with the new floorsverify-publishedgates all three advertise jobs;sync-release-prkeyed offprs_created/proutputs🤖 Generated with Claude Code