Skip to content

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
mainfrom
chore/release-hardening
Open

chore: harden the release pipeline — floor guard, release-PR auto-sync, publish gate, stderr fix, exit-code docs#103
GregHolmes wants to merge 15 commits into
mainfrom
chore/release-hardening

Conversation

@GregHolmes

Copy link
Copy Markdown
Contributor

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 corrupted dg ... -o json for 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.py enforces: root floors == workspace versions (the delivery manifest — pip's only-if-needed strategy delivers nothing a floor doesn't force), and sub-package floors ≤ sibling versions (satisfiability). make floors-check / make floors-fix locally, a floors job 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 --locked failed every CI job, two releases running) nor root floors (→ the #102 delivery gap). A sync-release-pr job 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-published polls pip install --dry-run deepctl==X in 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 CHANGES pointed at a contract documented only in web/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

  • Full suite: 1096 passed, 6 skipped (4 new tests)
  • ruff format + check, mypy: clean
  • make floors-check: OK after the sweep; correctly failed with 13 findings before it
  • uv lock --check: clean (floor edits don't invalidate the lock)
  • pip install --dry-run deepctl==0.3.0 still resolves from live PyPI with the new floors
  • Workflow YAML parses; job wiring: verify-published gates all three advertise jobs; sync-release-pr keyed off prs_created/pr outputs

🤖 Generated with Claude Code

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.
Comment thread .github/workflows/test.yml Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants