diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e3dae..29d4ce0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,11 @@ name: CI on: push: branches: [main] + # ready_for_review is listed because draft PRs skip the test matrix: it runs + # once, when the PR is marked ready, not on every push while it is a draft. + # Lint still runs on drafts (see the lint job). pull_request: + types: [opened, synchronize, reopened, ready_for_review] # Weekly drift detector: firmware, the Printables GraphQL API, OrcaSlicer # flags and new CVEs (pip-audit) all move without a commit in this repo. schedule: @@ -23,14 +27,61 @@ permissions: contents: read jobs: + # A squash merge whose tree is byte-identical to the PR head it came from + # was already tested: the ruleset only lets a PR merge once all six required + # checks passed on that head. Re-running the matrix on main would test the + # same files again. Any other push (a merge onto a main that moved, a commit + # without "(#N)") is tested. Only branch pushes are checked; release.yml + # calls this workflow from a tag push, and the release gate always runs. + # Uses git alone (contents: read), since release.yml grants nothing more. + merged: + name: Skip if this merge was already tested + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + already-tested: ${{ steps.compare.outputs.already-tested }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - id: compare + env: + MESSAGE: ${{ github.event.head_commit.message }} + run: | + pr=$(printf '%s\n' "$MESSAGE" | head -n 1 | sed -nE 's/.*\(#([0-9]+)\)$/\1/p') + if [ -z "$pr" ]; then + echo "No (#N) in the commit subject: testing." + exit 0 + fi + if ! git fetch --no-tags --depth=1 origin "refs/pull/$pr/head"; then + echo "Could not fetch PR #$pr: testing." + exit 0 + fi + merged_tree=$(git rev-parse 'HEAD^{tree}') || merged_tree="" + tested_tree=$(git rev-parse 'FETCH_HEAD^{tree}') || tested_tree="" + if [ -n "$merged_tree" ] && [ "$merged_tree" = "$tested_tree" ]; then + echo "Same tree as PR #$pr's head, which passed the required checks: skipping." + echo "already-tested=true" >> "$GITHUB_OUTPUT" + else + echo "Tree differs from PR #$pr's head (main moved under it): testing." + fi + test: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + needs: merged + # Runs unless the PR is a draft or the merge was already tested. A failed + # or skipped "merged" job leaves the output empty, which means test. A + # skipped matrix job reports its unexpanded name, so a draft never shows + # the six required check names as passed. + if: >- + ${{ !cancelled() + && !(github.event_name == 'pull_request' && github.event.pull_request.draft) + && needs.merged.outputs.already-tested != 'true' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - # Full Python coverage on cheap Linux runners; one smoke job each on - # macOS (10x minute multiplier) and Windows (2x) at the newest Python. + # Full Python coverage on Linux; one job each on macOS and Windows at + # the newest Python. Those runners start slower (and bill at 10x / 2x + # if the repo is ever private; public-repo runners are free). os: [ubuntu-latest] python-version: ["3.10", "3.12", "3.14"] include: @@ -132,6 +183,11 @@ jobs: lint: name: Lint (ruff + bandit) + needs: merged + # Not skipped on drafts: a skipped job reports success under this fixed + # name, which is a required check, and could sit beside a real failure on + # the same commit. Lint is one quick Linux job; the matrix is the cost. + if: ${{ !cancelled() && needs.merged.outputs.already-tested != 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 4998038..1c597be 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -5,7 +5,7 @@ name: Mutation baseline on: workflow_dispatch: schedule: - # 06:15 UTC daily + # 06:15 UTC daily; the "changed" job skips the run when main has not moved. - cron: "15 6 * * *" concurrency: @@ -16,8 +16,43 @@ permissions: contents: read jobs: + # The nightly run re-tests main only when main changed since the last green + # baseline: the same commit gives the same score, so most nights there is + # nothing to do. A manual run always runs. A failed baseline keeps running + # nightly until one passes. + changed: + name: Main changed since the last green baseline + runs-on: ubuntu-latest + permissions: + actions: read + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - id: check + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + SHA: ${{ github.sha }} + run: | + if [ "$EVENT" != "schedule" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + last=$(gh run list --workflow mutation.yml --branch main --status success --limit 1 --json headSha --jq '.[0].headSha // ""') || last="" + if [ -n "$last" ] && [ "$last" = "$SHA" ]; then + echo "main is still $SHA, which already has a green baseline: skipping." + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "main is $SHA; last green baseline was ${last:-unknown}: running." + echo "run=true" >> "$GITHUB_OUTPUT" + fi + mutation: name: Safety-module mutation baseline + needs: changed + # A failed "changed" job leaves the output empty, which means run. + if: ${{ !cancelled() && needs.changed.outputs.run != 'false' }} runs-on: ubuntu-latest timeout-minutes: 120 steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a65a5d4..b861b66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ that CI's clean checkout does not have — expected locally, not a failure to ch `-m "not live"` never runs it. See [docs/live-printer-smoke.md](docs/live-printer-smoke.md). Never run against a printer mid-print; print start needs `BAMBU_LIVE_PRINT_CONFIRM`. - **Mutation baseline** (safety modules): `./scripts/run_mutation_baseline.sh` — - [docs/mutation-baseline.md](docs/mutation-baseline.md). Nightly CI only, not every PR. + [docs/mutation-baseline.md](docs/mutation-baseline.md). Nightly CI only (when `main` has changed), not every PR. - CI enforces a "no test-awareness in production code" rule: production modules must not branch on `Mock` / `unittest in sys.modules`. Prefer injecting collaborators over patching module globals. Domain code raises `BambuError` / `abort`; `sys.exit` only in @@ -136,6 +136,18 @@ scoreboard for what is actually ticked. - Follow `docs/quality-roadmap.md` and `docs/test-backlog.md` when adding tests. - Do not add Claude-Session or similar trailers to commits or PRs. +## Pull requests and CI + +- **Open a PR as a draft while you are still pushing to it** (`gh pr create --draft`). + A draft runs only the lint job; the test matrix (Linux, macOS, Windows) runs once when the + PR is marked ready (`gh pr ready`), so intermediate pushes don't each start it. A draft + cannot be merged, so nothing lands untested. +- After a squash merge, CI on `main` runs only if the merged files differ from the PR's + last tested commit, which happens when `main` moved while the PR was open. A tag push + (a release) always runs the full matrix. +- The weekly scheduled run still tests `main` with nothing changed, to catch firmware, + OrcaSlicer, Printables and CVE drift. + ## Releases (maintainers) Full procedure, including the **yank/rollback runbook**: [docs/releasing.md](docs/releasing.md). diff --git a/README.md b/README.md index 093f14b..bbb7bbb 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ The numbers in the badges are measured on this repo, not aspirational: | **Coverage** | **93.5%** line+branch across the package; CI fails the build below **90%** | | **Platforms** | Linux, macOS, and Windows in CI, on Python 3.10 / 3.12 / 3.14 | | **Blocking gates** | `ruff`, `ruff format`, `mypy` (whole package, `check_untyped_defs`), `bandit`, `pip-audit`, plus layer-boundary and JSON-schema-drift checks | -| **Mutation testing** | Nightly, scoped to the safety-critical modules (download validation, SSRF guards, slicer options, job payload) | +| **Mutation testing** | Nightly whenever `main` has changed, scoped to the safety-critical modules (download validation, SSRF guards, slicer options, job payload) | | **Packaging** | Every CI run installs the built wheel *and* the sdist and runs the CLI from them | Coverage is deliberately honest — `except Exception` / `pass` / `continue` are **not** diff --git a/docs/MASTER-PLAN.md b/docs/MASTER-PLAN.md index 14bd6d7..fccb7be 100644 --- a/docs/MASTER-PLAN.md +++ b/docs/MASTER-PLAN.md @@ -12,13 +12,8 @@ The work we chose, in order. **No task from the user? Take the top 🤖 box unde - [ ] **D-2 · Dependabot PRs #150 (actions) and #152 (python deps)** · P2 · 🧑 Open since 2026-10-26 and 2026-11-02. *Done when:* merged or closed. -- [ ] **D-3 · Keep `"bambu"` in the PyPI keywords?** · P2 · 🧑 - Unruled since 2026-07-25; recommendation was keep (nominative search term, not a name). -- [ ] **L-1 · Post to r/BambuLab** · P1 · both · after L-2 +- [ ] **L-1 · Post to r/BambuLab** · P1 · both 🤖 rewrites the held copy to lead with `plate tui`/`plate go`; 🧑 posts (Tue–Thu, 10–11 EDT). -- [ ] **L-2 · Cut 0.6.0** · P1 · both - Release PR on `release/0.6.0` (2026-11-06) sets 0.6.0 and dates the changelog. 🧑 merges it and approves the `v0.6.0` tag push (it publishes to PyPI). - Then 🤖: `pip install platecli==0.6.0` and `'platecli[tui]==0.6.0'` in a clean venv (`plate --version`, `plate --sim status`), then the `0.7.0.dev0` bump PR (`docs/releasing.md` steps 4 and 6). ## Next @@ -29,4 +24,4 @@ The work we chose, in order. **No task from the user? Take the top 🤖 box unde Nothing chosen yet; new findings go in `docs/plan/inbox.md`. -**Highest ids given:** D-3 · L-4 · S-3 · retired: D-1, L-3, S-1, S-2, S-3 +**Highest ids given:** D-3 · L-4 · S-3 · retired: D-1, D-3, L-2, L-3, S-1, S-2, S-3 diff --git a/docs/mutation-baseline.md b/docs/mutation-baseline.md index 365430d..300a3ad 100644 --- a/docs/mutation-baseline.md +++ b/docs/mutation-baseline.md @@ -4,7 +4,7 @@ **Current measurement:** 2026-08-04 — **50.7%**, floor raised 40 → **48** (re-run before changing the floor again) **Tool:** mutmut 3.6.0 **Reproduce:** `./scripts/run_mutation_baseline.sh` -**CI:** `.github/workflows/mutation.yml` — `workflow_dispatch` + nightly `schedule` only +**CI:** `.github/workflows/mutation.yml` — `workflow_dispatch` + nightly `schedule` only; the nightly run is skipped when `main` has not changed since the last green baseline (**not** on every `pull_request`; full runs are minutes-long and would slow PR feedback) Related: [quality-roadmap.md](quality-roadmap.md), [test-backlog.md](test-backlog.md). diff --git a/docs/plan/done.md b/docs/plan/done.md index 697b47b..5474574 100644 --- a/docs/plan/done.md +++ b/docs/plan/done.md @@ -2,6 +2,8 @@ One line per finished plan box, newest first: `- date · id · what happened · commit`. +- 2026-11-06 · L-2 · 0.6.0 released: PR #154 merged, tag `v0.6.0` → `b0ef746`, and release.yml passed (CI matrix, build, PyPI, GitHub Release). `platecli==0.6.0` and `[tui]` (textual 8.2.8) installed from PyPI in clean Python 3.12 venvs; `plate --version` and `plate --sim status` passed. `plate tui` was not run in a real terminal, and the live-printer smoke was not run. Main is bumped to 0.7.0.dev0 · this commit +- 2026-11-06 · D-3 · ruled: keep `"bambu"` in the keywords and prose, never in the product name (`docs/plan/settled.md`) · this commit - 2026-11-06 · L-3 · closed: folded into L-2, which checks the 0.6.0 install from PyPI (a 0.5.1 check is stale once 0.6.0 ships) · this commit - 2026-11-06 · D-1 · PR #153 (audit round 4) squash-merged after all 6 checks passed on its last commit · e925f96 - 2026-11-05 · S-3 · setup shows a lone mDNS result and asks (no → manual entry); unspecified/multicast addresses are skipped, public/loopback ones need an explicit yes, also when picked from several · this commit diff --git a/docs/plan/settled.md b/docs/plan/settled.md new file mode 100644 index 0000000..3e0f0b2 --- /dev/null +++ b/docs/plan/settled.md @@ -0,0 +1,5 @@ +# Settled + +The user's answered decisions, newest first. Read before reopening one. + +- 2026-11-06 · D-3 · **"Bambu" in names vs. prose.** "Bambu" is fine wherever it describes the printers: PyPI keywords, "for Bambu Lab printers", docs. It never goes in the product's name or title ("bambu cli", "bambu unofficial cli" and the like). Adding "unofficial" doesn't fix that, because the trademark is still in the name. The PyPI description already uses the safe form, "local CLI for Bambu Lab printers (not affiliated with Bambu Lab)". diff --git a/docs/releasing.md b/docs/releasing.md index 05cfa36..37aa227 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,6 +9,8 @@ ruleset's required checks to match, or PRs will block forever waiting on a check that no longer reports. - The commit you are about to tag is **green on CI** (`gh run list --branch main --limit 1`). + A run whose test jobs were skipped because "this merge was already tested" counts: the + same tree passed the required checks on the PR. The tag push re-runs the matrix anyway. Tags must only ever point at green commits: `release.yml` re-runs the full CI matrix via `uses: ./.github/workflows/ci.yml` before build/publish, so a red commit fails the release rather than shipping — but do not rely on that as your first line of defence. diff --git a/pyproject.toml b/pyproject.toml index 52d098d..ca20278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "platecli" # Unreleased work carries a .devN suffix so a source build is never mistaken # for the released wheel of the same number. Drop the suffix when tagging. -version = "0.6.0" +version = "0.7.0.dev0" description = "platecli — local CLI for Bambu Lab printers (not affiliated with Bambu Lab)" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 5e9a277..ccbeae0 100644 --- a/uv.lock +++ b/uv.lock @@ -431,7 +431,7 @@ wheels = [ [[package]] name = "platecli" -version = "0.6.0" +version = "0.7.0.dev0" source = { editable = "." } dependencies = [ { name = "paho-mqtt" },