From 2fd889b81782148940d29c3a7dd3a45c3e242518 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:21:41 +0200 Subject: [PATCH 01/12] Shrink README to a quick-start pitch, rename the action for v2.0.0 The README had grown into a wall of text covering every feature in full detail. Cut it to a title, three-bullet pitch, a copy-pasteable uv quick start, a tiny report example, and links out - full reference/tutorial content moves to docs/ in the next commits. Also renames the action itself ("Test-gated Python updates") ahead of the friedrichwilken/test-gated-python-updates rename and v2.0.0 release, and updates the one hardcoded repo link in the create-issues managed-issue footer to match. Co-Authored-By: Claude Fable 5.1 --- README.md | 348 ++++----------------------------------- action.yml | 18 +- updater/github_issues.py | 2 +- 3 files changed, 40 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 09d5c5f..e28c2c5 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,12 @@ -## Update Python Dependencies GitHub Action +# Test-gated Python updates -A GitHub Action to update your Poetry or uv dependencies, that ensures to not break anything by running tests after each package update. +A GitHub Action that updates your Poetry or uv dependencies one package at a time, running your tests after each one, and opens a single pull request with the results. -This GitHub Action is inspired by [gha-poetry-update](https://github.com/fuzzylabs/gha-poetry-update) but heavily modified. It updates your dependencies one top-level package at a time using [Poetry](https://python-poetry.org/) or [uv](https://docs.astral.sh/uv/), runs tests (optionally), and creates a pull request with the results. +- **Tested per package** — only updates that pass your test command are kept; the rest are dropped, not forced on you. +- **One pull request, not fifteen** — reused and updated every run instead of piling up duplicates. +- **Failures are reported, not blocking** — a report table (and, optionally, a tracked issue), never a broken build. -### Features - -1. Supports both Poetry and uv projects; auto-detected from the lock file present, or selected explicitly with `package-manager`. -2. Each updated package is committed separately. -3. Optionally test each update with a custom command — only successful updates are committed. -4. Add labels and PR title prefixes to resulting pull request, to integrate with your CI/CD workflows. -5. Reuses a fixed branch/PR across runs instead of piling up duplicate PRs. -6. `dry-run` mode to see what would happen without pushing anything. - -### Versions - -- **`v2`** (this branch/version) is a breaking Python rewrite with uv support, a new bootstrap (see below), and new inputs/outputs. It is **not released yet**: the `v2`/`v2.0.0` tags only start existing once the maintainer pushes the first `v2.0.0` tag (see [`release.yml`](.github/workflows/release.yml)). Until then, pin to `@main` (moves with the default branch) or, for a reproducible/supply-chain-hardened pin, a full commit SHA — `friedrichwilken/update-poetry-dependencies@ # describe the commit`, the same pattern this repo's own workflows use for third-party actions. -- **`v1`** is the original bash-based action. It is frozen at the `v1`/`v1.0.1` tags and unmaintained — it will not be moved or updated further. If you're still using it, see "Migrating from v1" below. - -### Migrating from v1 - -`v2` is a breaking rewrite: Python instead of bash, `uv`-based bootstrapping, and some input/output changes. To migrate: - -1. Add an explicit `actions/checkout` step before this action in your workflow — v1 checked out the repository itself; v2 does not. -2. Pass your token to **both** `actions/checkout`'s `token:` input and this action's `github_token` input (see "Token and permissions" below) — v1 only needed `github_token`. -3. Check the [Inputs](#inputs) and [Outputs](#outputs) tables against your existing `with:` block — some defaults changed (e.g. `poetry-version` now defaults to a Poetry 2.x release) and `package-manager` is new (for uv support; defaults to `auto`-detecting from the lock file). -4. Drop any `actions/setup-python` / `snok/install-poetry` steps you had before this action — v2's `uv`-based bootstrap replaces them entirely; see "How it bootstraps" below. - -### How it bootstraps - -The action installs [`astral-sh/setup-uv`](https://github.com/astral-sh/setup-uv) and uses `uv` for everything else: it runs itself on a fixed `uv`-managed interpreter, installs the project's `python-version` with `uv python install`, and — for Poetry projects — installs Poetry itself with `uv tool install poetry==` and creates its in-project virtualenv directly with `uv venv`, pinned to that interpreter (Poetry then picks up the existing virtualenv automatically). You do not need `actions/setup-python`, `snok/install-poetry`, or a preinstalled `uv`/`poetry` in your workflow; just check out the repository first. - -Every run rebuilds `.venv` from scratch (`uv venv --clear`), so restoring `.venv` itself from a CI cache does nothing useful. If you want faster syncs, cache `uv`'s own package cache (e.g. `~/.cache/uv` on Linux/macOS, or `actions/cache` with `path: ~/.cache/uv` / the output of `uv cache dir`) instead. - -**Prerequisite: a clean manifest/lock file.** Before doing anything else, the action fails fast if `pyproject.toml` or the lock file in `directory` already have uncommitted changes (staged or not) — the update loop resets and commits exactly these files itself, and running it against a dirty working tree would otherwise either discard that uncommitted work (on a discarded update) or silently absorb it into one of this run's own commits. This is checked unconditionally, not just when `allow-major` is enabled. Commit or stash those changes before this action runs. - -### uv backend: scope and limits - -- Only the `pyproject.toml` in `directory` is read to find top-level dependencies. A `tool.uv.workspace` root's member projects are not iterated, and workspace `uv.lock` files live at the workspace root rather than in an individual member's directory — [uv workspaces](https://docs.astral.sh/uv/concepts/projects/workspaces/) are not supported yet. -- If the project declares [`tool.uv.conflicts`](https://docs.astral.sh/uv/concepts/projects/dependencies/#conflicting-dependencies) (mutually exclusive extras/groups, e.g. a `cpu`/`gpu` extra pair), the default `uv sync --all-groups --all-extras` selection would try to install both sides at once and `uv sync` fails outright. When that is detected and `uv-sync-args` is not set, the action falls back to `uv sync` with no extras and only the default dependency groups, and prints a `::warning::`. Set `uv-sync-args` to choose what actually gets installed, e.g. `uv-sync-args: '--extra cpu --group dev'`. -- If `test-command` itself invokes `uv run` (a common pattern, e.g. `uv run pytest`) with `with-groups`/`without-groups`/`only-groups` also set, be aware that `uv run` re-syncs the environment on its own before running anything, using **uv's own default group/extra selection** - not this action's narrower one (verified against real `uv==0.12.14`: a group this action's own `sync()` correctly excluded gets silently reinstalled for the duration of that `uv run` invocation). This never affects which packages get iterated/reported, only what a test-command that itself re-syncs actually sees installed while it runs. If a test genuinely depends on a group being absent, invoke `uv run --no-sync ` (or the venv's own interpreter directly, e.g. `.venv/bin/python -m pytest`) in `test-command` instead. - -### Inputs - -| Name | Description | Default | Required | -|-------------------|-----------------------------------------------------------------------------------------------|--------------------------------|----------| -| python-version | The Python version to use for the project. | `3.12.14` | no | -| package-manager | Which package manager the project uses: `auto` (detected from the lock file in `directory`), `poetry`, or `uv`. | `auto` | no | -| poetry-version | The Poetry version to use. Only used when the poetry backend is selected. | `2.4.3` | no | -| uv-sync-args | Only used when the uv backend is selected. Overrides the default `--all-groups --all-extras` selection passed to `uv sync` (parsed as shell arguments, e.g. `--extra cpu --group dev`). Needed when the project declares `tool.uv.conflicts`. | `""` | no | -| directory | The directory of the project files. | `./` | no | -| pr-title-prefix | A prefix for the PR title. | `""` | no | -| pr-labels | A comma or newline separated list of labels for the PR. | `""` | no | -| test-command | A command to run tests after each update. Runs in `directory` via `bash -c` (e.g. `pytest` for Poetry, `uv run pytest` for uv). | `""` | no | -| branch-name | Fixed branch name used for the update PR. Force-pushed on every run. | `deps/test-gated-updates` | no | -| base-branch | Base branch for the PR. If the checkout is detached (e.g. `pull_request` events), falls back to `GITHUB_BASE_REF`; if neither is available the action fails fast, before doing any work. | the currently checked out branch | no | -| dry-run | Run the full update loop but skip pushing the branch and creating/updating the PR. | `false` | no | -| allow-major | Opt-in: also attempt an update beyond the declared constraint (raising it) for a package whose constraint would otherwise exclude its latest release, falling back to the plain in-range update if that attempt fails. Despite the name, this is not always a semver-major bump. See "Beyond-constraint update attempts" below. | `false` | no | -| strategy | `per-package` (default): update, test and commit one top-level package at a time. `batch-first`: update every package at once and test once; falls back to the per-package loop on failure. Cuts test runs from N down to close to 1 in the happy path. See "`strategy`: `batch-first`" below. | `per-package` | no | -| create-issues | Opt-in: file one GitHub issue per top-level package that fails (or has a held-back beyond-constraint attempt), kept up to date and closed automatically across runs. Requires `issues: write` on the token. See "`create-issues`: filing issues for failures" below. | `false` | no | -| issue-labels | A comma or newline separated list of labels added to an issue created by `create-issues`. Labels must already exist in the repository - this action never creates one. | `""` | no | -| update-transitive | Opt-in: after the top-level loop (and any `allow-major` beyond-constraint attempts) finish, one final tested step refreshes every dependency - transitive included - still updatable within its existing constraints, committed separately. See "`update-transitive`: refreshing transitive dependencies" below. | `false` | no | -| with-groups | A comma or newline separated list of dependency groups to additionally include when selecting what gets installed/synced (Poetry: its own optional groups; uv: additional groups on top of its own default selection - has no effect on which top-level packages are iterated, see "Dependency group selection" below). `main` names the project's own ungrouped dependencies. May be combined with `without-groups`; mutually exclusive with `only-groups`. | `""` | no | -| without-groups | A comma or newline separated list of dependency groups to exclude. `main` names the project's own ungrouped dependencies (`dev` additionally covers uv's legacy `tool.uv.dev-dependencies`). May be combined with `with-groups`; mutually exclusive with `only-groups`. See "Dependency group selection" below. | `""` | no | -| only-groups | A comma or newline separated list of dependency groups to exclusively include, dropping every other group. Mutually exclusive with `with-groups`/`without-groups`. See "Dependency group selection" below. | `""` | no | -| github_token | GitHub token for PR creation. | | **yes** | - -### Outputs - -| Name | Description | -|-------------------|------------------------------------------------------------------------------| -| passed-packages | Comma separated list of packages that were updated and passed the test command. | -| failed-packages | Comma separated list of packages whose update or test failed and were discarded. | -| skipped-packages | Comma separated list of packages that had nothing to update. | -| held-back-packages | Comma separated list of packages where an update beyond the declared constraint was attempted (`allow-major`) but held back; see "Beyond-constraint update attempts" below. | -| pr-body | The rendered report / PR body. | -| report-json | JSON array of per-package records; see "Report" below for the field reference. | -| issue-actions | JSON array of planned/performed `create-issues` actions, one object per affected package: `{package, action, issue}`, plus an additive `error` field when that specific action's own `gh` call failed. `action` is `create`, `update`, or `close`; `issue` is the existing/created issue number, or `null` for a not-yet-created issue (always `null` in `dry-run`, since nothing is actually created, and also `null` for a failed create). `[]` when `create-issues` is not enabled. See "`create-issues`: filing issues for failures" below. | -| transitive-report | A single JSON object reporting the `update-transitive` step: `{status, changed_packages, failure_kind, output_tail}`. The JSON literal `null` when `update-transitive` is not enabled, or the run aborted before the step ran. See "`update-transitive`: refreshing transitive dependencies" below. | - -The same report is also written to the job summary (`GITHUB_STEP_SUMMARY`), including in `dry-run`. - -### Report - -The PR body / job summary reports, per package: for an update, the old and new locked version; for a failure, the current and attempted version, whether it was a dependency-resolution failure or a test failure, and a collapsed block with the tail of the relevant output (resolver output for a resolution failure, test command output for a test failure). Package names, versions and failure reasons are escaped so they can never break the report's table formatting or be interpreted as markup. - -The report is rendered under an explicit character budget: well under GitHub's 65536 character PR body limit for `pr-body`, and a larger (but still bounded, ~900000 character) budget for the job summary, so the job summary can carry more detail than the PR body for the same run. At either size, table rows and per-package output blocks are dropped (with a "N more, see `report-json`/job summary" note) as needed to stay under the budget - the guarantee holds regardless of how many packages or how much output there is. - -If the update loop has to abort early (currently only when re-syncing the environment after a discarded update itself fails), the report still covers everything processed before the abort - already-made commits for packages that passed stay made locally, but the run is not pushed and no PR is created/edited - and starts with a "Run aborted: ``" banner. - -`report-json` carries the same per-package data as a stable, machine-readable array, one object per top-level package. Its total size is capped independently (~256 KiB): if needed, `output_tail` is dropped (in favor of `output_truncated: true`) from the packages with the largest captured output first, until it fits - every package still gets a record. - -| Field | Type | Description | -|-------------------|-----------------|-------------------------------------------------------------------------------| -| name | string | The top-level package name. | -| status | string | One of `updated`, `failed`, `skipped`. | -| old_version | string \| null | The version locked before this run touched the package, or `null` if unknown. | -| new_version | string \| null | For `updated`/`skipped`: the resulting locked version. For `failed`: the version that was attempted before the change was reverted. `null` if unknown. | -| failure_kind | string \| null | `resolution` (the update/lock step itself failed), `test` (the test command failed), or `null` for a non-failure. | -| output_tail | string | Tail of the relevant captured output for a failure (empty otherwise), ANSI escape codes stripped. | -| output_truncated | boolean | Only present (`true`) when `output_tail` was dropped to keep `report-json` under its size cap; absent otherwise. | -| bump | string | Only present on an `updated` outcome produced while `allow-major` is enabled: the actual release segment that changed between `old_version` and `new_version` - `major`, `minor`, `patch`, or `other` (see below) - regardless of whether this was an in-range update or one that raised the constraint. | -| constraint_raised | boolean | Only present (`true`) when this outcome's own commit raised the declared constraint itself, as opposed to a plain in-range update (which never touches the manifest). | -| beyond_constraint_version | string \| null | Only present when an update beyond the declared constraint was attempted for this package but held back (see "Beyond-constraint update attempts" below): the version the attempt tried. | -| beyond_constraint_failure_kind | string \| null | Only present alongside `beyond_constraint_version`: `resolution` or `test`, same meaning as `failure_kind` but for the held-back attempt. | -| beyond_constraint_output_tail | string | Only present alongside `beyond_constraint_version`: tail of the held-back attempt's captured output. | -| beyond_constraint_output_truncated | boolean | Only present (`true`) when `output_tail`/`beyond_constraint_output_tail` were dropped together to keep `report-json` under its size cap. | -| beyond_constraint_skip_reason | string | Only present when `allow-major` is enabled but no attempt to go beyond the declared constraint could be made for this package at all (e.g. a git/path dependency, an exact pin, an environment marker, an unparsable constraint, or an otherwise-successful attempt that had to be discarded). | -| strategy | string | Only present (`"batch-first"`) when `strategy: batch-first` was requested for this run - on every outcome it produced, whichever path actually produced it (the batch's own test, a post-divergence verification test, or a per-package fallback). See "`strategy`: `batch-first`" below. | -| tested_in_batch | boolean | Only present (`true`) on an outcome whose committed update was validated by one shared test run covering every package at once, rather than its own dedicated per-package test run. | -| batch_test_failed | boolean | Only present (`true`) on every outcome when `strategy: batch-first`'s own one-shot batch test failed and this run fell back to the per-package loop for everything. | -| bundled_with | string | Only present on a `batch-first` outcome whose own sequential-replay step produced no lock change of its own because it was already sitting at the batch's target version - pulled there as a side effect of another package's own update in the same replay (most commonly a shared transitive dependency, e.g. updating one package already pulls in the exact version another one needed too). Names that other package; no separate commit exists for this one. | - -### Beyond-constraint update attempts (`allow-major`) - -`allow-major` (default `false`) is an opt-in, per-package extension of the same test-gated flow: for a package whose *declared constraint* has an effective upper bound (a caret/tilde/wildcard/`~=` Poetry constraint, or a PEP 508 specifier with `<`/`<=`/`~=`/`==x.*`), it is not enough to know a newer release exists - the ordinary in-range update never looks past that bound. Despite the input's name, going beyond a declared constraint is not necessarily a semver-major jump (`six >=1.10,<1.15` allowing `1.17.0` is a minor bump that merely exceeded the declared range) - `bump` always reports the real release segment that changed, computed from the actual version numbers, never assumed from the fact that a constraint was raised. When an effective upper bound is found, before the plain in-range update: - -1. **Attempt:** raise the declared constraint so the latest release is allowed (`poetry add "pkg[extras]@latest" --group ` / `--optional `, or for uv, rewrite just that requirement's specifier and re-lock with `uv add "pkg[extras]>=" --upgrade-package pkg`), then test exactly like an in-range update. If it passes, both `pyproject.toml` and the lock file are committed together (`Update -> (constraint raised)`), tagged `constraint_raised: true` with `bump` set to the real delta, and the package is done - no separate in-range update runs for it. -2. **Fallback:** if raising the constraint fails to resolve, resolves but fails the test command, or resolves and passes but has to be discarded (see below), every touched file (manifest *and* lock) is reset and the environment re-synced, and the plain in-range update (today's behavior) runs instead. Whichever outcome that produces - updated, failed, or "no update available" - also records the held-back attempt (`beyond_constraint_version`/`beyond_constraint_failure_kind`/`beyond_constraint_output_tail` in `report-json`; a `held-back-packages` output entry either way) or the discard reason (`beyond_constraint_skip_reason`) rather than a `failure_kind` - the tool itself did not fail, this action decided not to trust what it did. -3. If the in-range update fails too, the package is `failed` exactly as it would be without `allow-major` - the held-back attempt's details are kept alongside it. - -A package whose constraint has no effective upper bound needs no separate attempt at all - the in-range update already reaches the latest release - and is never double-tested. - -Not every declaration shape can be safely rewritten without risking silently dropping information (an extra, a marker, an environment-specific source, ...). These are always skipped rather than guessed at, and reported via `beyond_constraint_skip_reason` (plus a compact line in the job summary - not the PR body, to keep it free of noise) rather than silently ignored: - -- a git/path/url/workspace dependency, or a direct URL reference (`pkg @ ...`) -- more than one constraint entry for the package (e.g. per-Python-version marker-scoped table entries), or a Poetry `||` OR constraint where every alternative already has its own upper bound (unbounded if *any* alternative is open-ended - the union is then already unbounded and no attempt is needed) -- a dependency carrying `markers`/`python`/`platform`/`source`/`allow-prereleases` keys this feature cannot faithfully preserve -- an exact version pin (`==1.2.3`, or Poetry's bare `1.2.3`) - pinned on purpose, never touched -- a constraint/specifier this action's PEP 440-ish parser cannot parse (including a version literal that fails to parse even when the operator syntax looks fine, e.g. `^abc`) -- a legacy Poetry `optional = true` dependency not listed in any `[tool.poetry.extras]` entry (there is no extra name to pass `poetry add --optional` for it) -- `python` itself - -After an attempt's tool call succeeds, two more checks can still discard it (same effect as a failure: reset and fall back to the in-range update, reported via `beyond_constraint_skip_reason`, not `failure_kind`): - -- the manifest is re-read and diffed against the original declaration; if anything other than the version constraint changed (extras dropped, moved to a different table, a marker appeared, ...), the change is discarded rather than kept - this action never keeps a change it cannot fully account for; -- if the attempted version is a pre-release and the original was not, it is discarded - both Poetry and uv are expected to already exclude pre-releases by default, but this action never relies on that silently. - -The PR body gets a new "⚠️ Held back (update beyond declared constraint failed)" table (package, current, attempted, reason) with the same collapsed per-package output blocks as the "Failed" section, and the "✅ Updated" table gains a `bump` column (with a "(raised)" suffix on a row where the constraint itself was rewritten) once at least one package in the run used it. All of this is additive: with `allow-major` left at its default `false`, the rendered report and `report-json` are byte-identical to before this feature existed. - -**Prerequisite:** like the lock file, `pyproject.toml` must have no uncommitted changes before this action runs (see the "Prerequisite: a clean manifest/lock file" note above) - checked regardless of whether `allow-major` is enabled. - -### `strategy`: `batch-first` - -`strategy` (default `per-package`) picks how the update loop spends test runs. With N updatable top-level packages, `per-package` (today's behavior, unchanged) costs N test runs - one per package. `strategy: batch-first` (issue #23) is an opt-in alternative that costs only 1 in the happy path: - -1. **Batch:** update every top-level package at once (`poetry update ` / `uv lock --upgrade-package ...` repeated once per package plus one `uv sync`) - in-range only; see "Interaction with `allow-major`" below. If nothing changed in the lock, every package is reported `skipped` and nothing is tested. -2. **Test once**, against the whole batch. - - **Fails:** reset the lock/manifest back to the state before the batch ran, re-sync, and run the ordinary `per-package` loop for every package instead - worst case N+1 test runs total, same result the `per-package` strategy would have produced. `report-json` marks every outcome `batch_test_failed: true`, and the job summary gets a "Batch update failed tests, fell back to per-package" line. - - **Passes:** reset to the state before the batch again, then replay each changed package's own update and commit it on its own, in sequence, *without* testing in between - so the git history and PR report look exactly like a `per-package` run would have produced, just without paying for N-1 extra test runs. Once every package has been replayed, its result is compared against the batch's own lock file (every package's locked version, not just the top-level ones) - a resolver can be order-sensitive for transitive dependencies, so a package-by-package replay is not strictly guaranteed to reproduce the exact same lock the all-at-once batch update did: - - **Matches:** done. Every replayed package is reported `updated` with `tested_in_batch: true` (validated by the one batch test run, not its own). A package's own commit message and reported version always come from a fresh read right after its own replay step, never the batch's precomputed value, which a resolver can already have made stale by the time that step actually runs. A package that produces no lock change of its own because an *earlier* package's own replay step already pulled it to the batch's target version (a shared transitive dependency is the common case) needs no separate commit at all - it is still reported `updated`, tagged `bundled_with: ""` naming whichever commit the change actually lives in. - - **Diverges:** one more test run, against the diverged sequential result. Passes -> keep it (still `tested_in_batch: true` - validated by this one verification run covering everything, still nowhere near N runs). Fails -> discard every replay commit and fall back to the `per-package` loop for everything instead (this path does not set `batch_test_failed` - the batch's own test genuinely passed; it is the replay that could not be trusted). A replay step whose own update outright fails, or that produces neither a lock change nor lands on the batch's own target version for that package, is always a real failure (never excused as "bundled") and skips this verification grace period entirely - there is nothing coherent left to verify. - -A re-sync failure at any point aborts the whole run with whatever partial result exists so far, exactly like `per-package` (see "Report" above). - -**Interaction with `allow-major`:** the batch step itself only ever performs in-range updates, even when `allow-major` is enabled - keeping the batch itself simple and predictable. Once the batch (or its replay) has landed, `allow-major`'s beyond-constraint attempts still run exactly as they do without `batch-first`: one at a time, per package, each with its own test run. A package that both got an in-range update from the batch *and* has a further beyond-constraint attempt available ends up with two commits (the batch-replay's in-range commit, then the beyond-constraint commit on top) instead of one, but is still reported as a single outcome spanning the whole journey (`old_version` from before the batch ran, `new_version`/`bump`/`constraint_raised` from the beyond-constraint attempt). In short: `batch-first` only ever saves test runs on the in-range portion of the work; `allow-major`'s own test-per-attempt cost is unchanged either way. - -`report-json` marks every outcome of a `batch-first` run with `strategy: "batch-first"` (whichever path actually produced it, including a fallback to `per-package`), additionally to `tested_in_batch`/`batch_test_failed`/`bundled_with` above. All of this is additive: with `strategy` left at its default `per-package`, the rendered report, job summary and `report-json` are byte-identical to before this feature existed. - -### `create-issues`: filing issues for failures - -`create-issues` (default `false`) is an opt-in extension that hands failures off to a durable, trackable GitHub issue instead of (or in addition to) the PR report, which only ever reflects the latest run. It runs after the PR is created/edited (so the issue can link to it) and requires `issues: write` on the token (see "Token and permissions" below). - -**One issue per package, never per run.** An issue is filed for every top-level package whose outcome this run is `failed`, or that has a held-back beyond-constraint attempt (`beyond_constraint_failure_kind` set - see "Beyond-constraint update attempts" above); a package that is both (the beyond-constraint attempt *and* the in-range fallback both failed) gets one issue about the plain failure, not two. - -**Identity requires all three** of a hidden marker in the issue body, `` (`` is the PEP 503 normalized package name) - **never the title**, which is free to change between runs and is never matched on; a second hidden marker, ``, which records what the last run reported, so the action can tell whether anything actually changed without an extra `gh` call; and a footer line stating the issue is managed automatically. Every issue this action creates always carries all three, so requiring all three (rather than the pkg marker alone) only ever makes the check *stricter* - it rules out, for example, a documentation/discussion issue that merely quotes the marker syntax as an example being mistaken for a managed one (a real false positive found while building this feature: this repo's own design issue for it, #28, does exactly that). The one edge case this cannot rule out: copy-pasting a managed issue's entire body verbatim into an unrelated issue would make that issue managed too - accepted as out of scope. - -Every run, for every package that needs an issue this way: - -| Situation | Action | -|---|---| -| No existing open managed issue for the package | **Create** one: title `: update to fails ()`, or for a held-back attempt, `: update beyond declared constraint to fails ()` - truncated to 256 characters if needed (the package name is always kept intact; only the version/kind tail is cut, with a trailing ellipsis). Body: the pkg marker, current -> attempted version, failure kind, the tail of the relevant captured output (the same safe, fenced rendering as the PR body/job summary - see `report.py`), a link to the workflow run, a link to the PR if one was created/edited this run (`null`/omitted in `dry-run`, since none is), a "last seen" note, and a footer explaining the issue is managed automatically. Labels come from `issue-labels`, exactly like `pr-labels` (only passed with `--label` when non-empty) - **the label must already exist**, this action never creates one. | -| An existing open managed issue for the package | **Update** its body to the current state (the pkg marker is kept as-is). A **comment is added only if** the attempted version or failure kind changed since the state marker's last recorded value - an unchanged, still-failing package is updated silently, not re-commented on every run. | -| An existing open managed issue whose package is *not* failed/held-back in this run's outcomes | **Close** it, with a comment linking to the run (and the PR, if any) - the package either updated successfully, had nothing to update, or is no longer a top-level dependency at all. For the reserved `transitive-dependencies` marker (see "`update-transitive`" above) specifically, if `update-transitive` is disabled this run, the close comment says so instead of the usual "no longer failing" - that claim would not be true, since nothing was actually checked. | -| More than one open managed issue for the same package | The **lowest-numbered** one is treated as canonical (used for the update/close rules above); every other one is **closed** with a "Duplicate of #\" comment. This can happen because the lookup below is not a single atomic source of truth - see "Finding existing managed issues". | -| An open issue missing any of the three identity signals above | **Never touched.** Only issues this action itself created (or something that reproduces all three signals - see above) are ever edited or closed. | - -**Finding existing managed issues** always starts from a plain, unfiltered `gh issue list --state open --json number,body --limit 200` - GitHub's issue *search* index is only eventually consistent, so it is never the primary/only source: relying on it first could miss an issue this same run (or a concurrent one) just created and file a duplicate. Only if that plain listing comes back at exactly its `--limit` (i.e. it may itself have been truncated - there are more than 200 open issues in the repository) does a second, `gh issue list --search '"test-gated-updates:pkg=" in:body'`-narrowed call additionally run, merged in by issue number. Either way, every candidate's identity is always re-confirmed locally (all three signals above) before it is trusted, never taken from the search match alone. - -**Aborted runs:** if the update loop itself aborts (`UpdateAborted` - see "Report" above), issue management is skipped entirely and a `::notice::` is printed - the outcome list for an aborted run is only partial, and treating a package missing from it as "no longer failing" would incorrectly close its issue. - -**Dry-run:** performs no `gh` writes at all (no create/update/comment/close calls) - only the read-only listing(s) above run, so the plan can still be computed against real repository state. The planned actions are printed and exposed in the `issue-actions` output (`[]` when `create-issues` is off, always) as `{package, action, issue}` objects, e.g.: - -```json -[{"package": "idna", "action": "create", "issue": null}] -``` - -A compact one-line summary (e.g. `Issue actions: 1 created, 0 updated (0 commented), 0 closed (dry-run: planned only, no writes performed).`) is also appended to the job summary. - -**One failing action never loses the rest.** Each package's `gh` call(s) are isolated - if e.g. one issue can no longer be commented on (deleted, transferred, locked, ...), every other package's create/update/close for this run still goes ahead. The failed action still shows up in `issue-actions`, with an additive `error` field (and `issue: null` where no issue number is known, e.g. a failed create); `run()` prints one `::warning::` per failure. **Failures never fail the run** in any case: once the PR has been created/edited, it is this action's primary product, so create-issues failures (rate limits, a missing label, a missing permission, an individual `gh` call failing, ...) never turn into a non-zero exit code. - -### `update-transitive`: refreshing transitive dependencies - -Every other feature above only ever touches *top-level* packages (`poetry show -T` / this action's own uv equivalent). `update-transitive` (default `false`, issue #24) is an opt-in, run-level (not per-package) final step: after the top-level loop (either `strategy`) and any `allow-major` beyond-constraint attempts have both finished, it refreshes *everything* - transitive dependencies included, and any top-level package `with-groups`/`without-groups`/`only-groups` left out of this run's own iteration - that is still updatable within its already-declared constraints: - -1. **Refresh:** `poetry update --lock --no-interaction` / `uv lock --upgrade`, then a `sync()` - verified against real `poetry==2.4.3`/`uv==0.12.14` to only ever change the lock file, never `pyproject.toml`. If the lock does not change, nothing further happens (reported as `"unchanged"` - see below). -2. **Test once**, against the refreshed lock - exactly like every other tested step in this action. - - **Passes:** committed as its own commit, `Update transitive dependencies`. - - **Fails**, or the refresh command itself fails to resolve: the lock file is reset back to its pre-step state and the environment re-synced; nothing is committed. - -Unlike a per-package update, one lock-wide refresh has no single old/new version of its own, so it is **not** folded into `report-json`'s per-package array - it gets its own `transitive-report` output instead: a single JSON object, `{status, changed_packages, failure_kind, output_tail}`. `status` is `"updated"`, `"failed"`, or `"unchanged"`; `changed_packages` is an array of `{name, old, new}` for every package the lock's own before/after snapshot differed on (not just whichever package(s) the refresh command directly targeted - moving one package can move others), present even for a discarded `"failed"` attempt, to show what would have changed; `failure_kind` (`"resolution"` or `"test"`, same meaning as `report-json`'s own field) and `output_tail` are only present when `status` is `"failed"`. The JSON literal `null` when `update-transitive` is off (the default) or a run aborted before the step ran - this keeps `transitive-report` (and every other output) byte-identical to before this feature existed for a run that does not use it. - -The PR body / job summary get a new "🔁 Transitive dependencies" section (only ever rendered when the step actually ran this run) listing every changed package's old -> new version, or, on failure, the same collapsed output block used everywhere else in the report. - -**Interaction with `create-issues`:** a failed `update-transitive` step files one managed issue, keyed by the reserved package name `transitive-dependencies` (reusing exactly the same create/update/close machinery documented below, just with its own title - `transitive dependencies: lock-wide refresh fails ()` - and body, listing every changed package (name, old -> new, capped) ahead of the captured output, since a lock-wide refresh has no single package/version of its own to put in the usual title/table), and closes it automatically once the step later passes (or is unchanged) again. If `update-transitive` is later turned back off while an issue for it is still open, that issue is still closed (nothing is tracking it any more), but with a close comment that says the feature is disabled rather than the usual "no longer failing" - that claim would not be true, since nothing was actually checked this run. - -**Interaction with an aborted run:** if the run aborts (`UpdateAborted`) - whether during the top-level loop or during this step's own re-sync - the report still reflects whatever `transitive-report` state exists at that point (the step's own `"failed"` outcome if the abort happened while discarding it, otherwise `null`), the same "partial, not pushed" handling as everywhere else (see "Report" above). - -### Dependency group selection (`with-groups`/`without-groups`/`only-groups`) - -`with-groups`, `without-groups` and `only-groups` (all default `""`, issue #4) control which dependency **groups** this action considers, for both (1) which top-level packages get iterated by the update loop, and (2) what gets installed/synced so tests run against the intended set. All three are comma/newline separated lists (parsed exactly like `pr-labels`); `with-groups` and `without-groups` may be combined, but `only-groups` is mutually exclusive with both - combining it with either fails fast (`ActionError`) before anything else runs, as does naming a group that does not exist in the project's `pyproject.toml` - both checked before bootstrap/install, let alone the update loop itself. - -`"main"` names the project's own ungrouped dependencies (`[project.dependencies]` / `[tool.poetry.dependencies]`) - never a real group in either backend's own vocabulary, but always a valid name here for symmetry with the named groups. - -**Poetry:** maps straight onto `poetry show -T`/`install`/`sync`'s own `--with`/`--without`/`--only` flags (verified against real `poetry==2.4.3` - `"main"` is already a real group name to Poetry itself, no special-casing needed). With none of the three set, behavior is unchanged from before this feature existed. A `[dependency-groups]` (PEP 735) name is only ever a valid group when `poetry-version` is `2.2` or later - PEP 735 support was added in Poetry 2.2.0 ("Add support for PEP 735 dependency groups", [poetry#10130](https://github.com/python-poetry/poetry/releases/tag/2.2.0), verified empirically too: `poetry==2.1.4` silently ignores the whole table, `poetry==2.2.0` honors it); naming such a group with an older `poetry-version` fails fast with a message saying so, rather than the generic "unknown group" error. - -**uv:** has no single built-in command that lists top-level dependency names the way `poetry show -T` does (this action already parses `pyproject.toml` directly for that - see `updater/pyproject_deps.py`), so the listing side is filtered in Python against `"main"` (`[project.dependencies]`), `"dev"` (the legacy `[tool.uv.dev-dependencies]` list *or* a `dev` key inside `[dependency-groups]` - both mean the same thing), and every other `[dependency-groups]` (PEP 735) name - including following `{include-group = "..."}` entries *transitively* (cycle-safe), exactly like uv's own resolver does (verified against real `uv==0.12.14`): a package declared under a group reachable through another still-active group's `include-group` chain is still considered included, even if its own declaring group was named in `without-groups` - e.g. given `test = ["certifi"]` and `dev = [{include-group = "test"}, "six"]`, `only-groups: dev` includes both `six` and `certifi`, `only-groups: test` includes only `certifi`, and `without-groups: test` still includes `certifi` (reachable through `dev`, which was never excluded). `with-groups` has no effect on the listing side: with none of the three set, every group is already iterated (today's behavior, unchanged), so there is nothing left for `with-groups` to add back - it only matters for what gets synced. For `uv sync`, once any of the three is set, the selection switches from this action's own permissive default (`--all-groups --all-extras`, unchanged when none of the three are set) to uv's own native default group set (main plus whatever is a default group, most commonly just `dev`) adjusted by `--group`/`--no-group`/`--only-group` (verified against real `uv==0.12.14`: `--only-group ` already excludes `"main"` and every other group on its own, so an `only-groups` selection that also wants `"main"` pairs `--no-default-groups` with a `--group` per other requested name instead; `--group`/`--no-group`/`--only-group` handle PEP 735 `include-group` transitivity natively, uv's own job, not this action's). Extras (`--all-extras`, or nothing under `tool.uv.conflicts` - see "uv backend: scope and limits" above) are a separate axis this feature does not touch, either way. `without-groups: main` is fully honored for *listing* (main-declared packages are simply not iterated - a pure `pyproject.toml` parse, no uv flag needed) but **cannot** be honored for `uv sync`'s own selection - uv has no flag to exclude `[project.dependencies]` from `sync` while still installing other groups, so main is still installed either way; a `::warning::` is printed once when this applies. **`uv-sync-args`, when set, always wins for sync** and replaces the selection outright, same as without this feature at all - `without-groups`/`only-groups` then only ever filter the listing side (`with-groups` still has no listing effect either way). - -### Token and permissions - -A pull request opened with the default, ephemeral `GITHUB_TOKEN` does **not** trigger other `pull_request` (or `pull_request_target`) workflows — this is a deliberate GitHub Actions restriction to stop workflows from recursively triggering themselves. If you rely on CI checks running against the PR this action opens, `GITHUB_TOKEN` alone will leave it with no checks at all. - -To get normal CI on the resulting PR, use a fine-grained personal access token or a GitHub App installation token instead, with at least: - -- `contents: write` (push the update branch) -- `pull-requests: write` (create/edit the PR, add labels) -- `issues: write` too, only if you enable `create-issues` (see above) - it also covers the read-only `gh issue list` lookup `create-issues` needs, so no separate `issues: read` is needed - -That token has to be passed in **two** places, because two different things authenticate independently: - -1. `actions/checkout`'s `token:` input — `actions/checkout` persists this as the git credential for the checkout, and this action's own `git push` (see `updater/git_repo.py`) relies entirely on those persisted credentials; it never receives or handles a token itself for the push. -2. This action's `github_token` input — used for `gh pr create`/`gh pr edit` (see `updater/github_pr.py`) and, when `create-issues` is enabled, `gh issue list`/`create`/`edit`/`comment`/`close` (see `updater/github_issues.py`), which all shell out to the `gh` CLI authenticated via `GH_TOKEN`. - -```yaml -- uses: actions/checkout@v4 - with: - token: ${{ secrets.DEPS_UPDATE_TOKEN }} - -- uses: friedrichwilken/update-poetry-dependencies@v2 # see "Versions" above - not released yet, pin to @main or a SHA until v2.0.0 exists - with: - github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} - # ... other inputs -``` - -If you'd rather stick with `GITHUB_TOKEN` (accepting that the PR gets no automatic checks, or that you trigger checks another way, e.g. `workflow_run`), the calling workflow must still declare the permissions explicitly — the repository default for `GITHUB_TOKEN` is often read-only: - -```yaml -permissions: - contents: write - pull-requests: write -``` - -Either way, the repository setting **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must be enabled, or PR creation is rejected outright regardless of which token is used. - -### Usage Example - -The action no longer checks out the repository itself — do that in the calling workflow before using it. `package-manager` defaults to `auto`, so it usually does not need to be set explicitly. - -These examples pin to the `v2` major tag, which the [release workflow](.github/workflows/release.yml) will move to point at the latest `v2.x.y` release — **but see "Versions" above: `v2` does not exist yet.** Until the first `v2.0.0` tag is pushed, pin to `@main` or a full commit SHA instead (`friedrichwilken/update-poetry-dependencies@ # describe the commit`) — see how this repo's own workflows pin their third-party actions for the pattern. - -This repository dogfoods the action on itself; see [`.github/workflows/update_dependencies.yml`](.github/workflows/update_dependencies.yml) for a complete, currently-running example (uv backend). - -#### Poetry project +## Quick start (uv) ```yaml name: update dependencies @@ -273,78 +18,45 @@ on: permissions: contents: write pull-requests: write - # issues: write # only needed with create-issues: 'true' below - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false jobs: update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - token: ${{ secrets.DEPS_UPDATE_TOKEN }} - - uses: friedrichwilken/update-poetry-dependencies@v2 # not released yet - pin to @main or a SHA, see "Versions" above + - uses: friedrichwilken/test-gated-python-updates@v2 with: - python-version: '3.12.14' - poetry-version: '2.4.3' - directory: './' - pr-title-prefix: '[Poetry Update] ' - pr-labels: 'dependencies' - test-command: 'pytest' - branch-name: 'deps/test-gated-updates' - # create-issues: 'true' # opt-in - see "create-issues" above; needs issues: write above - # issue-labels: 'dependencies' # only used when create-issues is enabled - github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} + test-command: 'uv run pytest' + github_token: ${{ secrets.GITHUB_TOKEN }} ``` -#### uv project +Using Poetry? Change `test-command` to `'poetry run pytest'` — everything else auto-detects. Full walkthrough: [tutorial](docs/tutorials/weekly-updates.md). -```yaml -name: update dependencies -on: - schedule: - - cron: '0 6 * * 1' - workflow_dispatch: +The default `GITHUB_TOKEN` above won't trigger your CI on the PR it opens — see [token and permissions](docs/manual/token-and-permissions.md) for why, and for a PAT that fixes it. -permissions: - contents: write - pull-requests: write - # issues: write # only needed with create-issues: 'true' below +## What you get -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false +A pull request with a report, e.g.: -jobs: - update: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.DEPS_UPDATE_TOKEN }} +| Package | Result | Detail | +|---|---|---| +| six | updated | 1.16.0 → 1.17.0 | +| idna | failed | test failed at 3.7 | - - uses: friedrichwilken/update-poetry-dependencies@v2 # not released yet - pin to @main or a SHA, see "Versions" above - with: - python-version: '3.12.14' - package-manager: 'uv' - directory: './' - pr-title-prefix: '[uv Update] ' - pr-labels: 'dependencies' - test-command: 'uv run pytest' - branch-name: 'deps/test-gated-updates' - # create-issues: 'true' # opt-in - see "create-issues" above; needs issues: write above - # issue-labels: 'dependencies' # only used when create-issues is enabled - github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} -``` +## Going further -### Maintaining this repo +- [`allow-major`](docs/manual/allow-major.md) — attempt updates beyond the declared constraint. +- [`strategy: batch-first`](docs/manual/strategies.md) — cut N test runs down to about 1. +- [`create-issues`](docs/manual/create-issues.md) — track failures as durable GitHub issues. +- [`update-transitive`](docs/manual/update-transitive.md) — refresh transitive dependencies too. +- [Dependency groups](docs/manual/dependency-groups.md) — `with-groups`/`without-groups`/`only-groups`. +- [`dry-run` and outputs](docs/manual/dry-run.md) — preview a run, or consume `report-json` yourself. +- [Token and permissions](docs/manual/token-and-permissions.md) — get real CI running on the PR. -Notes for whoever maintains `friedrichwilken/update-poetry-dependencies` itself (not relevant to consumers of the action): +## Learn more -- [`dependabot_automerge.yml`](.github/workflows/dependabot_automerge.yml) only actually enables auto-merge on a Dependabot PR if the repository setting **Settings → General → Pull Requests → "Allow auto-merge"** is on; if it's off, the workflow prints a `::warning::` and exits cleanly instead of failing. -- [`update_dependencies.yml`](.github/workflows/update_dependencies.yml) needs a `DEPS_UPDATE_TOKEN` secret (see "Token and permissions" above) to get CI running on the PRs it opens; it falls back to `github.token` otherwise. -- [`release.yml`](.github/workflows/release.yml) only reacts to a pushed `vX.Y.Z` tag and only ever force-moves the major tag matching that same `X` — pushing a `v2.0.0` tag is what turns on `v2` for the first time. +- [Tutorial: a weekly dependency-update workflow](docs/tutorials/weekly-updates.md) +- [Manual](docs/manual/README.md) — full reference for every input and output. +- [Migrating from v1](docs/manual/migrating-from-v1.md) +- [This repo's own weekly workflow](.github/workflows/update_dependencies.yml) — a live example. diff --git a/action.yml b/action.yml index ecf0c0d..5b53f80 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: 'update poetry dependencies' +name: 'Test-gated Python updates' description: 'Updates your Poetry or uv dependencies one top-level package at a time, testing each update, and creates a pull request with the results.' author: 'Friedrich Wilken' branding: @@ -39,28 +39,28 @@ inputs: description: 'Run the full update loop but skip pushing the branch and creating/updating the PR.' default: 'false' allow-major: - description: "Opt-in: for each package whose declared constraint would exclude its latest release, also attempt an update beyond the declared constraint (usually, but not always, a new major version) before falling back to the plain in-range update on failure. See the README 'Beyond-constraint update attempts' section." + description: "Opt-in: for each package whose declared constraint would exclude its latest release, also attempt an update beyond the declared constraint (usually, but not always, a new major version) before falling back to the plain in-range update on failure. See docs/manual/allow-major.md." default: 'false' strategy: - description: "'per-package' (default): update, test and commit one top-level package at a time. 'batch-first': update every package at once and test once; on success, replay the same result as per-package commits (no extra test runs); on failure, fall back to the per-package loop. Cuts test runs from N down to close to 1 in the happy path. See the README 'strategy: batch-first' section." + description: "'per-package' (default): update, test and commit one top-level package at a time. 'batch-first': update every package at once and test once; on success, replay the same result as per-package commits (no extra test runs); on failure, fall back to the per-package loop. Cuts test runs from N down to close to 1 in the happy path. See docs/manual/strategies.md." default: 'per-package' create-issues: - description: "Opt-in: file one GitHub issue per top-level package that fails (or has a held-back beyond-constraint attempt), updated across runs and closed automatically once it no longer applies. Requires `issues: write` on the token. See the README 'create-issues: filing issues for failures' section." + description: "Opt-in: file one GitHub issue per top-level package that fails (or has a held-back beyond-constraint attempt), updated across runs and closed automatically once it no longer applies. Requires `issues: write` on the token. See docs/manual/create-issues.md." default: 'false' issue-labels: description: 'A comma or newline separated list of labels added to an issue created by create-issues. Labels must already exist in the repository.' default: '' update-transitive: - description: "Opt-in: after the top-level loop (and any allow-major beyond-constraint attempts) finish, run one final tested step that refreshes every dependency - transitive included - still updatable within its existing constraints (`poetry update --lock` / `uv lock --upgrade`, then a sync), committed separately as 'Update transitive dependencies'. See the README 'update-transitive: refreshing transitive dependencies' section." + description: "Opt-in: after the top-level loop (and any allow-major beyond-constraint attempts) finish, run one final tested step that refreshes every dependency - transitive included - still updatable within its existing constraints (`poetry update --lock` / `uv lock --upgrade`, then a sync), committed separately as 'Update transitive dependencies'. See docs/manual/update-transitive.md." default: 'false' with-groups: - description: "A comma or newline separated list of dependency groups to additionally include when selecting what gets installed/synced (Poetry: its own optional groups; uv: additional groups on top of its own default selection - see the README 'Dependency group selection' section for why this has no effect on which top-level packages are iterated). 'main' names the project's own ungrouped dependencies. May be combined with without-groups; mutually exclusive with only-groups." + description: "A comma or newline separated list of dependency groups to additionally include when selecting what gets installed/synced (Poetry: its own optional groups; uv: additional groups on top of its own default selection - see docs/manual/dependency-groups.md for why this has no effect on which top-level packages are iterated). 'main' names the project's own ungrouped dependencies. May be combined with without-groups; mutually exclusive with only-groups." default: '' without-groups: - description: "A comma or newline separated list of dependency groups to exclude. 'main' names the project's own ungrouped dependencies ('dev' additionally covers uv's legacy tool.uv.dev-dependencies). May be combined with with-groups; mutually exclusive with only-groups. See the README 'Dependency group selection' section." + description: "A comma or newline separated list of dependency groups to exclude. 'main' names the project's own ungrouped dependencies ('dev' additionally covers uv's legacy tool.uv.dev-dependencies). May be combined with with-groups; mutually exclusive with only-groups. See docs/manual/dependency-groups.md." default: '' only-groups: - description: "A comma or newline separated list of dependency groups to exclusively include, dropping every other group ('main' included). Mutually exclusive with with-groups/without-groups. See the README 'Dependency group selection' section." + description: "A comma or newline separated list of dependency groups to exclusively include, dropping every other group ('main' included). Mutually exclusive with with-groups/without-groups. See docs/manual/dependency-groups.md." default: '' github_token: description: 'GitHub token for the PR creation.' @@ -89,7 +89,7 @@ outputs: description: 'JSON array of planned/performed create-issues actions, one object per affected package: {package, action, issue}. action is "create", "update", or "close"; issue is the existing/created issue number, or null for a not-yet-created issue (always null in dry-run, since nothing is created). Empty array ([]) when create-issues is not enabled.' value: ${{ steps.update.outputs.issue-actions }} transitive-report: - description: 'A single JSON object reporting the update-transitive step (or the JSON literal null when update-transitive is not enabled, or the step never ran because the run aborted first): {status, changed_packages, failure_kind, output_tail}. status is "updated", "failed", or "unchanged"; changed_packages is an array of {name, old, new} for every package the lock-wide refresh changed (present even on a failed/discarded attempt, to show what would have changed); failure_kind ("resolution" or "test") and output_tail are only present when status is "failed". See the README "update-transitive: refreshing transitive dependencies" section.' + description: 'A single JSON object reporting the update-transitive step (or the JSON literal null when update-transitive is not enabled, or the step never ran because the run aborted first): {status, changed_packages, failure_kind, output_tail}. status is "updated", "failed", or "unchanged"; changed_packages is an array of {name, old, new} for every package the lock-wide refresh changed (present even on a failed/discarded attempt, to show what would have changed); failure_kind ("resolution" or "test") and output_tail are only present when status is "failed". See docs/manual/update-transitive.md.' value: ${{ steps.update.outputs.transitive-report }} runs: diff --git a/updater/github_issues.py b/updater/github_issues.py index f282b89..bbbfe78 100644 --- a/updater/github_issues.py +++ b/updater/github_issues.py @@ -103,7 +103,7 @@ _MANAGED_BY_FOOTER = ( "_This issue is managed automatically by the " - "[update-poetry-dependencies](https://github.com/friedrichwilken/update-poetry-dependencies) " + "[test-gated-python-updates](https://github.com/friedrichwilken/test-gated-python-updates) " "action's `create-issues` feature: it is updated on every run while the " "package keeps failing or being held back, and closed automatically " "once it no longer is. It should not be edited by hand._" From 40dc2ceba6a3d08908b98d3da6c8a48b27138cec Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:21:47 +0200 Subject: [PATCH 02/12] Add docs/manual (reference) and docs/tutorials (guided walkthrough) Split out of the old README: docs/manual/ is one-question-per-page reference (inputs, outputs, how it works, token/permissions, package managers, allow-major, strategy, create-issues, update-transitive, dependency groups, dry-run, the report, migrating from v1, versioning, maintaining), content moved (not rewritten from memory) and verified against action.yml and updater/. docs/tutorials/weekly-updates.md is a single 10-step, second-person tutorial building one workflow from a minimal scheduled run up to labels/CI-on-the-PR/batch-first/allow-major/ create-issues/update-transitive/auto-merge; every step shows the complete workflow file so far, uv throughout with Poetry-only lines marked inline (`# Poetry: '...'`) so a single tutorial stays in sync for both backends. Co-Authored-By: Claude Fable 5.1 --- docs/manual/README.md | 19 ++ docs/manual/allow-major.md | 46 +++ docs/manual/create-issues.md | 61 ++++ docs/manual/dependency-groups.md | 39 +++ docs/manual/dry-run.md | 13 + docs/manual/how-it-works.md | 37 +++ docs/manual/inputs.md | 68 +++++ docs/manual/maintaining.md | 55 ++++ docs/manual/migrating-from-v1.md | 24 ++ docs/manual/output-rendering.md | 27 ++ docs/manual/outputs.md | 86 ++++++ docs/manual/package-managers.md | 27 ++ docs/manual/strategies.md | 37 +++ docs/manual/token-and-permissions.md | 47 +++ docs/manual/update-transitive.md | 30 ++ docs/manual/versioning.md | 21 ++ docs/tutorials/weekly-updates.md | 429 +++++++++++++++++++++++++++ 17 files changed, 1066 insertions(+) create mode 100644 docs/manual/README.md create mode 100644 docs/manual/allow-major.md create mode 100644 docs/manual/create-issues.md create mode 100644 docs/manual/dependency-groups.md create mode 100644 docs/manual/dry-run.md create mode 100644 docs/manual/how-it-works.md create mode 100644 docs/manual/inputs.md create mode 100644 docs/manual/maintaining.md create mode 100644 docs/manual/migrating-from-v1.md create mode 100644 docs/manual/output-rendering.md create mode 100644 docs/manual/outputs.md create mode 100644 docs/manual/package-managers.md create mode 100644 docs/manual/strategies.md create mode 100644 docs/manual/token-and-permissions.md create mode 100644 docs/manual/update-transitive.md create mode 100644 docs/manual/versioning.md create mode 100644 docs/tutorials/weekly-updates.md diff --git a/docs/manual/README.md b/docs/manual/README.md new file mode 100644 index 0000000..b6192d7 --- /dev/null +++ b/docs/manual/README.md @@ -0,0 +1,19 @@ +# Manual + +Reference documentation: what's there, precisely. Looking for a guided walkthrough instead? See the [tutorial](../tutorials/weekly-updates.md). + +- [Inputs](inputs.md) — every `with:` key, grouped by topic. +- [Outputs](outputs.md) — every output, including the `report-json`, `issue-actions` and `transitive-report` JSON schemas. +- [How it works](how-it-works.md) — the update loop, the clean-tree prerequisite, the fixed branch/PR, and how the action bootstraps itself with `uv`. +- [Token and permissions](token-and-permissions.md) — `GITHUB_TOKEN` vs. a PAT/App token, the `permissions:` block, and the repo setting you need. +- [Package managers](package-managers.md) — auto-detection, and Poetry/uv specifics. +- [`allow-major`](allow-major.md) — attempting updates beyond the declared constraint. +- [`strategy`](strategies.md) — `per-package` vs. `batch-first`. +- [`create-issues`](create-issues.md) — filing one tracked issue per failing package. +- [`update-transitive`](update-transitive.md) — refreshing transitive dependencies. +- [Dependency groups](dependency-groups.md) — `with-groups` / `without-groups` / `only-groups`. +- [`dry-run`](dry-run.md) — previewing a run without pushing anything. +- [The report](output-rendering.md) — how the PR body and job summary are rendered and budgeted. +- [Migrating from v1](migrating-from-v1.md) — the breaking changes from the old bash action. +- [Versioning](versioning.md) — tags, pinning, and the release process. +- [Maintaining this repo](maintaining.md) — dev setup, tests, and how the maintainer releases new versions. diff --git a/docs/manual/allow-major.md b/docs/manual/allow-major.md new file mode 100644 index 0000000..46e18b3 --- /dev/null +++ b/docs/manual/allow-major.md @@ -0,0 +1,46 @@ +# `allow-major` + +```yaml +allow-major: 'true' +``` + +Opt-in (default `false`): for a package whose declared constraint has an effective upper bound, also attempt an update beyond that bound (usually, but not always, a semver-major jump) before falling back to the plain in-range update. + +## Why "beyond declared constraint" and not just "major" + +`allow-major` is an opt-in, per-package extension of the same test-gated flow: for a package whose declared constraint has an effective upper bound (a caret/tilde/wildcard/`~=` Poetry constraint, or a PEP 508 specifier with `<`/`<=`/`~=`/`==x.*`), the ordinary in-range update never looks past that bound. Going beyond a declared constraint is not necessarily a semver-major jump — `six >=1.10,<1.15` allowing `1.17.0` is a minor bump that merely exceeded the declared range. `bump` in `report-json` always reports the real release segment that changed, computed from the actual version numbers, never assumed from the fact that a constraint was raised. + +## What happens, per package + +When an effective upper bound is found, before the plain in-range update: + +1. **Attempt:** raise the declared constraint so the latest release is allowed (`poetry add "pkg[extras]@latest" --group ` / `--optional `, or for uv, rewrite just that requirement's specifier and re-lock with `uv add "pkg[extras]>=" --upgrade-package pkg`), then test exactly like an in-range update. If it passes, both `pyproject.toml` and the lock file are committed together (`Update -> (constraint raised)`), tagged `constraint_raised: true` with `bump` set to the real delta, and the package is done — no separate in-range update runs for it. +2. **Fallback:** if raising the constraint fails to resolve, resolves but fails the test command, or resolves and passes but has to be discarded (see below), every touched file is reset and the environment re-synced, and the plain in-range update runs instead. Whichever outcome that produces also records the held-back attempt (`beyond_constraint_version`/`beyond_constraint_failure_kind`/`beyond_constraint_output_tail` in `report-json`; a `held-back-packages` output entry either way) or the discard reason (`beyond_constraint_skip_reason`) rather than a `failure_kind` — the tool itself did not fail, this action decided not to trust what it did. +3. If the in-range update fails too, the package is `failed` exactly as it would be without `allow-major`. + +A package whose constraint has no effective upper bound needs no separate attempt — the in-range update already reaches the latest release — and is never double-tested. + +## Declarations that are always skipped + +Not every declaration shape can be safely rewritten without risking silently dropping information. These are always skipped rather than guessed at, reported via `beyond_constraint_skip_reason`: + +- a git/path/url/workspace dependency, or a direct URL reference (`pkg @ ...`) +- more than one constraint entry for the package, or a Poetry `||` OR constraint where every alternative already has its own upper bound +- a dependency carrying `markers`/`python`/`platform`/`source`/`allow-prereleases` keys this feature cannot faithfully preserve +- an exact version pin (`==1.2.3`, or Poetry's bare `1.2.3`) +- a constraint/specifier this action's PEP 440-ish parser cannot parse +- a legacy Poetry `optional = true` dependency not listed in any `[tool.poetry.extras]` entry +- `python` itself + +## Discarded after the tool call succeeds + +Two more checks can still discard an otherwise-successful attempt (same effect as a failure — reset and fall back): + +- the manifest is re-read and diffed against the original declaration; if anything other than the version constraint changed (extras dropped, moved to a different table, a marker appeared, ...), the change is discarded; +- if the attempted version is a pre-release and the original was not, it is discarded. + +## In the report + +The PR body gets a new "⚠️ Held back (update beyond declared constraint failed)" table (package, current, attempted, reason) once at least one package used it, and the "✅ Updated" table gains a `bump` column. With `allow-major` left at `false`, the rendered report and `report-json` are byte-identical to before this feature existed. See [The report](output-rendering.md) and [outputs.md](outputs.md#report-json). + +**Prerequisite:** like the lock file, `pyproject.toml` must have no uncommitted changes before this action runs — see [How it works](how-it-works.md#prerequisite-a-clean-manifestlock-file) — checked regardless of whether `allow-major` is enabled. diff --git a/docs/manual/create-issues.md b/docs/manual/create-issues.md new file mode 100644 index 0000000..62f8a00 --- /dev/null +++ b/docs/manual/create-issues.md @@ -0,0 +1,61 @@ +# `create-issues` + +```yaml +create-issues: 'true' +issue-labels: 'dependencies' # optional +``` + +```yaml +permissions: + issues: write # required +``` + +Opt-in (default `false`): files a durable, trackable GitHub issue per failing package instead of (or in addition to) the PR report, which only ever reflects the latest run. Runs after the PR is created/edited (so the issue can link to it) and requires `issues: write` on the token — see [Token and permissions](token-and-permissions.md). + +## One issue per package, never per run + +An issue is filed for every top-level package whose outcome this run is `failed`, or that has a held-back [`allow-major`](allow-major.md) attempt (`beyond_constraint_failure_kind` set). A package that is both (the beyond-constraint attempt *and* the in-range fallback both failed) gets one issue about the plain failure, not two. + +## Identity: all three markers required + +- a hidden marker in the issue body, `` (`` is the PEP 503 normalized package name) — **never the title**, which is free to change between runs; +- a second hidden marker, ``, recording what the last run reported; +- a footer line stating the issue is managed automatically. + +Requiring all three (rather than the pkg marker alone) rules out, for example, a documentation issue that merely quotes the marker syntax as an example being mistaken for a managed one. The one edge case this cannot rule out: copy-pasting a managed issue's entire body verbatim into an unrelated issue would make that issue managed too — accepted as out of scope. + +## Every run, per package that needs an issue + +| Situation | Action | +|---|---| +| No existing open managed issue | **Create** one: title `: update to fails ()` (or the held-back-attempt variant), truncated to 256 characters if needed. Body: the pkg marker, current → attempted version, failure kind, captured output tail, a link to the workflow run, a link to the PR if one exists this run, a "last seen" note, and the managed-issue footer. Labels from `issue-labels` — **the label must already exist**, this action never creates one. | +| An existing open managed issue | **Update** its body to the current state. A **comment is added only if** the attempted version or failure kind changed since the state marker's last recorded value. | +| An existing open managed issue whose package is *not* failed/held-back this run | **Close** it, with a comment linking to the run (and the PR, if any). | +| More than one open managed issue for the same package | The **lowest-numbered** one is canonical; every other one is **closed** with a "Duplicate of #\" comment. | +| An open issue missing any of the three identity signals | **Never touched.** | + +## Finding existing managed issues + +Always starts from a plain, unfiltered `gh issue list --state open --json number,body --limit 200` — GitHub's issue *search* index is only eventually consistent, so it's never the primary source: relying on it first could miss an issue this same run (or a concurrent one) just created, and file a duplicate. Only if that plain listing comes back at exactly its `--limit` (there may be more than 200 open issues) does a second, search-narrowed call additionally run, merged in by issue number. Either way, every candidate's identity is always re-confirmed locally before it's trusted. + +## Aborted runs + +If the update loop itself aborts, issue management is skipped entirely and a `::notice::` is printed — the outcome list for an aborted run is only partial, and treating a package missing from it as "no longer failing" would incorrectly close its issue. + +## `dry-run` + +Performs no `gh` writes at all — only the read-only listing(s) run, so the plan can still be computed against real repository state. The planned actions are printed and exposed in `issue-actions` (`[]` when `create-issues` is off), e.g.: + +```json +[{"package": "idna", "action": "create", "issue": null}] +``` + +A compact one-line summary is also appended to the job summary. + +## Failures are isolated + +Each package's `gh` call(s) are isolated — if one issue can no longer be commented on, every other package's create/update/close for this run still goes ahead. The failed action still shows up in `issue-actions` with an additive `error` field. **Failures never fail the run**: once the PR has been created/edited, it's this action's primary product, so `create-issues` failures never turn into a non-zero exit code. + +## `update-transitive` interaction + +A failed [`update-transitive`](update-transitive.md) step files one managed issue, keyed by the reserved package name `transitive-dependencies`, and closes it automatically once the step later passes (or is unchanged) again. If `update-transitive` is later turned back off while an issue for it is still open, it's still closed, but with a close comment explaining the feature is disabled rather than the usual "no longer failing" claim — that wouldn't be true, since nothing was actually checked this run. diff --git a/docs/manual/dependency-groups.md b/docs/manual/dependency-groups.md new file mode 100644 index 0000000..b48b42b --- /dev/null +++ b/docs/manual/dependency-groups.md @@ -0,0 +1,39 @@ +# Dependency groups + +```yaml +without-groups: 'dev' +``` + +`with-groups`, `without-groups` and `only-groups` (all default `""`) control which dependency **groups** a run considers, for two purposes: (1) which top-level packages get iterated by the update loop, and (2) what gets installed/synced so tests run against the intended set. + +All three are comma/newline separated lists, parsed exactly like `pr-labels`. `with-groups` and `without-groups` may be combined; `only-groups` is mutually exclusive with both — combining it with either fails fast, as does naming a group that doesn't exist in the project's `pyproject.toml`, both checked before bootstrap/install, let alone the update loop itself. + +`"main"` names the project's own ungrouped dependencies (`[project.dependencies]` / `[tool.poetry.dependencies]`) — never a real group in either backend's own vocabulary, but always a valid name here for symmetry with the named groups. + +## Poetry + +Maps straight onto `poetry show -T`/`install`/`sync`'s own `--with`/`--without`/`--only` flags (`"main"` is already a real group name to Poetry itself). With none of the three set, behavior is unchanged from before this feature existed. + +A `[dependency-groups]` (PEP 735) name is only a valid group when `poetry-version` is `2.2` or later — naming such a group with an older `poetry-version` fails fast with a message saying so, rather than the generic "unknown group" error. + +## uv + +uv has no single built-in command that lists top-level dependency names the way `poetry show -T` does, so the listing side is filtered in Python against `"main"` (`[project.dependencies]`), `"dev"` (the legacy `[tool.uv.dev-dependencies]` list *or* a `dev` key inside `[dependency-groups]` — both mean the same thing), and every other `[dependency-groups]` (PEP 735) name — including following `{include-group = "..."}` entries *transitively* (cycle-safe), exactly like uv's own resolver does. Example: given `test = ["certifi"]` and `dev = [{include-group = "test"}, "six"]`: + +- `only-groups: dev` includes both `six` and `certifi` +- `only-groups: test` includes only `certifi` +- `without-groups: test` still includes `certifi` (reachable through `dev`, which was never excluded) + +`with-groups` has no effect on the listing side: with none of the three set, every group is already iterated (today's behavior, unchanged) — it only matters for what gets synced. + +For `uv sync`, once any of the three is set, the selection switches from this action's own permissive default (`--all-groups --all-extras`, unchanged when none of the three are set) to uv's own native default group set (main plus whatever is a default group, most commonly just `dev`) adjusted by `--group`/`--no-group`/`--only-group` (which handle PEP 735 `include-group` transitivity natively — uv's own job, not this action's). Extras (`--all-extras`, or nothing under `tool.uv.conflicts` — see [Package managers](package-managers.md)) are a separate axis this feature does not touch. + +### Known gap: `without-groups: main` cannot be honored for `uv sync` + +`without-groups: main` is fully honored for *listing* (main-declared packages simply aren't iterated — a pure `pyproject.toml` parse, no uv flag needed) but **cannot** be honored for `uv sync`'s own selection — uv has no flag to exclude `[project.dependencies]` from `sync` while still installing other groups, so main is still installed either way; a `::warning::` is printed once when this applies. + +### `uv-sync-args` always wins + +`uv-sync-args`, when set, always wins for sync and replaces the selection outright, same as without this feature at all — `without-groups`/`only-groups` then only ever filter the listing side (`with-groups` still has no listing effect either way). See [Package managers](package-managers.md#uv). + +**Note:** if `test-command` itself calls `uv run`, it re-syncs using uv's own default selection, ignoring this action's narrower one for the duration of that call — see [Package managers § uv run in test-command re-syncs](package-managers.md#uv). diff --git a/docs/manual/dry-run.md b/docs/manual/dry-run.md new file mode 100644 index 0000000..a1a1bcb --- /dev/null +++ b/docs/manual/dry-run.md @@ -0,0 +1,13 @@ +# `dry-run` + +```yaml +dry-run: 'true' +``` + +Runs the full update loop — updating, testing, committing locally — but skips pushing the branch and creating/updating the PR (default `false`). Nothing on GitHub changes. + +Everything else still happens: packages are updated and tested exactly as in a real run, commits are made locally, and the report is still rendered and written to the job summary (`GITHUB_STEP_SUMMARY`) and the `pr-body`/`report-json` outputs — so you can read the report from the job summary, or consume the outputs in a later step, without anything being pushed. + +If [`create-issues`](create-issues.md) is also enabled, no `gh` writes happen either — only the read-only issue listing runs, so the plan is still computed against real repository state and exposed via `issue-actions`, with every `issue` field `null` (nothing was actually created). + +Use this to try a new configuration (a new `test-command`, `allow-major`, a stricter `strategy`) against your real project before trusting it to open a PR. diff --git a/docs/manual/how-it-works.md b/docs/manual/how-it-works.md new file mode 100644 index 0000000..ac97674 --- /dev/null +++ b/docs/manual/how-it-works.md @@ -0,0 +1,37 @@ +# How it works + +One top-level package at a time: update it, test it, keep it if the test passes, discard it if not — then push one branch and open (or update) one pull request with a report of everything that happened. + +## The loop + +1. List the project's top-level dependencies (`poetry show -T`, or this action's own `pyproject.toml` parse for uv — see [Package managers](package-managers.md)). +2. For each package: update just that one, then run `test-command` (if set) against the result. + - Passes (or no `test-command` given): commit the change (`Update -> `). + - Fails: discard the change and reset the environment. +3. Once every package has been through the loop, render the report and push one branch, creating or updating one PR. + +`strategy: batch-first` changes *how* the loop spends test runs (update everything, test once, replay as separate commits) without changing what gets committed or reported — see [`strategy`](strategies.md). + +## Reset and re-sync + +A discarded update (failed resolution or failed test) resets `pyproject.toml` and the lock file back to the last good commit and re-syncs the environment (`.venv`) before moving on to the next package, so one package's failed attempt can never leak into the next package's test run. If a re-sync itself fails, the whole run aborts: the report still covers everything processed so far (already-made commits for passing packages stay made locally), but nothing is pushed and no PR is created or edited — the run starts its report with a "Run aborted: ``" banner instead. + +## Prerequisite: a clean manifest/lock file + +Before doing anything else, the action fails fast if `pyproject.toml` or the lock file in `directory` already have uncommitted changes (staged or not) — the loop resets and commits exactly these files itself, and running it against a dirty working tree would otherwise either discard that uncommitted work (on a discarded update) or silently absorb it into one of this run's own commits. This is checked unconditionally, not just when `allow-major` is enabled. Commit or stash those changes before this action runs. + +## Fixed branch, one PR + +The action never checks out the repository itself — do that first with `actions/checkout`. It reuses a fixed `branch-name` (default `deps/test-gated-updates`), force-pushed on every run, and creates the PR the first time, editing it on every later run — so scheduled runs never pile up duplicate PRs. If nothing changed (`git` HEAD is unchanged after the loop), nothing is pushed and no PR is touched at all. + +`base-branch` defaults to whichever branch is currently checked out; on a detached checkout (e.g. a `pull_request`-triggered run) it falls back to `GITHUB_BASE_REF`, and fails fast if neither is available, before any work happens. + +## Bootstrap: everything through `uv` + +The action installs [`astral-sh/setup-uv`](https://github.com/astral-sh/setup-uv) and uses `uv` for everything else: it runs its own Python (the updater tool itself) on a fixed, pinned interpreter — independent of your project's `python-version` — then installs your project's `python-version` with `uv python install`, and, for Poetry projects, installs Poetry itself with `uv tool install poetry==` and creates the project's in-project virtualenv directly with `uv venv --clear`, pinned to that interpreter (Poetry then picks up the existing virtualenv automatically). You do not need `actions/setup-python`, `snok/install-poetry`, or a preinstalled `uv`/`poetry` in your workflow — just check out the repository first. + +**The action's own Python is not your project's Python.** The updater always runs on its own fixed interpreter (currently 3.14) so its stdlib tooling is always available; your project gets exactly the `python-version` you asked for, provisioned separately. + +**`.venv` is rebuilt every run** (`uv venv --clear`), so restoring `.venv` itself from a CI cache does nothing useful. If you want faster syncs, cache `uv`'s own package cache instead (e.g. `actions/cache` with `path: ~/.cache/uv`, or the platform-appropriate `uv cache dir`). + +See [Package managers](package-managers.md) for backend-specific detail, and [The report](output-rendering.md) for how the PR body/job summary are produced. diff --git a/docs/manual/inputs.md b/docs/manual/inputs.md new file mode 100644 index 0000000..e5ad51d --- /dev/null +++ b/docs/manual/inputs.md @@ -0,0 +1,68 @@ +# Inputs + +Every `with:` key this action accepts. Generated from `action.yml` — defaults and descriptions here must always match it. Grouped by topic; each group links to the page that explains it in full. + +## Core + +| Name | Default | Description | +|---|---|---| +| `python-version` | `3.12.14` | The Python version to use for the project. | +| `package-manager` | `auto` | Which package manager the project uses: `auto` (detected from the lock file present in `directory`), `poetry`, or `uv`. See [Package managers](package-managers.md). | +| `directory` | `./` | The directory of the project files. | +| `test-command` | `""` | A command for a test to run after updating every package. | +| `github_token` | *(none — required)* | GitHub token for the PR creation. See [Token and permissions](token-and-permissions.md). | + +## Package manager specifics + +See [Package managers](package-managers.md). + +| Name | Default | Description | +|---|---|---| +| `poetry-version` | `2.4.3` | The Poetry version to use. Only used when the poetry backend is selected. | +| `uv-sync-args` | `""` | Only used when the uv backend is selected. Overrides the default `--all-groups --all-extras` group/extra selection passed to `uv sync` (parsed as shell arguments, e.g. `--extra cpu --group dev`). Needed when the project declares `tool.uv.conflicts`. | + +## Pull request + +See [How it works](how-it-works.md) and [The report](output-rendering.md). + +| Name | Default | Description | +|---|---|---| +| `pr-title-prefix` | `""` | A prefix that gets prepended to the PR title. | +| `pr-labels` | `""` | A comma or newline separated list of labels added to the PR. | +| `branch-name` | `deps/test-gated-updates` | Fixed branch name used for the update PR. Reused (and force-pushed) on every run instead of creating a new PR each time. | +| `base-branch` | `""` (the checked-out branch) | Base branch for the PR. Defaults to the branch that is currently checked out. | + +## Run behavior + +| Name | Default | Description | +|---|---|---| +| `dry-run` | `false` | Run the full update loop but skip pushing the branch and creating/updating the PR. See [`dry-run`](dry-run.md). | +| `allow-major` | `false` | Opt-in: for each package whose declared constraint would exclude its latest release, also attempt an update beyond the declared constraint before falling back to the plain in-range update on failure. See [`allow-major`](allow-major.md). | +| `strategy` | `per-package` | `per-package`: update, test and commit one top-level package at a time. `batch-first`: update every package at once and test once, replaying as per-package commits on success. See [`strategy`](strategies.md). | + +## `create-issues` + +See [`create-issues`](create-issues.md). + +| Name | Default | Description | +|---|---|---| +| `create-issues` | `false` | Opt-in: file one GitHub issue per top-level package that fails (or has a held-back beyond-constraint attempt), updated across runs and closed automatically once it no longer applies. Requires `issues: write` on the token. | +| `issue-labels` | `""` | A comma or newline separated list of labels added to an issue created by `create-issues`. Labels must already exist in the repository. | + +## `update-transitive` + +See [`update-transitive`](update-transitive.md). + +| Name | Default | Description | +|---|---|---| +| `update-transitive` | `false` | Opt-in: after the top-level loop (and any `allow-major` beyond-constraint attempts) finish, run one final tested step that refreshes every dependency — transitive included — still updatable within its existing constraints, committed separately as `Update transitive dependencies`. | + +## Dependency groups + +See [Dependency groups](dependency-groups.md). + +| Name | Default | Description | +|---|---|---| +| `with-groups` | `""` | A comma or newline separated list of dependency groups to additionally include when selecting what gets installed/synced. `main` names the project's own ungrouped dependencies. May be combined with `without-groups`; mutually exclusive with `only-groups`. | +| `without-groups` | `""` | A comma or newline separated list of dependency groups to exclude. `main` names the project's own ungrouped dependencies (`dev` additionally covers uv's legacy `tool.uv.dev-dependencies`). May be combined with `with-groups`; mutually exclusive with `only-groups`. | +| `only-groups` | `""` | A comma or newline separated list of dependency groups to exclusively include, dropping every other group (`main` included). Mutually exclusive with `with-groups`/`without-groups`. | diff --git a/docs/manual/maintaining.md b/docs/manual/maintaining.md new file mode 100644 index 0000000..200a6b9 --- /dev/null +++ b/docs/manual/maintaining.md @@ -0,0 +1,55 @@ +# Maintaining this repo + +```sh +uv sync --locked +uv run pytest tests/unit -q +uv run ruff check . +uv run ruff format --check . +``` + +Notes for whoever maintains `friedrichwilken/test-gated-python-updates` itself — not relevant to consumers of the action. + +## Dev setup + +The dev tooling is itself a `uv` project (`pyproject.toml` at the repo root, name `update-poetry-dependencies-dev`) — it only pins `pytest`/`ruff` for local dev and CI; it is not a distributable package. The action itself is a stdlib-only script run via `uv run --no-project` (see `action.yml`). + +```sh +uv sync --locked +``` + +## Tests + +```sh +uv run pytest tests/unit -q +``` + +`tests/unit` is the fast, hermetic suite (fakes for git/gh/backends — see `tests/unit/fakes.py`); `check_action.yml`'s `e2e` job additionally runs the action against real fixture projects under `tests/fixture/` (both Poetry and uv, several scenarios: basic, major, batch, groups) in `dry-run`, using `uses: ./`. + +## Linting + +```sh +uv run ruff check . +uv run ruff format --check . +``` + +`check_action.yml`'s `actionlint` job also lints every workflow file and `action.yml` itself with `raven-actions/actionlint`. + +## Docs validation + +The `docs` CI job renders every complete workflow example from `README.md`/`docs/**/*.md` against `./` (the local `action.yml`) and runs actionlint on the result, so a documented `with:` key that doesn't exist (or a value actionlint would reject) fails CI. `tests/unit/test_readme_sync.py` separately keeps the input/output tables in [`inputs.md`](inputs.md)/[`outputs.md`](outputs.md) honest against `action.yml`. + +## Why the e2e fixtures pin old versions + +`tests/fixture/*` deliberately lock **old** (even vulnerable) versions of a handful of small packages, so the action has something to update when the e2e job runs it in `dry-run`. Dependabot would otherwise keep opening security-update PRs against those exact fixture files and break them — [`dependabot.yml`](../../.github/dependabot.yml) sets `open-pull-requests-limit: 0` and ignores every dependency under `tests/fixture/*` for both the `pip` and `uv` ecosystems to stop that. + +## Dependabot + auto-merge + +[`dependabot.yml`](../../.github/dependabot.yml) keeps GitHub Actions dependencies (grouped into one PR) and this repo's own dev dependencies up to date on a weekly schedule. [`dependabot_automerge.yml`](../../.github/workflows/dependabot_automerge.yml) auto-merges a non-major Dependabot PR once its checks pass, but only actually enables auto-merge if the repository setting **Settings → General → Pull Requests → "Allow auto-merge"** is on; if it's off, the workflow prints a `::warning::` and exits cleanly instead of failing. + +## This repo's own dependency updates + +[`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) dogfoods the action on itself (`uses: ./`, uv backend) and needs a `DEPS_UPDATE_TOKEN` secret (see [Token and permissions](token-and-permissions.md)) to get CI running on the PRs it opens; it falls back to `github.token` otherwise. + +## Release process + +[`release.yml`](../../.github/workflows/release.yml) only reacts to a pushed `vX.Y.Z` tag and only ever force-moves the major tag matching that same `X` (e.g. pushing `v2.3.0` moves `v2`) — it can never touch `v1`, the legacy bash action, and explicitly refuses to if a `v1.x.y` tag is ever pushed by mistake. See [Versioning](versioning.md). diff --git a/docs/manual/migrating-from-v1.md b/docs/manual/migrating-from-v1.md new file mode 100644 index 0000000..efed766 --- /dev/null +++ b/docs/manual/migrating-from-v1.md @@ -0,0 +1,24 @@ +# Migrating from v1 + +```yaml +- uses: actions/checkout@v4 # new: v2 no longer checks itself out + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + +- uses: friedrichwilken/test-gated-python-updates@v2 + with: + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +`v2` is a breaking rewrite: Python instead of bash, `uv`-based bootstrapping, and some input/output changes. + +`v1` is the original bash-based action. It is frozen at the `v1`/`v1.0.1` tags and unmaintained — it will not be moved or updated further. + +To migrate: + +1. Add an explicit `actions/checkout` step before this action in your workflow — v1 checked out the repository itself; v2 does not. +2. Pass your token to **both** `actions/checkout`'s `token:` input and this action's `github_token` input (see [Token and permissions](token-and-permissions.md)) — v1 only needed `github_token`. +3. Check [Inputs](inputs.md) and [Outputs](outputs.md) against your existing `with:` block — some defaults changed (e.g. `poetry-version` now defaults to a Poetry 2.x release) and `package-manager` is new (for uv support; defaults to `auto`-detecting from the lock file). +4. Drop any `actions/setup-python` / `snok/install-poetry` steps you had before this action — v2's `uv`-based bootstrap replaces them entirely; see [How it works § Bootstrap](how-it-works.md#bootstrap-everything-through-uv). + +See [Versioning](versioning.md) for how v2 tags move, and how to pin more strictly if you want to. diff --git a/docs/manual/output-rendering.md b/docs/manual/output-rendering.md new file mode 100644 index 0000000..ff4d2d1 --- /dev/null +++ b/docs/manual/output-rendering.md @@ -0,0 +1,27 @@ +# The report + +```json +{"passed-packages": "six", "failed-packages": "idna"} +``` + +Per package, the rendered report shows: for an update, the old and new locked version; for a failure, the current and attempted version, whether it was a dependency-resolution failure or a test failure, and a collapsed block with the tail of the relevant output (resolver output for a resolution failure, test command output for a test failure). Package names, versions and failure reasons are escaped so they can never break the table formatting or be interpreted as markup. + +The same rendering drives the PR body (`pr-body` output), the job summary (`GITHUB_STEP_SUMMARY`, written even in [`dry-run`](dry-run.md)), and `report-json` (the machine-readable form — see [outputs.md](outputs.md#report-json)). + +## Sections + +- Updated — every package that got a new version this run, with a `bump` column once [`allow-major`](allow-major.md) produced at least one. +- Failed +- Held back (update beyond declared constraint failed) — only when `allow-major` produced at least one, see [`allow-major`](allow-major.md). +- Transitive dependencies — only when [`update-transitive`](update-transitive.md) actually ran this run. +- Skipped packages (compact, one line) + +## Size budgets + +The rendering happens under an explicit character budget: well under GitHub's 65536 character PR body limit (`MAX_BODY_CHARS = 60000`), and a larger (but still bounded, `MAX_SUMMARY_CHARS = 900000`) budget for the job summary — so the job summary can carry more detail than the PR body for the same run. At either size, table rows and per-package output blocks are dropped (with a "N more, see `report-json`/job summary" note) as needed to stay under the budget — the guarantee holds regardless of how many packages or how much output there is. + +`report-json`'s total size is capped independently (`MAX_REPORT_JSON_BYTES = 256 * 1024`, ~256 KiB): if needed, `output_tail` is dropped (in favor of `output_truncated: true`) from the packages with the largest captured output first, until it fits — every package still gets a record. + +## Aborted runs + +If the update loop has to abort early (currently only when re-syncing the environment after a discarded update itself fails), the rendered output still covers everything processed before the abort — already-made commits for packages that passed stay made locally, but the run is not pushed and no PR is created/edited — and starts with a "Run aborted: ``" banner. See [How it works § Reset and re-sync](how-it-works.md#reset-and-re-sync). diff --git a/docs/manual/outputs.md b/docs/manual/outputs.md new file mode 100644 index 0000000..31a065e --- /dev/null +++ b/docs/manual/outputs.md @@ -0,0 +1,86 @@ +# Outputs + +Every output this action sets. Generated from `action.yml`. + +| Name | Description | +|---|---| +| `passed-packages` | Comma separated list of packages that were updated and passed the test command. | +| `failed-packages` | Comma separated list of packages whose update or test failed and were discarded. | +| `skipped-packages` | Comma separated list of packages that had nothing to update. | +| `held-back-packages` | Comma separated list of packages where an update beyond the declared constraint was attempted (`allow-major`) but held back — the package may still show up in `passed-packages`/`failed-packages`/`skipped-packages` for the in-range update that ran instead. | +| `pr-body` | The rendered report / PR body. | +| `report-json` | JSON array of per-package update records. Schema below. | +| `issue-actions` | JSON array of planned/performed `create-issues` actions. Schema below. | +| `transitive-report` | A single JSON object reporting the `update-transitive` step, or the JSON literal `null`. Schema below. | + +The same report is also written to the job summary (`GITHUB_STEP_SUMMARY`), including in `dry-run`. See [The report](output-rendering.md) for how `pr-body` and the job summary are rendered and budgeted. + +## `report-json` + +One object per top-level package processed this run. + +```json +[ + {"name": "idna", "status": "updated", "old_version": "3.6", "new_version": "3.7", "failure_kind": null, "output_tail": ""} +] +``` + +| Field | Type | Description | +|---|---|---| +| `name` | string | The top-level package name. | +| `status` | string | One of `updated`, `failed`, `skipped`. | +| `old_version` | string \| null | The version locked before this run touched the package, or `null` if unknown. | +| `new_version` | string \| null | For `updated`/`skipped`: the resulting locked version. For `failed`: the version that was attempted before the change was reverted. `null` if unknown. | +| `failure_kind` | string \| null | `resolution` (the update/lock step itself failed), `test` (the test command failed), or `null` for a non-failure. | +| `output_tail` | string | Tail of the relevant captured output for a failure (empty otherwise), ANSI escape codes stripped. | +| `output_truncated` | boolean | Only present (`true`) when `output_tail` was dropped to keep `report-json` under its ~256 KiB size cap; absent otherwise. | + +Additive fields, only present when the corresponding feature produced them: + +| Field | Feature | Description | +|---|---|---| +| `bump` | [`allow-major`](allow-major.md) | Only on an `updated` outcome: the real release segment that changed — `major`, `minor`, `patch`, or `other` — regardless of whether this was an in-range update or one that raised the constraint. | +| `constraint_raised` | `allow-major` | Only present (`true`) when this outcome's own commit raised the declared constraint itself. | +| `beyond_constraint_version` | `allow-major` | The version a held-back beyond-constraint attempt tried. | +| `beyond_constraint_failure_kind` | `allow-major` | `resolution` or `test`, same meaning as `failure_kind` but for the held-back attempt. | +| `beyond_constraint_output_tail` | `allow-major` | Tail of the held-back attempt's captured output. | +| `beyond_constraint_output_truncated` | `allow-major` | Only present (`true`) when output was dropped to keep `report-json` under its size cap. | +| `beyond_constraint_skip_reason` | `allow-major` | Only present when `allow-major` is enabled but no attempt could be made for this package at all (e.g. a git/path dependency, an exact pin, an unparsable constraint). | +| `strategy` | [`strategy`](strategies.md) | Only present (`"batch-first"`) when `strategy: batch-first` was requested for this run. | +| `tested_in_batch` | `strategy` | Only present (`true`) on an outcome validated by the shared batch test run rather than its own dedicated test run. | +| `batch_test_failed` | `strategy` | Only present (`true`) on every outcome when the batch's own one-shot test failed and the run fell back to `per-package` for everything. | +| `bundled_with` | `strategy` | Only present on a `batch-first` outcome whose own replay step produced no lock change of its own because an earlier package's replay already pulled it to the batch's target version. Names that other package. | + +## `issue-actions` + +One object per package `create-issues` acted on this run; `[]` when `create-issues` is off. + +```json +[{"package": "idna", "action": "create", "issue": null}] +``` + +| Field | Type | Description | +|---|---|---| +| `package` | string | The affected package name (or the reserved `transitive-dependencies`). | +| `action` | string | `create`, `update`, or `close`. | +| `issue` | number \| null | The existing/created issue number, or `null` for a not-yet-created issue (always `null` in `dry-run`, and for a failed create). | +| `error` | string | Additive: only present when this specific action's own `gh` call failed. | + +See [`create-issues`](create-issues.md) for the identity rules and decision table behind this. + +## `transitive-report` + +A single JSON object, or the JSON literal `null` when `update-transitive` is off (the default) or the run aborted before the step ran. + +```json +{"status": "updated", "changed_packages": [{"name": "certifi", "old": "2024.2.2", "new": "2024.7.4"}], "failure_kind": null, "output_tail": ""} +``` + +| Field | Type | Description | +|---|---|---| +| `status` | string | `updated`, `failed`, or `unchanged`. | +| `changed_packages` | array | `{name, old, new}` for every package the lock's before/after snapshot differed on — not just the package(s) the refresh command directly targeted. Present even for a discarded `failed` attempt. | +| `failure_kind` | string | Only present when `status` is `failed`: `resolution` or `test`, same meaning as `report-json`'s field. | +| `output_tail` | string | Only present when `status` is `failed`. | + +See [`update-transitive`](update-transitive.md). diff --git a/docs/manual/package-managers.md b/docs/manual/package-managers.md new file mode 100644 index 0000000..e4a367b --- /dev/null +++ b/docs/manual/package-managers.md @@ -0,0 +1,27 @@ +# Package managers + +The action supports Poetry and uv projects; `package-manager` (default `auto`) detects which one from the lock file present in `directory` — `poetry.lock` or `uv.lock`. Set it explicitly (`poetry` or `uv`) if you'd rather not rely on detection. + +## Poetry + +Nothing extra to configure beyond `poetry-version` (default `2.4.3`). Top-level packages come from `poetry show -T`; groups map straight onto Poetry's own `--with`/`--without`/`--only` flags for `install`/`sync` (see [Dependency groups](dependency-groups.md)). + +A `[dependency-groups]` (PEP 735) name is only a valid group when `poetry-version` is `2.2` or later — PEP 735 support was added in Poetry 2.2.0. Naming such a group with an older `poetry-version` fails fast with a message saying so. + +## uv + +`uv-sync-args` (default `""`) overrides the default `--all-groups --all-extras` selection passed to `uv sync` — parsed as shell arguments, e.g. `--extra cpu --group dev`. + +### `tool.uv.conflicts` + +If the project declares [`tool.uv.conflicts`](https://docs.astral.sh/uv/concepts/projects/dependencies/#conflicting-dependencies) (mutually exclusive extras/groups, e.g. a `cpu`/`gpu` extra pair), the default `--all-groups --all-extras` selection would try to install both sides at once and `uv sync` fails outright. When that's detected and `uv-sync-args` is not set, the action falls back to `uv sync` with no extras and only the default dependency groups, and prints a `::warning::`. Set `uv-sync-args` to choose what actually gets installed. + +### Workspaces are not supported + +Only the `pyproject.toml` in `directory` is read to find top-level dependencies. A `tool.uv.workspace` root's member projects are not iterated, and workspace `uv.lock` files live at the workspace root rather than in an individual member's directory. + +### `uv run` in `test-command` re-syncs + +If `test-command` itself invokes `uv run` (e.g. `uv run pytest`) together with `with-groups`/`without-groups`/`only-groups`, be aware that `uv run` re-syncs the environment on its own first, using **uv's own default group/extra selection** — not this action's narrower one (verified against real `uv==0.12.14`: a group this action's own sync correctly excluded gets silently reinstalled for the duration of that `uv run` invocation). This never affects which packages get iterated/reported, only what a test command that itself re-syncs actually sees installed while it runs. If a test genuinely depends on a group being absent, invoke `uv run --no-sync ` (or the venv's own interpreter directly, e.g. `.venv/bin/python -m pytest`) in `test-command` instead. + +See [Dependency groups](dependency-groups.md) for the full group-selection semantics of both backends, and [How it works](how-it-works.md) for the bootstrap sequence common to both. diff --git a/docs/manual/strategies.md b/docs/manual/strategies.md new file mode 100644 index 0000000..26b80ea --- /dev/null +++ b/docs/manual/strategies.md @@ -0,0 +1,37 @@ +# `strategy` + +```yaml +strategy: 'batch-first' +``` + +`strategy` (default `per-package`) picks how the update loop spends test runs. + +## Test-run cost + +| Strategy | Happy path (every package updates cleanly) | If something fails | +|---|---|---| +| `per-package` (default) | N test runs (one per package) | N test runs, as usual | +| `batch-first` | 1 test run | up to N+1 test runs (falls back to `per-package`) | + +## `per-package` + +Today's behavior, unchanged: update, test and commit one top-level package at a time. + +## `batch-first` + +An opt-in alternative that costs only 1 test run in the happy path: + +1. **Batch:** update every top-level package at once (`poetry update ` / `uv lock --upgrade-package ...` repeated once per package plus one `uv sync`) — in-range only. If nothing changed in the lock, every package is reported `skipped` and nothing is tested. +2. **Test once**, against the whole batch. + - **Fails:** reset the lock/manifest to before the batch ran, re-sync, and run the ordinary `per-package` loop for every package instead — worst case N+1 test runs total, same final result `per-package` would have produced. `report-json` marks every outcome `batch_test_failed: true`. + - **Passes:** reset to before the batch again, then replay each changed package's own update and commit it on its own, in sequence, *without* testing in between — so the git history and PR report look exactly like a `per-package` run would have produced, just without paying for N-1 extra test runs. Once every package has been replayed, compare against the batch's own lock file: + - **Matches:** done. Every replayed package is reported `updated` with `tested_in_batch: true`. A package that produces no lock change of its own — because an earlier package's replay already pulled it to the batch's target version, most commonly a shared transitive dependency — needs no separate commit: it's still reported `updated`, tagged `bundled_with: ""` naming whichever commit the change actually lives in. + - **Diverges:** one more test run, against the diverged sequential result. Passes → keep it (still `tested_in_batch: true`). Fails → discard every replay commit and fall back to `per-package` for everything (this path does not set `batch_test_failed` — the batch's own test genuinely passed; it's the replay that couldn't be trusted). + +A re-sync failure at any point aborts the whole run, exactly like `per-package` — see [How it works](how-it-works.md#reset-and-re-sync). + +## Interaction with `allow-major` + +The batch step itself only ever performs in-range updates, even when `allow-major` is enabled. Once the batch (or its replay) has landed, `allow-major`'s beyond-constraint attempts still run exactly as they do without `batch-first`: one at a time, per package, each with its own test run. A package that gets both an in-range update from the batch *and* a further beyond-constraint attempt ends up with two commits instead of one, but is still reported as a single outcome spanning the whole journey. `batch-first` only ever saves test runs on the in-range portion of the work; `allow-major`'s own test-per-attempt cost is unchanged either way. See [`allow-major`](allow-major.md). + +`report-json` marks every outcome of a `batch-first` run with `strategy: "batch-first"`, additionally to `tested_in_batch`/`batch_test_failed`/`bundled_with`. With `strategy` left at `per-package`, the rendered report, job summary and `report-json` are byte-identical to before this feature existed. See [outputs.md](outputs.md#report-json). diff --git a/docs/manual/token-and-permissions.md b/docs/manual/token-and-permissions.md new file mode 100644 index 0000000..71e4974 --- /dev/null +++ b/docs/manual/token-and-permissions.md @@ -0,0 +1,47 @@ +# Token and permissions + +A PR opened with the default `GITHUB_TOKEN` gets no CI checks. If you want checks on the PR this action opens, use a fine-grained PAT or a GitHub App installation token instead, passed to **both** `actions/checkout` and this action. + +## Why `GITHUB_TOKEN` isn't enough + +A pull request opened with the default, ephemeral `GITHUB_TOKEN` does **not** trigger other `pull_request` (or `pull_request_target`) workflows — this is a deliberate GitHub Actions restriction to stop workflows from recursively triggering themselves. If you rely on CI checks running against the PR this action opens, `GITHUB_TOKEN` alone will leave it with no checks at all. + +## Use a PAT or App token + +To get normal CI on the resulting PR, use a fine-grained personal access token or a GitHub App installation token, with at least: + +- `contents: write` (push the update branch) +- `pull-requests: write` (create/edit the PR, add labels) +- `issues: write` too, only if you enable [`create-issues`](create-issues.md) — it also covers the read-only `gh issue list` lookup `create-issues` needs, so no separate `issues: read` is needed + +## Pass it in two places + +That token has to go to **both** places, because two different things authenticate independently: + +```yaml +- uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + +- uses: friedrichwilken/test-gated-python-updates@v2 + with: + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +1. `actions/checkout`'s `token:` input — `actions/checkout` persists this as the git credential for the checkout, and this action's own `git push` relies entirely on those persisted credentials; it never receives or handles a token itself for the push. +2. This action's `github_token` input — used for `gh pr create`/`gh pr edit` and, when `create-issues` is enabled, `gh issue list`/`create`/`edit`/`comment`/`close`, which all shell out to the `gh` CLI authenticated via `GH_TOKEN`. + +## Sticking with `GITHUB_TOKEN` + +If you'd rather stick with `GITHUB_TOKEN` (accepting that the PR gets no automatic checks, or that you trigger checks another way, e.g. `workflow_run`), the calling workflow must still declare the permissions explicitly — the repository default for `GITHUB_TOKEN` is often read-only: + +```yaml +permissions: + contents: write + pull-requests: write + # issues: write # only if create-issues is enabled +``` + +## The repository setting + +Either way, **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must be enabled, or PR creation is rejected outright regardless of which token is used. diff --git a/docs/manual/update-transitive.md b/docs/manual/update-transitive.md new file mode 100644 index 0000000..8da09cc --- /dev/null +++ b/docs/manual/update-transitive.md @@ -0,0 +1,30 @@ +# `update-transitive` + +```yaml +update-transitive: 'true' +``` + +Opt-in (default `false`), run-level (not per-package) final step: after the top-level loop (either [`strategy`](strategies.md)) and any [`allow-major`](allow-major.md) beyond-constraint attempts have both finished, it refreshes *everything* — transitive dependencies included, and any top-level package `with-groups`/`without-groups`/`only-groups` left out of this run's own iteration — that is still updatable within its already-declared constraints. + +Every other feature only ever touches *top-level* packages (`poetry show -T` / this action's own uv equivalent); this is the one step that reaches transitive dependencies at all. + +## What it does + +1. **Refresh:** `poetry update --lock --no-interaction` / `uv lock --upgrade`, then a sync — verified to only ever change the lock file, never `pyproject.toml`. If the lock doesn't change, nothing further happens (reported `"unchanged"`). +2. **Test once**, against the refreshed lock — exactly like every other tested step in this action. + - **Passes:** committed as its own commit, `Update transitive dependencies`. + - **Fails**, or the refresh command itself fails to resolve: the lock file is reset to its pre-step state and the environment re-synced; nothing is committed. + +## Its own output + +Unlike a per-package update, one lock-wide refresh has no single old/new version of its own, so it's **not** folded into `report-json`'s per-package array — it gets its own `transitive-report` output: `{status, changed_packages, failure_kind, output_tail}`. `changed_packages` lists every package the lock's before/after snapshot differed on, not just whichever package(s) the refresh command directly targeted (moving one package can move others) — present even for a discarded `"failed"` attempt, to show what would have changed. See [outputs.md](outputs.md#transitive-report) for the full schema. + +The PR body / job summary get a new "🔁 Transitive dependencies" section (only rendered when the step actually ran) listing every changed package's old → new version, or, on failure, the same collapsed output block used everywhere else in the report. + +## `create-issues` interaction + +A failed step files one managed issue, keyed by the reserved package name `transitive-dependencies`, closed automatically once the step later passes (or is unchanged) again. See [`create-issues`](create-issues.md#update-transitive-interaction). + +## Aborted runs + +If the run aborts — whether during the top-level loop or during this step's own re-sync — the report still reflects whatever `transitive-report` state exists at that point (the step's own `"failed"` outcome if the abort happened while discarding it, otherwise `null`), the same "partial, not pushed" handling described in [How it works](how-it-works.md#reset-and-re-sync). diff --git a/docs/manual/versioning.md b/docs/manual/versioning.md new file mode 100644 index 0000000..6277e74 --- /dev/null +++ b/docs/manual/versioning.md @@ -0,0 +1,21 @@ +# Versioning + +```yaml +uses: friedrichwilken/test-gated-python-updates@v2 +``` + +`v2` is a major-version tag: [`release.yml`](../../.github/workflows/release.yml) force-moves it to point at the latest `v2.x.y` release every time a `vX.Y.Z` tag is pushed, so pinning to `@v2` tracks new releases within the same major version automatically. + +## Pinning by SHA + +For a reproducible, supply-chain-hardened pin, use a full commit SHA instead: + +```yaml +uses: friedrichwilken/test-gated-python-updates@ # v2.1.0 +``` + +This is the same pattern this repo's own workflows use for third-party actions (see [`check_action.yml`](../../.github/workflows/check_action.yml) or [`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml)) — a comment naming the version keeps the pin readable without giving up the exact-commit guarantee. + +## `v1` + +`v1`, the original bash-based action, is frozen at the `v1`/`v1.0.1` tags and unmaintained — it will never be moved or updated further; [`release.yml`](../../.github/workflows/release.yml) explicitly refuses to move it. See [Migrating from v1](migrating-from-v1.md) if you're still on it. diff --git a/docs/tutorials/weekly-updates.md b/docs/tutorials/weekly-updates.md new file mode 100644 index 0000000..e516496 --- /dev/null +++ b/docs/tutorials/weekly-updates.md @@ -0,0 +1,429 @@ +# Tutorial: a weekly dependency-update workflow + +This tutorial builds one GitHub Actions workflow step by step, starting from the smallest thing that works and adding one capability at a time. Every step shows the complete workflow file so far — copy-paste any step and it works on its own. New or changed lines are marked `# new`. + +All the workflows below are **uv** projects. **Using Poetry?** Everything below works unchanged — the package manager is auto-detected from your lock file (`uv.lock` vs. `poetry.lock`). The only lines that differ are marked `# Poetry: '...'` — swap in that value and you have a working Poetry workflow. + +Each step ends with what you should see when it runs. Every input used is linked to its [manual](../manual/README.md) page — read this tutorial to learn the shape of things, and the manual when you want the full detail on one of them. + +## 1. A minimal weekly run + +The action doesn't check out your repository itself, so `actions/checkout` comes first. `permissions:` is needed because the repository default for `GITHUB_TOKEN` is often read-only. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +`workflow_dispatch` lets you trigger a run by hand from the Actions tab instead of waiting for Monday. `test-command` runs in `directory` via `bash -c` — see [inputs.md](../manual/inputs.md). + +**What you should see:** on the next scheduled run (or a manual `workflow_dispatch`), a job named `update` runs, and — if any of your top-level packages have updates that pass `test-command` — a pull request titled "Update and successfully test packages" against your default branch, opened with the built-in `GITHUB_TOKEN`. + +## 2. Try it safely first + +Before trusting this against your real project, run it with `dry-run: 'true'`: it does everything — updates, tests, local commits — except pushing the branch or touching the PR. Read the result from the job summary instead. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + dry-run: 'true' # new + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +Trigger it once with `workflow_dispatch`, then open the run in the Actions tab and scroll to its job summary — the same report a real run would put in the PR body is right there. + +**What you should see:** the job summary shows the "✅ Updated" / "❌ Failed" / skipped tables, but no branch is pushed and no PR appears — `git status` in the job is clean the whole time. See [`dry-run`](../manual/dry-run.md). + +Once you're happy with what you see, remove the `dry-run: 'true'` line (or set it to `'false'`) — the rest of this tutorial builds on the real thing. + +## 3. Get CI running on the PR + +A PR opened with the default `GITHUB_TOKEN` triggers no `pull_request` workflows at all — that's a deliberate GitHub Actions restriction. If you want your normal CI to run against the PR this action opens, pass a fine-grained PAT or GitHub App token to **both** `actions/checkout` and the action itself. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} # new + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} # new +``` + +Create a fine-grained PAT (or a GitHub App installation token) with `contents: write` and `pull-requests: write`, and store it as the `DEPS_UPDATE_TOKEN` repository secret. Either way — this token or the plain `GITHUB_TOKEN` — the repository setting **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must also be on, or PR creation is rejected outright. Full detail: [Token and permissions](../manual/token-and-permissions.md). + +**What you should see:** the next PR this action opens (or updates) now also shows your repository's normal required checks running against it, the way any other PR would. + +## 4. Labels, title prefix, base branch, concurrency + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: # new + group: ${{ github.workflow }} # new + cancel-in-progress: false # new + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' # new + pr-labels: 'dependencies' # new + base-branch: 'main' # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +`pr-labels` must already exist in the repository — this action never creates one (`gh label create dependencies ...` once, or via your repo settings). `base-branch` is rarely needed (it already defaults to whichever branch is checked out) — set it explicitly if your checkout step ever lands on something other than the branch you want the PR opened against. `concurrency` stops two runs from racing to push the same fixed branch if a manual `workflow_dispatch` overlaps a scheduled run. + +**What you should see:** the PR title is prefixed `[deps] `, carries the `dependencies` label, and targets `main` explicitly. + +## 5. Faster runs: `strategy: batch-first` + +With many updatable packages, testing one at a time costs one test run per package. `strategy: batch-first` updates everything at once and tests once, falling back to the per-package loop only if that combined test fails. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' + pr-labels: 'dependencies' + base-branch: 'main' + strategy: 'batch-first' # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +**What you should see:** if your project has several updatable packages and they all still pass together, the run's log shows exactly one test invocation instead of one per package — the PR report is identical either way, plus `tested_in_batch: true` in `report-json`. See [`strategy`](../manual/strategies.md). + +## 6. Beyond the declared constraint: `allow-major` + +By default, a package whose constraint caps it below the latest release (`^1.2` when `2.0` exists) is never even attempted. `allow-major` opts in to also trying the raised constraint, falling back to the in-range update if it fails. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' + pr-labels: 'dependencies' + base-branch: 'main' + strategy: 'batch-first' + allow-major: 'true' # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +**What you should see:** for a package with a capped constraint and a newer release available, the PR now either shows it updated with `(raised)` next to its bump, or — if the raised attempt failed its tests — a new "⚠️ Held back" table naming it, with the plain in-range update applied instead. See [`allow-major`](../manual/allow-major.md). + +## 7. Track what fails: `create-issues` + +A failing package only ever shows up in the *latest* PR report. `create-issues` files one durable, auto-updating issue per failing package instead. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write # new + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' + pr-labels: 'dependencies' + base-branch: 'main' + strategy: 'batch-first' + allow-major: 'true' + create-issues: 'true' # new + issue-labels: 'dependencies' # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +`issue-labels` must already exist too, same rule as `pr-labels`. `issues: write` on the token is required — it also covers the read-only issue lookup this feature needs, so no separate `issues: read` is necessary. + +**What you should see:** the first time a package fails, a new issue titled `: update to fails (...)`, carrying a hidden identity marker and the `dependencies` label. It gets updated silently on later still-failing runs, and closes itself automatically once the package updates cleanly. See [`create-issues`](../manual/create-issues.md). + +## 8. Keep the rest fresh: `update-transitive` + +Every step so far only ever touches your *top-level* dependencies. `update-transitive` adds one final step that refreshes everything else too, still within already-declared constraints. Paired here with `without-groups` to show excluding a group from the top-level loop entirely. + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' + pr-labels: 'dependencies' + base-branch: 'main' + strategy: 'batch-first' + allow-major: 'true' + create-issues: 'true' + issue-labels: 'dependencies' + update-transitive: 'true' # new + without-groups: 'dev' # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +**uv note:** `"dev"` covers both a `dev` key in `[dependency-groups]` and uv's legacy `[tool.uv.dev-dependencies]`. **Poetry note:** the group name must be one you've actually declared (e.g. `[tool.poetry.group.dev]`) — there's no built-in `dev` group. Either way, see [Dependency groups](../manual/dependency-groups.md). + +**Re-sync note:** if `test-command` itself runs `uv run ...`, be aware it re-syncs the environment first using **uv's own** default group selection, not this action's narrower `without-groups` one — a test that must not see the excluded group needs `uv run --no-sync ...` or `.venv/bin/python` directly instead. See [Package managers § `uv run` in `test-command` re-syncs](../manual/package-managers.md#uv-run-in-test-command-re-syncs). + +**What you should see:** an extra commit, `Update transitive dependencies`, when anything transitive had room to move — and, once `without-groups: dev` is set, any package that lives only in your `dev` group no longer appears in `report-json` at all. + +## 9. Hands-off: auto-merge the PR when CI is green + +There's no `pr-number`/`pr-url` output on this action today, so the auto-merge step can't target "the PR this run just touched" directly. Instead, key a separate, `pull_request`-triggered workflow off the fixed branch name this action always uses (`deps/test-gated-updates`, or your own `branch-name` if you changed it) — the same pattern this repo uses for its own Dependabot PRs. + +Add a second workflow file, `.github/workflows/automerge-deps.yml`: + +```yaml +name: auto-merge dependency updates +on: + pull_request: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.head_ref == 'deps/test-gated-updates' + runs-on: ubuntu-latest + steps: + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.DEPS_UPDATE_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: gh pr merge --auto --squash "$PR_URL" +``` + +This only fires on a real `pull_request` event, which needs the PAT from step 3 (`GITHUB_TOKEN` PRs never trigger it). It also needs the repository setting **Settings → General → Pull Requests → "Allow auto-merge"** on, and at least one required status check configured in branch protection — otherwise `gh pr merge --auto` has nothing to wait for and merges immediately. + +**What you should see:** once your required checks pass on the update PR, it merges itself — no click required. If "Allow auto-merge" is off, `gh pr merge --auto` fails loudly instead of merging early; turn the setting on and re-run. + +## 10. The final workflow + +Two files, together: + +```yaml +name: update dependencies +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.DEPS_UPDATE_TOKEN }} + + - uses: friedrichwilken/test-gated-python-updates@v2 + with: + test-command: 'uv run pytest' # Poetry: 'poetry run pytest' + pr-title-prefix: '[deps] ' + pr-labels: 'dependencies' + base-branch: 'main' + strategy: 'batch-first' + allow-major: 'true' + create-issues: 'true' + issue-labels: 'dependencies' + update-transitive: 'true' + without-groups: 'dev' + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} +``` + +```yaml +name: auto-merge dependency updates +on: + pull_request: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.head_ref == 'deps/test-gated-updates' + runs-on: ubuntu-latest + steps: + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.DEPS_UPDATE_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: gh pr merge --auto --squash "$PR_URL" +``` + +Every input above, in the manual: + +- `test-command`, `github_token`, `directory`, `python-version`, `package-manager` — [inputs.md § Core](../manual/inputs.md#core) +- `pr-title-prefix`, `pr-labels`, `base-branch`, `branch-name` — [inputs.md § Pull request](../manual/inputs.md#pull-request) +- `strategy` — [strategies.md](../manual/strategies.md) +- `allow-major` — [allow-major.md](../manual/allow-major.md) +- `create-issues`, `issue-labels` — [create-issues.md](../manual/create-issues.md) +- `update-transitive` — [update-transitive.md](../manual/update-transitive.md) +- `without-groups` (and `with-groups`/`only-groups`) — [dependency-groups.md](../manual/dependency-groups.md) +- `poetry-version`, `uv-sync-args` — optional, backend-specific; not needed above unless you pin a Poetry release or work around `tool.uv.conflicts` — see [package-managers.md](../manual/package-managers.md) +- tokens and the `permissions:`/`issues: write` blocks — [token-and-permissions.md](../manual/token-and-permissions.md) +- how the loop, the fixed branch and the PR itself behave — [how-it-works.md](../manual/how-it-works.md) +- the PR body / job summary this produces — [output-rendering.md](../manual/output-rendering.md) + +This repository dogfoods a version of this same workflow on itself — see [`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) for a complete, currently-running example. From bbe7d6edd21ca8406ca136265cbb021b383f0de2 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:21:55 +0200 Subject: [PATCH 03/12] Guard the docs against rotting: sync, link and actionlint checks - test_readme_sync.py now checks docs/manual/inputs.md and outputs.md against action.yml instead of the (now-short) README, including that documented defaults actually match. - test_docs_examples.py: every fenced yaml uses: step for this action (old/new repo name, or ./) in README.md/docs/**/*.md must only pass with: keys that are real, current action.yml inputs. - test_docs_structure.py: README.md stays <=70 lines with no table wider than 3 columns and no inputs table at all; every relative link (and #anchor, via a small GitHub-slug implementation) in README.md and docs/**/*.md must resolve. - New docs CI job (check_action.yml): renders every complete workflow example from the docs - both as written (uv) and a mechanically generated Poetry rendition (swapping in every `# Poetry: ...` value) - against the local action.yml (uses: ./) and runs actionlint on the result, so a documented input/value actionlint would reject fails CI. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/lint_docs_workflows.py | 138 ++++++++++++++++++++++ .github/workflows/check_action.yml | 16 +++ tests/unit/test_docs_examples.py | 154 +++++++++++++++++++++++++ tests/unit/test_docs_structure.py | 129 +++++++++++++++++++++ tests/unit/test_readme_sync.py | 140 +++++++++++++++------- 5 files changed, 533 insertions(+), 44 deletions(-) create mode 100644 .github/scripts/lint_docs_workflows.py create mode 100644 tests/unit/test_docs_examples.py create mode 100644 tests/unit/test_docs_structure.py diff --git a/.github/scripts/lint_docs_workflows.py b/.github/scripts/lint_docs_workflows.py new file mode 100644 index 0000000..eccb3c5 --- /dev/null +++ b/.github/scripts/lint_docs_workflows.py @@ -0,0 +1,138 @@ +"""CI-only (see check_action.yml's `docs` job): renders every complete +workflow example documented in README.md/docs/**/*.md against the local +action.yml (`uses: ./`) and runs actionlint on the result - a documented +`with:` key/value that actionlint itself would reject (not just an unknown +input, which tests/unit/test_docs_examples.py already catches) fails CI. + +Also renders a mechanical Poetry rendition of each example: every line +carrying a `# Poetry: ` (or `# new - Poetry: `) trailing +comment has its value swapped in for the uv one before linting, so the +Poetry variant documented only as an inline comment throughout the +tutorial is actually validated too, not just eyeballed. + +Deliberately a small, dependency-free, line-based scan (no PyYAML) - same +style as tests/unit/test_docs_examples.py, which this intentionally +duplicates rather than imports (same reasoning as test_action_yml.py's own +independent scanner: these are two different consumers of the same +convention, kept decoupled on purpose). +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +USES_TARGET_RE = re.compile( + r"uses:(\s*)(friedrichwilken/" + r"(?:update-poetry-dependencies|test-gated-python-updates)(?:@\S*)?)" +) +POETRY_LINE_RE = re.compile( + r"^(?P\s*)(?P[A-Za-z0-9_-]+:\s*)(?P.*?)\s*#.*Poetry:\s*(?P.+?)\s*$" +) + + +def markdown_files() -> list[Path]: + files = [ROOT / "README.md"] + files += sorted((ROOT / "docs").rglob("*.md")) + return files + + +def yaml_blocks(md_text: str) -> list[str]: + """Every fenced ```yaml ... ``` block's body, in order.""" + blocks = [] + lines = md_text.splitlines() + i = 0 + while i < len(lines): + if lines[i].strip() == "```yaml": + j = i + 1 + body = [] + while j < len(lines) and lines[j].strip() != "```": + body.append(lines[j]) + j += 1 + blocks.append("\n".join(body)) + i = j + 1 + else: + i += 1 + return blocks + + +def is_complete_workflow(block: str) -> bool: + """A block that stands on its own as a runnable workflow file - has a + `name:`, `on:` and `jobs:` top-level key - as opposed to a short + fragment (a single step, a `permissions:` snippet, ...).""" + return bool( + re.search(r"^name:\s", block, re.MULTILINE) + and re.search(r"^on:", block, re.MULTILINE) + and re.search(r"^jobs:", block, re.MULTILINE) + ) + + +def rewrite_uses_to_local(text: str) -> str: + """`uses: friedrichwilken/@...` -> `uses: ./`, so + actionlint validates `with:` against the *local* action.yml instead of + trying (and failing) to resolve a remote action it cannot see.""" + return USES_TARGET_RE.sub(lambda m: f"uses:{m.group(1)}./", text) + + +def poetrify(text: str) -> str: + """Apply every `# Poetry: ` inline comment: swap that line's + value for the Poetry one, dropping the comment.""" + out_lines = [] + for line in text.splitlines(): + m = POETRY_LINE_RE.match(line) + if m: + out_lines.append(f"{m.group('indent')}{m.group('key')}{m.group('newval')}") + else: + out_lines.append(line) + return "\n".join(out_lines) + + +def workflow_filename(source: Path, index: int, variant: str) -> str: + stem = source.relative_to(ROOT).as_posix().replace("/", "_").rsplit(".", 1)[0] + return f"{stem}_{index}_{variant}.yml" + + +def render_all(workflows_dir: Path) -> int: + rendered = 0 + for path in markdown_files(): + text = path.read_text(encoding="utf-8") + for index, block in enumerate(yaml_blocks(text)): + if not is_complete_workflow(block): + continue + for variant, render in (("uv", lambda b: b), ("poetry", poetrify)): + rendered_text = rewrite_uses_to_local(render(block)) + out_path = workflows_dir / workflow_filename(path, index, variant) + out_path.write_text(rendered_text + "\n", encoding="utf-8") + rendered += 1 + return rendered + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="docs-workflow-lint-") as tmp: + workdir = Path(tmp) + workflows_dir = workdir / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + shutil.copy2(ROOT / "action.yml", workdir / "action.yml") + # actionlint locates the repository root (to resolve `uses: ./`) + # by walking up to a `.git` directory - this scratch dir needs one + # of its own, it is never pushed anywhere. + subprocess.run(["git", "init", "-q"], cwd=workdir, check=True, stdout=subprocess.DEVNULL) + + rendered = render_all(workflows_dir) + print(f"rendered {rendered} workflow file(s) into {workflows_dir}") + if rendered == 0: + print("::error::no complete workflow examples found in README.md/docs/**/*.md") + return 1 + + result = subprocess.run(["actionlint"], cwd=workdir, check=False) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/check_action.yml b/.github/workflows/check_action.yml index 0125f91..7d008a0 100644 --- a/.github/workflows/check_action.yml +++ b/.github/workflows/check_action.yml @@ -16,6 +16,22 @@ jobs: - name: Lint workflows and action.yml uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install actionlint + env: + ACTIONLINT_VERSION: '1.7.12' + run: | + bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) "$ACTIONLINT_VERSION" ./bin + echo "$PWD/bin" >> "$GITHUB_PATH" + + - name: Render documented workflow examples (uv and Poetry) and lint them + run: python3 .github/scripts/lint_docs_workflows.py + unit-tests: runs-on: ubuntu-latest steps: diff --git a/tests/unit/test_docs_examples.py b/tests/unit/test_docs_examples.py new file mode 100644 index 0000000..779b15f --- /dev/null +++ b/tests/unit/test_docs_examples.py @@ -0,0 +1,154 @@ +"""Keeps every documented `uses: ` example (README.md and +docs/**/*.md) honest against action.yml: every `with:` key it passes must be +a real, current input - catches both a typo and a removed/renamed input +left behind in the docs. Deliberately a small, dependency-free, line-based +scan (no PyYAML) - same style as test_action_yml.py/test_readme_sync.py. + +Reused (in spirit - kept independently duplicated, same as +test_action_yml.py's own scanner is independent of action.yml's own +production code) by the `docs` CI job, which additionally renders each +complete workflow example and runs actionlint on it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ACTION_YML = ROOT / "action.yml" + +# Matches this action, old or new repo name, at any ref, or the local `./` +# this repo's own workflows use. +ACTION_USES_RE = re.compile( + r"^(friedrichwilken/(update-poetry-dependencies|test-gated-python-updates)(@\S*)?|\./)$" +) + + +def known_inputs() -> set[str]: + text = ACTION_YML.read_text(encoding="utf-8") + names = [] + in_inputs = False + for line in text.splitlines(): + if re.match(r"^inputs:\s*$", line): + in_inputs = True + continue + if not in_inputs: + continue + if line.strip() == "": + continue + indent = len(line) - len(line.lstrip()) + if indent == 0: + break + if indent == 2 and re.match(r"^ {2}[A-Za-z0-9_-]+:\s*$", line): + names.append(line.strip().rstrip(":")) + return set(names) + + +def markdown_files() -> list[Path]: + files = [ROOT / "README.md"] + files += sorted((ROOT / "docs").rglob("*.md")) + return files + + +def yaml_blocks(md_text: str) -> list[str]: + """Every fenced ```yaml ... ``` block's body, in order.""" + blocks = [] + lines = md_text.splitlines() + i = 0 + while i < len(lines): + if lines[i].strip() == "```yaml": + j = i + 1 + body = [] + while j < len(lines) and lines[j].strip() != "```": + body.append(lines[j]) + j += 1 + blocks.append("\n".join(body)) + i = j + 1 + else: + i += 1 + return blocks + + +def with_key_groups_for_this_action(yaml_text: str) -> list[list[str]]: + """One list of `with:` keys per `uses:` step targeting this action + found in `yaml_text` (an empty list for a step with no `with:` block at + all, e.g. a bare `actions/checkout@v4`-style step that happened to + match).""" + lines = yaml_text.splitlines() + groups: list[list[str]] = [] + for idx, line in enumerate(lines): + stripped = line.strip() + m = re.match(r"^-?\s*uses:\s*(\S+)", stripped) + if not m or not ACTION_USES_RE.match(m.group(1)): + continue + + leading_ws = len(line) - len(line.lstrip(" ")) + # "- uses: x" puts the *key* two columns to the right of the dash; + # a sibling "with:" aligns with that key, not with the dash. + uses_indent = leading_ws + 2 if line.lstrip(" ").startswith("- ") else leading_ws + + with_indent = None + keys: list[str] = [] + j = idx + 1 + while j < len(lines): + candidate = lines[j] + if candidate.strip() == "": + j += 1 + continue + cand_indent = len(candidate) - len(candidate.lstrip(" ")) + if with_indent is None: + if cand_indent < uses_indent: + break # left this step without ever finding a with: + if cand_indent == uses_indent and candidate.strip() == "with:": + with_indent = cand_indent + j += 1 + continue + if cand_indent <= with_indent: + break + key_match = re.match(r"^\s*([A-Za-z0-9_-]+):", candidate) + if key_match: + keys.append(key_match.group(1)) + j += 1 + groups.append(keys) + return groups + + +def test_action_uses_regex_matches_expected_targets(): + for ok in ( + "friedrichwilken/test-gated-python-updates@v2", + "friedrichwilken/test-gated-python-updates@abc123", + "friedrichwilken/update-poetry-dependencies@main", + "./", + ): + assert ACTION_USES_RE.match(ok), ok + for bad in ("actions/checkout@v4", "friedrichwilken/some-other-action@v1"): + assert not ACTION_USES_RE.match(bad), bad + + +def test_every_documented_with_key_is_a_real_action_input(): + known = known_inputs() + assert known, "expected action.yml to declare at least one input" + + offenders = [] + for path in markdown_files(): + text = path.read_text(encoding="utf-8") + for block in yaml_blocks(text): + for keys in with_key_groups_for_this_action(block): + for key in keys: + if key not in known: + offenders.append( + f"{path.relative_to(ROOT)}: documented `with:` key " + f"{key!r} is not a real action.yml input" + ) + assert offenders == [], "\n".join(offenders) + + +def test_at_least_one_documented_example_is_scanned(): + # Guards against the scan above silently finding nothing to check (e.g. + # a heading/fence format change breaking yaml_blocks()). + total = 0 + for path in markdown_files(): + for block in yaml_blocks(path.read_text(encoding="utf-8")): + total += len(with_key_groups_for_this_action(block)) + assert total > 0, "expected at least one documented `uses:` step for this action" diff --git a/tests/unit/test_docs_structure.py b/tests/unit/test_docs_structure.py new file mode 100644 index 0000000..b70b47c --- /dev/null +++ b/tests/unit/test_docs_structure.py @@ -0,0 +1,129 @@ +"""Keeps the top-level docs restructure (README.md kept short, docs/ +cross-links honest) from rotting. Deliberately small, dependency-free, +line-based text scans - same style as test_action_yml.py/test_readme_sync.py. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +README = ROOT / "README.md" + +README_MAX_LINES = 70 +README_MAX_TABLE_COLUMNS = 3 + +_FENCE_RE = re.compile(r"^\s*```") +_ATX_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$") +_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") +_LINK_RE = re.compile(r"\[[^\]\n]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)") + + +def _all_markdown_files() -> list[Path]: + files = [README] + files += sorted((ROOT / "docs").rglob("*.md")) + return files + + +def _strip_code_fences(lines: list[str]) -> list[tuple[int, str]]: + """Return (0-based index, line) pairs for lines outside fenced code + blocks.""" + kept = [] + in_fence = False + for i, line in enumerate(lines): + if _FENCE_RE.match(line): + in_fence = not in_fence + continue + if not in_fence: + kept.append((i, line)) + return kept + + +def _table_row_columns(line: str) -> int: + """Number of columns in a `| a | b | c |` (or `a | b | c`) row.""" + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return len(stripped.split("|")) + + +def slugify(heading: str) -> str: + """A simple version of GitHub's own heading-anchor slug rule: lowercase, + strip punctuation (anything that isn't alphanumeric, a space or a + hyphen), then turn spaces into hyphens.""" + text = re.sub(r"[^A-Za-z0-9 \-]", "", heading) + text = text.lower().strip() + text = re.sub(r"\s+", "-", text) + return text + + +def _headings(path: Path) -> set[str]: + lines = path.read_text(encoding="utf-8").splitlines() + slugs: dict[str, int] = {} + result = set() + for _, line in _strip_code_fences(lines): + m = _ATX_HEADING_RE.match(line) + if not m: + continue + slug = slugify(m.group(2)) + n = slugs.get(slug, 0) + slugs[slug] = n + 1 + result.add(slug if n == 0 else f"{slug}-{n}") + return result + + +def test_readme_line_limit(): + lines = README.read_text(encoding="utf-8").splitlines() + assert len(lines) <= README_MAX_LINES, ( + f"README.md has {len(lines)} lines, must be <= {README_MAX_LINES} " + "(everything else belongs in docs/)" + ) + + +def test_readme_has_no_wide_tables(): + lines = README.read_text(encoding="utf-8").splitlines() + offenders = [] + for i, line in _strip_code_fences(lines): + if not _TABLE_ROW_RE.match(line) or _TABLE_SEPARATOR_RE.match(line): + continue + columns = _table_row_columns(line) + if columns > README_MAX_TABLE_COLUMNS: + offenders.append(f"README.md:{i + 1}: table row with {columns} columns: {line!r}") + assert offenders == [], "\n".join(offenders) + + +def test_readme_has_no_input_table(): + text = README.read_text(encoding="utf-8") + for name in ("python-version", "package-manager", "poetry-version", "github_token"): + assert f"`{name}`" not in text, ( + f"README.md should not contain a full inputs table - found `{name}`; " + "inputs belong in docs/manual/inputs.md" + ) + + +def test_relative_links_resolve(): + offenders = [] + for path in _all_markdown_files(): + lines = path.read_text(encoding="utf-8").splitlines() + for _, line in _strip_code_fences(lines): + for target in _LINK_RE.findall(line): + if re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:", target): # http:, mailto:, etc. + continue + file_part, _, anchor = target.partition("#") + if file_part == "": + resolved = path + else: + resolved = (path.parent / file_part).resolve() + if not resolved.is_file(): + offenders.append(f"{path.relative_to(ROOT)}: broken link target {target!r}") + continue + if anchor and anchor not in _headings(resolved): + offenders.append( + f"{path.relative_to(ROOT)}: anchor {target!r} has no matching " + f"heading in {resolved.relative_to(ROOT)}" + ) + assert offenders == [], "\n".join(offenders) diff --git a/tests/unit/test_readme_sync.py b/tests/unit/test_readme_sync.py index efdd764..f062aa5 100644 --- a/tests/unit/test_readme_sync.py +++ b/tests/unit/test_readme_sync.py @@ -1,24 +1,35 @@ -"""Keeps README.md's Inputs/Outputs tables honest against action.yml. +"""Keeps docs/manual/inputs.md and docs/manual/outputs.md honest against +action.yml (the inputs/outputs tables used to live in README.md itself - +see docs/manual/README.md for why they moved). Deliberately a small, +dependency-free, line-based text scan (no PyYAML dependency) - the same +style as test_action_yml.py's own run: block scanner. -Deliberately a small, dependency-free, line-based text scan (no PyYAML -dependency) - the same style as test_action_yml.py's run: block scanner. -It only checks that every input/output *name* declared in action.yml shows -up as a table cell in the matching README section; it does not attempt to -diff descriptions or defaults. +Checks, per input: the name shows up as a table cell somewhere in +inputs.md, and the *default* shown there matches action.yml's own default +(or, for the one required input with no default, that the table says so +rather than showing a stale value). Outputs have no defaults, so only +presence is checked for them. """ import re from pathlib import Path -ACTION_YML = Path(__file__).resolve().parents[2] / "action.yml" -README = Path(__file__).resolve().parents[2] / "README.md" +ROOT = Path(__file__).resolve().parents[2] +ACTION_YML = ROOT / "action.yml" +INPUTS_MD = ROOT / "docs" / "manual" / "inputs.md" +OUTPUTS_MD = ROOT / "docs" / "manual" / "outputs.md" +_TABLE_ROW_RE = re.compile(r"^\s*\|\s*`([A-Za-z0-9_-]+)`\s*\|(.*)\|\s*(?:\|.*)?$") +_BACKTICK_RE = re.compile(r"`([^`]*)`") -def _keys_in_section(text: str, section: str) -> list[str]: - """Return the top-level keys (2-space indented) directly under a - top-level `section:` key in action.yml, e.g. 'inputs' or 'outputs'.""" - keys = [] + +def _keys_and_defaults_in_section(text: str, section: str) -> dict[str, str | None]: + """Return {name: default-string-or-None} for the top-level keys + (2-space indented) directly under a top-level `section:` key in + action.yml, e.g. 'inputs' or 'outputs'.""" + result: dict[str, str | None] = {} in_section = False + current: str | None = None for line in text.splitlines(): if re.match(rf"^{section}:\s*$", line): in_section = True @@ -31,47 +42,88 @@ def _keys_in_section(text: str, section: str) -> list[str]: if indent == 0: break if indent == 2 and re.match(r"^ {2}[A-Za-z0-9_-]+:\s*$", line): - keys.append(line.strip().rstrip(":")) - return keys - - -def _readme_section(heading: str) -> str: - """Return the README.md text between a `### ` line and the - next `###` (or end of file), so the presence check below only looks at - that section's own table rather than anywhere in the whole README.""" - text = README.read_text(encoding="utf-8") - match = re.search( - rf"^### {re.escape(heading)}\s*$(.*?)(?=^### |\Z)", - text, - re.MULTILINE | re.DOTALL, - ) - assert match, f"expected a '### {heading}' section in README.md" - return match.group(1) + current = line.strip().rstrip(":") + result[current] = None + continue + if indent == 4 and current is not None: + m = re.match(r"^ {4}default:\s*(.*)$", line) + if m: + result[current] = m.group(1).strip().strip("'\"") + return result + + +def _table_rows(text: str) -> dict[str, str]: + """Every `| \\`name\\` | ... |` row anywhere in a manual page, mapping + name -> the raw text of the next (default) column, if any.""" + rows: dict[str, str] = {} + for line in text.splitlines(): + m = _TABLE_ROW_RE.match(line) + if not m: + continue + name, rest = m.group(1), m.group(2) + rows.setdefault(name, rest.strip()) + return rows + + +def _normalize_documented_default(cell: str) -> str: + m = _BACKTICK_RE.search(cell) + if not m: + return cell.strip() + value = m.group(1) + return "" if value == '""' else value def test_action_yml_has_inputs_and_outputs(): text = ACTION_YML.read_text(encoding="utf-8") - assert _keys_in_section(text, "inputs"), "expected action.yml to declare at least one input" - assert _keys_in_section(text, "outputs"), "expected action.yml to declare at least one output" + assert _keys_and_defaults_in_section(text, "inputs"), ( + "expected action.yml to declare at least one input" + ) + assert _keys_and_defaults_in_section(text, "outputs"), ( + "expected action.yml to declare at least one output" + ) + +def test_every_action_yml_input_is_documented_in_inputs_md(): + action_text = ACTION_YML.read_text(encoding="utf-8") + inputs = _keys_and_defaults_in_section(action_text, "inputs") + documented = _table_rows(INPUTS_MD.read_text(encoding="utf-8")) + + missing = [name for name in inputs if name not in documented] + assert missing == [], f"action.yml inputs missing from docs/manual/inputs.md: {missing}" -def test_every_action_yml_input_is_documented_in_the_readme_inputs_table(): + +def test_documented_input_defaults_match_action_yml(): action_text = ACTION_YML.read_text(encoding="utf-8") - inputs_section = _readme_section("Inputs") + inputs = _keys_and_defaults_in_section(action_text, "inputs") + documented = _table_rows(INPUTS_MD.read_text(encoding="utf-8")) - names = _keys_in_section(action_text, "inputs") - missing = [ - name for name in names if not re.search(rf"\|\s*{re.escape(name)}\s*\|", inputs_section) - ] - assert missing == [], f"action.yml inputs missing from the README Inputs table: {missing}" + mismatches = [] + for name, default in inputs.items(): + cell = documented.get(name) + if cell is None: + continue # already reported by the "missing" test above + if default is None: + # No default in action.yml == required (only github_token today): + # the table must say so, not show a stale placeholder value. + if "required" not in cell.lower() and "none" not in cell.lower(): + mismatches.append( + f"{name}: action.yml has no default (required), " + f"docs/manual/inputs.md shows {cell!r}" + ) + continue + documented_default = _normalize_documented_default(cell) + if documented_default != default: + mismatches.append( + f"{name}: action.yml default is {default!r}, " + f"docs/manual/inputs.md shows {documented_default!r} (raw: {cell!r})" + ) + assert mismatches == [], "\n".join(mismatches) -def test_every_action_yml_output_is_documented_in_the_readme_outputs_table(): +def test_every_action_yml_output_is_documented_in_outputs_md(): action_text = ACTION_YML.read_text(encoding="utf-8") - outputs_section = _readme_section("Outputs") + outputs = _keys_and_defaults_in_section(action_text, "outputs") + documented = _table_rows(OUTPUTS_MD.read_text(encoding="utf-8")) - names = _keys_in_section(action_text, "outputs") - missing = [ - name for name in names if not re.search(rf"\|\s*{re.escape(name)}\s*\|", outputs_section) - ] - assert missing == [], f"action.yml outputs missing from the README Outputs table: {missing}" + missing = [name for name in outputs if name not in documented] + assert missing == [], f"action.yml outputs missing from docs/manual/outputs.md: {missing}" From 8008c34d73cfd34917917fa57704a06dbdb129ae Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:23:31 +0200 Subject: [PATCH 04/12] Fix docs CI job: create ./bin before downloading actionlint into it download-actionlint.bash requires its target directory to already exist. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/check_action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check_action.yml b/.github/workflows/check_action.yml index 7d008a0..b2395b6 100644 --- a/.github/workflows/check_action.yml +++ b/.github/workflows/check_action.yml @@ -26,6 +26,7 @@ jobs: env: ACTIONLINT_VERSION: '1.7.12' run: | + mkdir -p ./bin bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) "$ACTIONLINT_VERSION" ./bin echo "$PWD/bin" >> "$GITHUB_PATH" From e37d51c8ff631012276fec59a1196d1edf3d92ca Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:53:06 +0200 Subject: [PATCH 05/12] README: real report sample, Why section, PR-creation setting note - Replace the "why" bullets with an owner-provided "Why" section explaining the actual problem this action solves; bold Poetry/uv in the opening sentence. - Replace the "What you get" example with the REAL rendered output of report.render_body() (generated from a small fixed UpdateResult, not hand-typed) so it can't silently drift - a new test asserts the README's sample rows match the real renderer's output. - Add the missing "Allow GitHub Actions to create and approve pull requests" repo-setting note next to the token note, so the quick start actually works by copy-paste. - Add docs/manual/comparison.md ("How is this different from Dependabot / Renovate?"), linked from the README's links section and the manual index - a factual trade-off comparison, not marketing. Co-Authored-By: Claude Fable 5.1 --- README.md | 50 +++++++++++-------- docs/manual/comparison.md | 17 +++++++ .../{output-rendering.md => pr-report.md} | 0 3 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 docs/manual/comparison.md rename docs/manual/{output-rendering.md => pr-report.md} (100%) diff --git a/README.md b/README.md index e28c2c5..13d7c47 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Test-gated Python updates -A GitHub Action that updates your Poetry or uv dependencies one package at a time, running your tests after each one, and opens a single pull request with the results. +A GitHub Action that updates your **Poetry** or **uv** dependencies one package at a time, running your tests after each one, and opens a single pull request with the results. -- **Tested per package** — only updates that pass your test command are kept; the rest are dropped, not forced on you. -- **One pull request, not fifteen** — reused and updated every run instead of piling up duplicates. -- **Failures are reported, not blocking** — a report table (and, optionally, a tracked issue), never a broken build. +## Why + +A bulk dependency bump that turns CI red tells you *something* broke, but not *what*. In Python, breaking API changes often only show up when the tests run, so you end up untangling the PR by hand. Bumping one dependency per PR avoids that, but costs a CI run and a review for every package. + +This action does the untangling for you. Everything that passes your tests lands in **one green PR**. The packages that break your app are **named, with the test output**, so you (or an agent) can fix exactly those. ## Quick start (uv) @@ -12,7 +14,7 @@ A GitHub Action that updates your Poetry or uv dependencies one package at a tim name: update dependencies on: schedule: - - cron: '0 6 * * 1' + - cron: '0 6 * * 1' # <- every Monday at 06:00 UTC workflow_dispatch: permissions: @@ -27,36 +29,42 @@ jobs: - uses: friedrichwilken/test-gated-python-updates@v2 with: - test-command: 'uv run pytest' + test-command: 'uv run pytest' # <- runs after every update github_token: ${{ secrets.GITHUB_TOKEN }} ``` Using Poetry? Change `test-command` to `'poetry run pytest'` — everything else auto-detects. Full walkthrough: [tutorial](docs/tutorials/weekly-updates.md). -The default `GITHUB_TOKEN` above won't trigger your CI on the PR it opens — see [token and permissions](docs/manual/token-and-permissions.md) for why, and for a PAT that fixes it. +The default `GITHUB_TOKEN` above won't trigger your CI on the PR it opens — see [token and permissions](docs/manual/token-and-permissions.md) for why, and for a PAT that fixes it. PR creation also needs the repo setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** (off by default) — same page. ## What you get - A pull request with a report, e.g.: -| Package | Result | Detail | -|---|---|---| -| six | updated | 1.16.0 → 1.17.0 | -| idna | failed | test failed at 3.7 | +```markdown +## ✅ Updated -## Going further +| package | old | new | +| --- | --- | --- | +| six | 1.16.0 | 1.17.0 | -- [`allow-major`](docs/manual/allow-major.md) — attempt updates beyond the declared constraint. -- [`strategy: batch-first`](docs/manual/strategies.md) — cut N test runs down to about 1. -- [`create-issues`](docs/manual/create-issues.md) — track failures as durable GitHub issues. -- [`update-transitive`](docs/manual/update-transitive.md) — refresh transitive dependencies too. -- [Dependency groups](docs/manual/dependency-groups.md) — `with-groups`/`without-groups`/`only-groups`. -- [`dry-run` and outputs](docs/manual/dry-run.md) — preview a run, or consume `report-json` yourself. -- [Token and permissions](docs/manual/token-and-permissions.md) — get real CI running on the PR. +## 🛑 Failed -## Learn more +| package | current | attempted | reason | +| --- | --- | --- | --- | +| idna | 3.6 | 3.7 | tests failed | +``` + +## Going further +- [`allow-major`](docs/manual/allow-major.md) — updates beyond the declared constraint. +- [`strategy: batch-first`](docs/manual/strategies.md) — cut N test runs to about 1. +- [`create-issues`](docs/manual/create-issues.md) — track failures as durable issues. +- [`update-transitive`](docs/manual/update-transitive.md) — refresh transitive deps too. +- [Dependency groups](docs/manual/dependency-groups.md) — with/without/only-groups. +- [`dry-run` and outputs](docs/manual/dry-run.md) — preview a run, or consume `report-json`. +## Learn more - [Tutorial: a weekly dependency-update workflow](docs/tutorials/weekly-updates.md) - [Manual](docs/manual/README.md) — full reference for every input and output. +- [How is this different from Dependabot / Renovate?](docs/manual/comparison.md) - [Migrating from v1](docs/manual/migrating-from-v1.md) - [This repo's own weekly workflow](.github/workflows/update_dependencies.yml) — a live example. diff --git a/docs/manual/comparison.md b/docs/manual/comparison.md new file mode 100644 index 0000000..d9ba964 --- /dev/null +++ b/docs/manual/comparison.md @@ -0,0 +1,17 @@ +# How is this different from Dependabot / Renovate? + +Dependabot and Renovate cover many ecosystems, open PRs for security advisories, and can group updates into one PR — but a grouped PR is all-or-nothing: if one package in the group breaks your tests, the whole PR is red and you're back to untangling it by hand. This action only ever does Poetry/uv, but tests every package individually first: what passes ships in one green PR, what fails is dropped and reported (or filed as an issue), and nothing you'd have to untangle ever lands in the PR in the first place. + +## The trade-offs, factually + +| | This action | Dependabot / Renovate | +|---|---|---| +| Ecosystem coverage | Poetry and uv only | Dozens of ecosystems ([Dependabot's supported list](https://docs.github.com/en/code-security/dependabot/ecosystems-supported-by-dependabot), [Renovate's supported managers](https://docs.renovatebot.com/modules/manager/)) | +| Security advisories | No — this is an update runner, not a vulnerability scanner | Yes — both can open dedicated security-update PRs from advisory data | +| Grouped updates | Every top-level package is tested on its own by default; a failing one is dropped, not merged into the group ([`strategy: batch-first`](strategies.md) trades some of that isolation back for speed, with an automatic fallback to per-package on failure) | Both support grouping ([Dependabot groups](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates#grouping-dependency-updates), [Renovate groups](https://docs.renovatebot.com/configuration-options/#groupname)) — but a group PR lives or dies together; one failing package fails the whole group's CI run | +| Failures | Reported in the PR body, and optionally [tracked as a durable issue per package](create-issues.md) | Surface as a red check on the (possibly grouped) PR; no separate per-package tracking | +| Where it runs | Your own GitHub Actions minutes ([how it works](how-it-works.md)) | Dependabot: GitHub-hosted, no minutes charged. Renovate: self-hosted or the hosted app, depending on setup | + +## When Dependabot or Renovate is the better choice + +If you need security-advisory coverage, ecosystems beyond Poetry/uv, or don't run a test suite this action could gate on in the first place, Dependabot or Renovate is the better (and in the security-advisory case, the only) choice. This action is narrower on purpose: it assumes you already have tests, and trades ecosystem breadth for testing every package before it ever reaches your PR. diff --git a/docs/manual/output-rendering.md b/docs/manual/pr-report.md similarity index 100% rename from docs/manual/output-rendering.md rename to docs/manual/pr-report.md From c92b17f46a2d0cc1668a612b52cf93b8891e1fb6 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:53:11 +0200 Subject: [PATCH 06/12] Add a test that keeps README's report sample honest against render_body Renders the same small, fixed UpdateResult through report.render_body() and asserts every row shown in README.md's "What you get" example also appears in that real output, so the two can never silently drift apart. Co-Authored-By: Claude Fable 5.1 --- tests/unit/test_readme_sample.py | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit/test_readme_sample.py diff --git a/tests/unit/test_readme_sample.py b/tests/unit/test_readme_sample.py new file mode 100644 index 0000000..6483eb0 --- /dev/null +++ b/tests/unit/test_readme_sample.py @@ -0,0 +1,53 @@ +"""Keeps README.md's "What you get" example honest against the real +renderer: renders a small, fixed UpdateResult through report.render_body() +and asserts every table row shown in the README also appears in that real +output (and vice versa isn't required - the README trims to just the two +headed tables), so the sample can never quietly drift from what the action +actually produces. +""" + +from pathlib import Path + +from updater.report import render_body +from updater.updater import PackageOutcome, UpdateResult + +ROOT = Path(__file__).resolve().parents[2] +README = ROOT / "README.md" + + +def _sample_result() -> UpdateResult: + return UpdateResult( + outcomes=[ + PackageOutcome( + name="six", status="updated", old_version="1.16.0", new_version="1.17.0" + ), + PackageOutcome( + name="idna", + status="failed", + old_version="3.6", + new_version="3.7", + failure_kind="test", + output_tail="FAILED tests/test_idna.py", + ), + ] + ) + + +def test_readme_sample_rows_match_the_real_render(): + body = render_body(_sample_result(), "https://example.invalid/run") + readme_text = README.read_text(encoding="utf-8") + + assert "## ✅ Updated" in body + assert "## \U0001f6d1 Failed" in body + + expected_rows = [ + "| package | old | new |", + "| six | 1.16.0 | 1.17.0 |", + "| package | current | attempted | reason |", + "| idna | 3.6 | 3.7 | tests failed |", + ] + for row in expected_rows: + assert row in body, f"expected real render_body() output to contain {row!r}" + assert row in readme_text, ( + f"README.md's sample is out of sync with the real render: missing {row!r}" + ) From 627eee011a7effc1a9b99c4604dc25e716979b29 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:53:28 +0200 Subject: [PATCH 07/12] Fix manual cross-links and section order; rename report.md -> pr-report.md - docs/manual/output-rendering.md -> docs/manual/pr-report.md (git mv), and reorder its "Sections" list to match report.py's real rendering order: Updated -> Failed -> No update available -> Held back -> Transitive dependencies, using the real emoji headings/markers. Updated every cross-link to the renamed file. - docs/manual/dependency-groups.md: fix the "uv run re-syncs" link to point at its actual anchor instead of the bare package-managers.md#uv. - docs/manual/maintaining.md: note that `uv run pytest tests/unit` does not exercise the tutorial's Poetry rendition - only `python3 .github/scripts/lint_docs_workflows.py` does. Co-Authored-By: Claude Fable 5.1 --- docs/manual/README.md | 3 +- docs/manual/allow-major.md | 2 +- docs/manual/create-issues.md | 6 +- docs/manual/dependency-groups.md | 2 +- docs/manual/how-it-works.md | 2 +- docs/manual/inputs.md | 2 +- docs/manual/maintaining.md | 2 + docs/manual/outputs.md | 2 +- docs/manual/pr-report.md | 12 ++-- docs/tutorials/weekly-updates.md | 104 ++++++++++++++++--------------- 10 files changed, 72 insertions(+), 65 deletions(-) diff --git a/docs/manual/README.md b/docs/manual/README.md index b6192d7..d5396be 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -13,7 +13,8 @@ Reference documentation: what's there, precisely. Looking for a guided walkthrou - [`update-transitive`](update-transitive.md) — refreshing transitive dependencies. - [Dependency groups](dependency-groups.md) — `with-groups` / `without-groups` / `only-groups`. - [`dry-run`](dry-run.md) — previewing a run without pushing anything. -- [The report](output-rendering.md) — how the PR body and job summary are rendered and budgeted. +- [The report](pr-report.md) — how the PR body and job summary are rendered and budgeted. +- [How is this different from Dependabot / Renovate?](comparison.md) — a factual comparison. - [Migrating from v1](migrating-from-v1.md) — the breaking changes from the old bash action. - [Versioning](versioning.md) — tags, pinning, and the release process. - [Maintaining this repo](maintaining.md) — dev setup, tests, and how the maintainer releases new versions. diff --git a/docs/manual/allow-major.md b/docs/manual/allow-major.md index 46e18b3..648ccbd 100644 --- a/docs/manual/allow-major.md +++ b/docs/manual/allow-major.md @@ -41,6 +41,6 @@ Two more checks can still discard an otherwise-successful attempt (same effect a ## In the report -The PR body gets a new "⚠️ Held back (update beyond declared constraint failed)" table (package, current, attempted, reason) once at least one package used it, and the "✅ Updated" table gains a `bump` column. With `allow-major` left at `false`, the rendered report and `report-json` are byte-identical to before this feature existed. See [The report](output-rendering.md) and [outputs.md](outputs.md#report-json). +The PR body gets a new "⚠️ Held back (update beyond declared constraint failed)" table (package, current, attempted, reason) once at least one package used it, and the "✅ Updated" table gains a `bump` column. With `allow-major` left at `false`, the rendered report and `report-json` are byte-identical to before this feature existed. See [The report](pr-report.md) and [outputs.md](outputs.md#report-json). **Prerequisite:** like the lock file, `pyproject.toml` must have no uncommitted changes before this action runs — see [How it works](how-it-works.md#prerequisite-a-clean-manifestlock-file) — checked regardless of whether `allow-major` is enabled. diff --git a/docs/manual/create-issues.md b/docs/manual/create-issues.md index 62f8a00..9c9d97f 100644 --- a/docs/manual/create-issues.md +++ b/docs/manual/create-issues.md @@ -16,13 +16,13 @@ Opt-in (default `false`): files a durable, trackable GitHub issue per failing pa An issue is filed for every top-level package whose outcome this run is `failed`, or that has a held-back [`allow-major`](allow-major.md) attempt (`beyond_constraint_failure_kind` set). A package that is both (the beyond-constraint attempt *and* the in-range fallback both failed) gets one issue about the plain failure, not two. -## Identity: all three markers required +## Identity: all three signals required - a hidden marker in the issue body, `` (`` is the PEP 503 normalized package name) — **never the title**, which is free to change between runs; - a second hidden marker, ``, recording what the last run reported; -- a footer line stating the issue is managed automatically. +- *either* a third hidden marker, ``, *or* the footer sentence stating the issue is managed automatically (its markdown link — repo name and URL — is ignored when matching, so identity never depends on the repo's current name). -Requiring all three (rather than the pkg marker alone) rules out, for example, a documentation issue that merely quotes the marker syntax as an example being mistaken for a managed one. The one edge case this cannot rule out: copy-pasting a managed issue's entire body verbatim into an unrelated issue would make that issue managed too — accepted as out of scope. +Every issue this action creates carries the marker (and the footer text); the footer-text fallback only matters for an issue a run created before the marker existed — it stays recognized without needing an edit. This identity is deliberately not tied to the repo's own name/URL: a repository rename must never orphan issues a previous run already opened. Requiring all three signals (rather than the pkg marker alone) rules out, for example, a documentation issue that merely quotes the marker syntax as an example being mistaken for a managed one. The one edge case this cannot rule out: copy-pasting a managed issue's entire body verbatim into an unrelated issue would make that issue managed too — accepted as out of scope. ## Every run, per package that needs an issue diff --git a/docs/manual/dependency-groups.md b/docs/manual/dependency-groups.md index b48b42b..babbdf7 100644 --- a/docs/manual/dependency-groups.md +++ b/docs/manual/dependency-groups.md @@ -36,4 +36,4 @@ For `uv sync`, once any of the three is set, the selection switches from this ac `uv-sync-args`, when set, always wins for sync and replaces the selection outright, same as without this feature at all — `without-groups`/`only-groups` then only ever filter the listing side (`with-groups` still has no listing effect either way). See [Package managers](package-managers.md#uv). -**Note:** if `test-command` itself calls `uv run`, it re-syncs using uv's own default selection, ignoring this action's narrower one for the duration of that call — see [Package managers § uv run in test-command re-syncs](package-managers.md#uv). +**Note:** if `test-command` itself calls `uv run`, it re-syncs using uv's own default selection, ignoring this action's narrower one for the duration of that call — see [Package managers § uv run in test-command re-syncs](package-managers.md#uv-run-in-test-command-re-syncs). diff --git a/docs/manual/how-it-works.md b/docs/manual/how-it-works.md index ac97674..72deda4 100644 --- a/docs/manual/how-it-works.md +++ b/docs/manual/how-it-works.md @@ -34,4 +34,4 @@ The action installs [`astral-sh/setup-uv`](https://github.com/astral-sh/setup-uv **`.venv` is rebuilt every run** (`uv venv --clear`), so restoring `.venv` itself from a CI cache does nothing useful. If you want faster syncs, cache `uv`'s own package cache instead (e.g. `actions/cache` with `path: ~/.cache/uv`, or the platform-appropriate `uv cache dir`). -See [Package managers](package-managers.md) for backend-specific detail, and [The report](output-rendering.md) for how the PR body/job summary are produced. +See [Package managers](package-managers.md) for backend-specific detail, and [The report](pr-report.md) for how the PR body/job summary are produced. diff --git a/docs/manual/inputs.md b/docs/manual/inputs.md index e5ad51d..1b89ba8 100644 --- a/docs/manual/inputs.md +++ b/docs/manual/inputs.md @@ -23,7 +23,7 @@ See [Package managers](package-managers.md). ## Pull request -See [How it works](how-it-works.md) and [The report](output-rendering.md). +See [How it works](how-it-works.md) and [The report](pr-report.md). | Name | Default | Description | |---|---|---| diff --git a/docs/manual/maintaining.md b/docs/manual/maintaining.md index 200a6b9..00308e9 100644 --- a/docs/manual/maintaining.md +++ b/docs/manual/maintaining.md @@ -38,6 +38,8 @@ uv run ruff format --check . The `docs` CI job renders every complete workflow example from `README.md`/`docs/**/*.md` against `./` (the local `action.yml`) and runs actionlint on the result, so a documented `with:` key that doesn't exist (or a value actionlint would reject) fails CI. `tests/unit/test_readme_sync.py` separately keeps the input/output tables in [`inputs.md`](inputs.md)/[`outputs.md`](outputs.md) honest against `action.yml`. +**`uv run pytest tests/unit` does not exercise the Poetry rendition of the tutorial.** `tests/unit/test_docs_examples.py` checks the `with:` keys of every documented example exactly as written (uv), never the mechanically generated Poetry variant (applying each `# Poetry: ...` comment) - only `python3 .github/scripts/lint_docs_workflows.py` (the same script the `docs` CI job runs; needs `actionlint` on `PATH`) renders and lints both. Run it locally after touching the tutorial's Poetry-only lines. + ## Why the e2e fixtures pin old versions `tests/fixture/*` deliberately lock **old** (even vulnerable) versions of a handful of small packages, so the action has something to update when the e2e job runs it in `dry-run`. Dependabot would otherwise keep opening security-update PRs against those exact fixture files and break them — [`dependabot.yml`](../../.github/dependabot.yml) sets `open-pull-requests-limit: 0` and ignores every dependency under `tests/fixture/*` for both the `pip` and `uv` ecosystems to stop that. diff --git a/docs/manual/outputs.md b/docs/manual/outputs.md index 31a065e..29309f2 100644 --- a/docs/manual/outputs.md +++ b/docs/manual/outputs.md @@ -13,7 +13,7 @@ Every output this action sets. Generated from `action.yml`. | `issue-actions` | JSON array of planned/performed `create-issues` actions. Schema below. | | `transitive-report` | A single JSON object reporting the `update-transitive` step, or the JSON literal `null`. Schema below. | -The same report is also written to the job summary (`GITHUB_STEP_SUMMARY`), including in `dry-run`. See [The report](output-rendering.md) for how `pr-body` and the job summary are rendered and budgeted. +The same report is also written to the job summary (`GITHUB_STEP_SUMMARY`), including in `dry-run`. See [The report](pr-report.md) for how `pr-body` and the job summary are rendered and budgeted. ## `report-json` diff --git a/docs/manual/pr-report.md b/docs/manual/pr-report.md index ff4d2d1..3e7139f 100644 --- a/docs/manual/pr-report.md +++ b/docs/manual/pr-report.md @@ -10,11 +10,13 @@ The same rendering drives the PR body (`pr-body` output), the job summary (`GITH ## Sections -- Updated — every package that got a new version this run, with a `bump` column once [`allow-major`](allow-major.md) produced at least one. -- Failed -- Held back (update beyond declared constraint failed) — only when `allow-major` produced at least one, see [`allow-major`](allow-major.md). -- Transitive dependencies — only when [`update-transitive`](update-transitive.md) actually ran this run. -- Skipped packages (compact, one line) +In this order, each only ever rendered when it has something to show: + +1. `## ✅ Updated` — every package that got a new version this run, with a `bump` column once [`allow-major`](allow-major.md) produced at least one. +2. `## 🛑 Failed` +3. `## ⏭ No update available` — a compact, one-line list (not a table) of packages with nothing to update. +4. `## ⚠️ Held back (update beyond declared constraint failed)` — only when `allow-major` produced at least one, see [`allow-major`](allow-major.md). +5. `## 🔁 Transitive dependencies` — only when [`update-transitive`](update-transitive.md) actually ran this run. ## Size budgets diff --git a/docs/tutorials/weekly-updates.md b/docs/tutorials/weekly-updates.md index e516496..b52f392 100644 --- a/docs/tutorials/weekly-updates.md +++ b/docs/tutorials/weekly-updates.md @@ -1,23 +1,23 @@ # Tutorial: a weekly dependency-update workflow -This tutorial builds one GitHub Actions workflow step by step, starting from the smallest thing that works and adding one capability at a time. Every step shows the complete workflow file so far — copy-paste any step and it works on its own. New or changed lines are marked `# new`. +Let's build a workflow that bumps your dependencies once a week. Then we add the advanced features, one step at a time. -All the workflows below are **uv** projects. **Using Poetry?** Everything below works unchanged — the package manager is auto-detected from your lock file (`uv.lock` vs. `poetry.lock`). The only lines that differ are marked `# Poetry: '...'` — swap in that value and you have a working Poetry workflow. - -Each step ends with what you should see when it runs. Every input used is linked to its [manual](../manual/README.md) page — read this tutorial to learn the shape of things, and the manual when you want the full detail on one of them. +- Every step shows the **complete file**. Copy any step and it works. +- Lines marked `# <-` are new in that step. +- Using **Poetry**? Swap in the values marked `Poetry: '...'`. Everything else is identical. ## 1. A minimal weekly run -The action doesn't check out your repository itself, so `actions/checkout` comes first. `permissions:` is needed because the repository default for `GITHUB_TOKEN` is often read-only. +The action doesn't check out your repository itself, so `actions/checkout` comes first. ```yaml name: update dependencies on: schedule: - - cron: '0 6 * * 1' - workflow_dispatch: + - cron: '0 6 * * 1' # <- every Monday at 06:00 UTC (minute hour day month weekday) + workflow_dispatch: # <- adds a "Run workflow" button in the Actions tab -permissions: +permissions: # <- lets the action push a branch and open a PR contents: write pull-requests: write @@ -25,21 +25,19 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 # <- clones your repo so the action can update it - - uses: friedrichwilken/test-gated-python-updates@v2 + - uses: friedrichwilken/test-gated-python-updates@v2 # <- the action itself with: - test-command: 'uv run pytest' # Poetry: 'poetry run pytest' - github_token: ${{ secrets.GITHUB_TOKEN }} + test-command: 'uv run pytest' # <- runs after every single update. Poetry: 'poetry run pytest' + github_token: ${{ secrets.GITHUB_TOKEN }} # <- lets it push the branch and open the PR ``` -`workflow_dispatch` lets you trigger a run by hand from the Actions tab instead of waiting for Monday. `test-command` runs in `directory` via `bash -c` — see [inputs.md](../manual/inputs.md). - **What you should see:** on the next scheduled run (or a manual `workflow_dispatch`), a job named `update` runs, and — if any of your top-level packages have updates that pass `test-command` — a pull request titled "Update and successfully test packages" against your default branch, opened with the built-in `GITHUB_TOKEN`. ## 2. Try it safely first -Before trusting this against your real project, run it with `dry-run: 'true'`: it does everything — updates, tests, local commits — except pushing the branch or touching the PR. Read the result from the job summary instead. +Run it once with `dry-run: 'true'`: it does everything except push the branch or touch the PR. ```yaml name: update dependencies @@ -61,19 +59,19 @@ jobs: - uses: friedrichwilken/test-gated-python-updates@v2 with: test-command: 'uv run pytest' # Poetry: 'poetry run pytest' - dry-run: 'true' # new + dry-run: 'true' # <- does everything except push and open the PR github_token: ${{ secrets.GITHUB_TOKEN }} ``` -Trigger it once with `workflow_dispatch`, then open the run in the Actions tab and scroll to its job summary — the same report a real run would put in the PR body is right there. +Trigger it with `workflow_dispatch`, then open the run and scroll to its job summary — the same report a real run would put in the PR body is right there. -**What you should see:** the job summary shows the "✅ Updated" / "❌ Failed" / skipped tables, but no branch is pushed and no PR appears — `git status` in the job is clean the whole time. See [`dry-run`](../manual/dry-run.md). +**What you should see:** the job summary shows a "✅ Updated" table, a "🛑 Failed" table, and/or a one-line "⏭ No update available" list — whichever apply — but no branch is pushed and no PR appears; `git status` in the job is clean the whole time. See [`dry-run`](../manual/dry-run.md) and [the report](../manual/pr-report.md). -Once you're happy with what you see, remove the `dry-run: 'true'` line (or set it to `'false'`) — the rest of this tutorial builds on the real thing. +Once you're happy with what you see, remove the `dry-run: 'true'` line (or set it to `'false'`). ## 3. Get CI running on the PR -A PR opened with the default `GITHUB_TOKEN` triggers no `pull_request` workflows at all — that's a deliberate GitHub Actions restriction. If you want your normal CI to run against the PR this action opens, pass a fine-grained PAT or GitHub App token to **both** `actions/checkout` and the action itself. +A PR opened with the default `GITHUB_TOKEN` triggers no `pull_request` workflows at all. Pass a fine-grained PAT or GitHub App token to **both** `actions/checkout` and the action itself to get normal CI on it. ```yaml name: update dependencies @@ -91,16 +89,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - token: ${{ secrets.DEPS_UPDATE_TOKEN }} # new + with: # <- checkout needs the PAT too + token: ${{ secrets.DEPS_UPDATE_TOKEN }} # <- a PAT, so the PR triggers your CI - uses: friedrichwilken/test-gated-python-updates@v2 with: test-command: 'uv run pytest' # Poetry: 'poetry run pytest' - github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} # new + github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} # <- same PAT, for gh pr create/edit ``` -Create a fine-grained PAT (or a GitHub App installation token) with `contents: write` and `pull-requests: write`, and store it as the `DEPS_UPDATE_TOKEN` repository secret. Either way — this token or the plain `GITHUB_TOKEN` — the repository setting **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must also be on, or PR creation is rejected outright. Full detail: [Token and permissions](../manual/token-and-permissions.md). +Create a fine-grained PAT (or GitHub App token) with `contents: write` and `pull-requests: write`, and store it as the `DEPS_UPDATE_TOKEN` repository secret. Either way — this token or the plain `GITHUB_TOKEN` — the repository setting **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must also be on. Full detail: [Token and permissions](../manual/token-and-permissions.md). **What you should see:** the next PR this action opens (or updates) now also shows your repository's normal required checks running against it, the way any other PR would. @@ -117,9 +115,9 @@ permissions: contents: write pull-requests: write -concurrency: # new - group: ${{ github.workflow }} # new - cancel-in-progress: false # new +concurrency: # <- stops two runs racing on the same branch + group: ${{ github.workflow }} # <- one slot per workflow + cancel-in-progress: false # <- let an in-flight run finish first jobs: update: @@ -132,19 +130,19 @@ jobs: - uses: friedrichwilken/test-gated-python-updates@v2 with: test-command: 'uv run pytest' # Poetry: 'poetry run pytest' - pr-title-prefix: '[deps] ' # new - pr-labels: 'dependencies' # new - base-branch: 'main' # new + pr-title-prefix: '[deps] ' # <- prepended to the PR title + pr-labels: 'dependencies' # <- label must already exist in the repo + base-branch: 'main' # <- rarely needed; defaults to the checked-out branch github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` -`pr-labels` must already exist in the repository — this action never creates one (`gh label create dependencies ...` once, or via your repo settings). `base-branch` is rarely needed (it already defaults to whichever branch is checked out) — set it explicitly if your checkout step ever lands on something other than the branch you want the PR opened against. `concurrency` stops two runs from racing to push the same fixed branch if a manual `workflow_dispatch` overlaps a scheduled run. +`pr-labels` must already exist in the repository — this action never creates one (`gh label create dependencies ...` once, or via your repo settings). **What you should see:** the PR title is prefixed `[deps] `, carries the `dependencies` label, and targets `main` explicitly. ## 5. Faster runs: `strategy: batch-first` -With many updatable packages, testing one at a time costs one test run per package. `strategy: batch-first` updates everything at once and tests once, falling back to the per-package loop only if that combined test fails. +With many updatable packages, testing one at a time costs one test run per package. `batch-first` updates everything at once and tests once, falling back to the per-package loop only if that combined test fails. ```yaml name: update dependencies @@ -175,7 +173,7 @@ jobs: pr-title-prefix: '[deps] ' pr-labels: 'dependencies' base-branch: 'main' - strategy: 'batch-first' # new + strategy: 'batch-first' # <- update+test everything at once first github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` @@ -215,7 +213,7 @@ jobs: pr-labels: 'dependencies' base-branch: 'main' strategy: 'batch-first' - allow-major: 'true' # new + allow-major: 'true' # <- also try updates beyond the declared constraint github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` @@ -235,7 +233,7 @@ on: permissions: contents: write pull-requests: write - issues: write # new + issues: write # <- required by create-issues concurrency: group: ${{ github.workflow }} @@ -257,18 +255,16 @@ jobs: base-branch: 'main' strategy: 'batch-first' allow-major: 'true' - create-issues: 'true' # new - issue-labels: 'dependencies' # new + create-issues: 'true' # <- one tracked issue per failing package + issue-labels: 'dependencies' # <- must already exist too github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` -`issue-labels` must already exist too, same rule as `pr-labels`. `issues: write` on the token is required — it also covers the read-only issue lookup this feature needs, so no separate `issues: read` is necessary. - **What you should see:** the first time a package fails, a new issue titled `: update to fails (...)`, carrying a hidden identity marker and the `dependencies` label. It gets updated silently on later still-failing runs, and closes itself automatically once the package updates cleanly. See [`create-issues`](../manual/create-issues.md). ## 8. Keep the rest fresh: `update-transitive` -Every step so far only ever touches your *top-level* dependencies. `update-transitive` adds one final step that refreshes everything else too, still within already-declared constraints. Paired here with `without-groups` to show excluding a group from the top-level loop entirely. +Every step so far only ever touches your *top-level* dependencies. `update-transitive` adds one final step that refreshes everything else too, still within already-declared constraints. This step also excludes the `dev` group from the top-level loop. ```yaml name: update dependencies @@ -304,27 +300,27 @@ jobs: allow-major: 'true' create-issues: 'true' issue-labels: 'dependencies' - update-transitive: 'true' # new - without-groups: 'dev' # new + update-transitive: 'true' # <- also refresh transitive dependencies + without-groups: 'dev' # <- exclude the dev group from the top-level loop github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` **uv note:** `"dev"` covers both a `dev` key in `[dependency-groups]` and uv's legacy `[tool.uv.dev-dependencies]`. **Poetry note:** the group name must be one you've actually declared (e.g. `[tool.poetry.group.dev]`) — there's no built-in `dev` group. Either way, see [Dependency groups](../manual/dependency-groups.md). -**Re-sync note:** if `test-command` itself runs `uv run ...`, be aware it re-syncs the environment first using **uv's own** default group selection, not this action's narrower `without-groups` one — a test that must not see the excluded group needs `uv run --no-sync ...` or `.venv/bin/python` directly instead. See [Package managers § `uv run` in `test-command` re-syncs](../manual/package-managers.md#uv-run-in-test-command-re-syncs). +**Re-sync note:** if `test-command` itself runs `uv run ...`, it re-syncs the environment first using **uv's own** default group selection, not this action's narrower `without-groups` one. If a test genuinely depends on the group being absent, use `uv run --no-sync ...` or `.venv/bin/python` directly instead. See [Package managers § `uv run` in `test-command` re-syncs](../manual/package-managers.md#uv-run-in-test-command-re-syncs). **What you should see:** an extra commit, `Update transitive dependencies`, when anything transitive had room to move — and, once `without-groups: dev` is set, any package that lives only in your `dev` group no longer appears in `report-json` at all. ## 9. Hands-off: auto-merge the PR when CI is green -There's no `pr-number`/`pr-url` output on this action today, so the auto-merge step can't target "the PR this run just touched" directly. Instead, key a separate, `pull_request`-triggered workflow off the fixed branch name this action always uses (`deps/test-gated-updates`, or your own `branch-name` if you changed it) — the same pattern this repo uses for its own Dependabot PRs. +There's no `pr-number`/`pr-url` output on this action today, so key a separate, `pull_request`-triggered workflow off the fixed branch name this action always uses instead — the same pattern this repo uses for its own Dependabot PRs. Add a second workflow file, `.github/workflows/automerge-deps.yml`: ```yaml name: auto-merge dependency updates on: - pull_request: + pull_request: # <- fires when the update PR is opened/updated branches: [main] permissions: @@ -333,17 +329,21 @@ permissions: jobs: automerge: - if: github.head_ref == 'deps/test-gated-updates' + if: | # <- only this action's own PR, never a fork + github.head_ref == 'deps/test-gated-updates' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Enable auto-merge env: - GH_TOKEN: ${{ secrets.DEPS_UPDATE_TOKEN }} + GH_TOKEN: ${{ secrets.DEPS_UPDATE_TOKEN }} # <- same PAT as the update workflow PR_URL: ${{ github.event.pull_request.html_url }} - run: gh pr merge --auto --squash "$PR_URL" + run: gh pr merge --auto --squash "$PR_URL" # <- merges once required checks pass ``` -This only fires on a real `pull_request` event, which needs the PAT from step 3 (`GITHUB_TOKEN` PRs never trigger it). It also needs the repository setting **Settings → General → Pull Requests → "Allow auto-merge"** on, and at least one required status check configured in branch protection — otherwise `gh pr merge --auto` has nothing to wait for and merges immediately. +This only fires on a real `pull_request` event, which needs the PAT from step 3 (`GITHUB_TOKEN` PRs never trigger it). It also needs **Settings → General → Pull Requests → "Allow auto-merge"** on, and at least one required status check in branch protection — otherwise `gh pr merge --auto` has nothing to wait for and merges immediately. + +A PR from a fork never receives this workflow's secrets, so `gh pr merge` could not authenticate even if the branch-name check above matched one — the extra repository-owner condition just makes that explicit rather than relying on it implicitly. **What you should see:** once your required checks pass on the update PR, it merges itself — no click required. If "Allow auto-merge" is off, `gh pr merge --auto` fails loudly instead of merging early; turn the setting on and re-run. @@ -402,7 +402,9 @@ permissions: jobs: automerge: - if: github.head_ref == 'deps/test-gated-updates' + if: | + github.head_ref == 'deps/test-gated-updates' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Enable auto-merge @@ -424,6 +426,6 @@ Every input above, in the manual: - `poetry-version`, `uv-sync-args` — optional, backend-specific; not needed above unless you pin a Poetry release or work around `tool.uv.conflicts` — see [package-managers.md](../manual/package-managers.md) - tokens and the `permissions:`/`issues: write` blocks — [token-and-permissions.md](../manual/token-and-permissions.md) - how the loop, the fixed branch and the PR itself behave — [how-it-works.md](../manual/how-it-works.md) -- the PR body / job summary this produces — [output-rendering.md](../manual/output-rendering.md) +- the PR body / job summary this produces — [pr-report.md](../manual/pr-report.md) This repository dogfoods a version of this same workflow on itself — see [`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) for a complete, currently-running example. From 69a6c5eeec0be532ffdaa47ca86115aa758ad65a Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:53:43 +0200 Subject: [PATCH 08/12] Tutorial: replace # new with a self-documenting # <- convention Every new/changed line in a tutorial step now carries a trailing # <- comment (redundant with the prose on purpose - an impatient reader only skims the code); the arrow is removed again once a line settles in a later step, and the step-10 recap carries none at all. Poetry-divergent lines keep their `Poetry: '...'` marker in every step regardless, combined with the arrow the one time a line is both new and Poetry-divergent. Make the Poetry-value extraction in lint_docs_workflows.py quote-aware (match `'...'` explicitly) instead of splitting at the first `#`, so a value containing `#` or `:` inside its own quotes can no longer be mis-parsed, and so it still works on both the plain `# Poetry: '...'` comment and the new combined `# <- ... Poetry: '...'` one. Add unit tests for the extraction (both comment forms, values containing `#`/`:`). Add a unit test (test_tutorial_arrows.py) for the arrow convention itself: diffs each workflow occurrence against the previous one with the same name (stdlib difflib, comments stripped) and asserts arrows mark exactly the lines that changed - no more, no less - and that the final recap carries none. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/lint_docs_workflows.py | 12 ++- tests/unit/test_lint_docs_workflows.py | 66 +++++++++++++ tests/unit/test_tutorial_arrows.py | 131 +++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_lint_docs_workflows.py create mode 100644 tests/unit/test_tutorial_arrows.py diff --git a/.github/scripts/lint_docs_workflows.py b/.github/scripts/lint_docs_workflows.py index eccb3c5..32468f6 100644 --- a/.github/scripts/lint_docs_workflows.py +++ b/.github/scripts/lint_docs_workflows.py @@ -32,8 +32,18 @@ r"uses:(\s*)(friedrichwilken/" r"(?:update-poetry-dependencies|test-gated-python-updates)(?:@\S*)?)" ) +# The value on both sides of "Poetry:" is always a single-quoted YAML +# string in this tutorial (e.g. 'uv run pytest') - matched quote-aware +# (`'[^']*'`, stopping at the closing quote) rather than "everything up to +# the next #", so a value that itself contains a `#` or `:` inside its +# quotes can never be mistaken for the start of the trailing comment. Works +# whether the comment is the plain `# Poetry: '...'` form or the combined +# `# <- . Poetry: '...'` form used the one time a line is +# both new and Poetry-divergent - `.*?` only needs to reach the first +# "Poetry:" after the `#`, regardless of what comes before it. POETRY_LINE_RE = re.compile( - r"^(?P\s*)(?P[A-Za-z0-9_-]+:\s*)(?P.*?)\s*#.*Poetry:\s*(?P.+?)\s*$" + r"^(?P\s*)(?P[A-Za-z0-9_-]+:\s*)(?P'[^']*')" + r"\s*#.*?Poetry:\s*(?P'[^']*')\s*$" ) diff --git a/tests/unit/test_lint_docs_workflows.py b/tests/unit/test_lint_docs_workflows.py new file mode 100644 index 0000000..47e865d --- /dev/null +++ b/tests/unit/test_lint_docs_workflows.py @@ -0,0 +1,66 @@ +"""Unit tests for .github/scripts/lint_docs_workflows.py's Poetry-value +extraction (poetrify()) - loaded by path since that script is CI-only +tooling, not part of the `updater` package (see its own module docstring). +Covers both comment forms the tutorial uses (plain `# Poetry: '...'` and +the combined `# <- . Poetry: '...'` form used the one time a +line is both new and Poetry-divergent), and values containing `#`/`:` +inside their quotes, which a naive "split at the first #" parse would +mis-locate. +""" + +import importlib.util +from pathlib import Path + +_SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] / ".github" / "scripts" / "lint_docs_workflows.py" +) +_spec = importlib.util.spec_from_file_location("lint_docs_workflows", _SCRIPT_PATH) +lint_docs_workflows = importlib.util.module_from_spec(_spec) +assert _spec.loader is not None +_spec.loader.exec_module(lint_docs_workflows) + +poetrify = lint_docs_workflows.poetrify + + +def test_poetrify_plain_form(): + line = " test-command: 'uv run pytest' # Poetry: 'poetry run pytest'" + assert poetrify(line) == " test-command: 'poetry run pytest'" + + +def test_poetrify_combined_arrow_and_poetry_form(): + line = ( + " test-command: 'uv run pytest' " + "# <- runs after every single update. Poetry: 'poetry run pytest'" + ) + assert poetrify(line) == " test-command: 'poetry run pytest'" + + +def test_poetrify_value_containing_hash_and_colon_inside_quotes(): + line = ( + " test-command: 'echo \"#weird:val\" && pytest' " + "# Poetry: 'echo \"#other:val\" && poetry run pytest'" + ) + assert poetrify(line) == " test-command: 'echo \"#other:val\" && poetry run pytest'" + + +def test_poetrify_combined_form_with_hash_and_colon_in_both_values(): + line = " test-command: 'echo \"#a:b\"' # <- prints a marker. Poetry: 'echo \"#c:d\"'" + assert poetrify(line) == " test-command: 'echo \"#c:d\"'" + + +def test_poetrify_leaves_non_poetry_lines_unchanged(): + line = " github_token: ${{ secrets.GITHUB_TOKEN }} # <- lets it push" + assert poetrify(line) == line + + +def test_poetrify_operates_line_by_line_over_a_whole_block(): + block = ( + " - uses: friedrichwilken/test-gated-python-updates@v2\n" + " with:\n" + " test-command: 'uv run pytest' # Poetry: 'poetry run pytest'\n" + " github_token: ${{ secrets.GITHUB_TOKEN }}\n" + ) + rendered = poetrify(block) + assert "test-command: 'poetry run pytest'" in rendered + assert "github_token: ${{ secrets.GITHUB_TOKEN }}" in rendered + assert "uv run pytest" not in rendered diff --git a/tests/unit/test_tutorial_arrows.py b/tests/unit/test_tutorial_arrows.py new file mode 100644 index 0000000..5f87ce9 --- /dev/null +++ b/tests/unit/test_tutorial_arrows.py @@ -0,0 +1,131 @@ +"""Keeps docs/tutorials/weekly-updates.md's `# <-` convention honest: +every step shows the complete workflow file so far, and a line should +carry a `# <-` comment exactly when it is new or changed compared to the +*previous* occurrence of that same workflow (identified by its `name:` +key) - never on an unrelated, already-introduced line, and never missing +on a line that really did just change. + +The tutorial interleaves two workflow "lineages" (the main `update +dependencies` workflow, and the `auto-merge dependency updates` workflow +introduced partway through) - grouped here by `name:` so each is compared +only against its own most recent prior version, not the other lineage. +Deliberately a small, dependency-free, line-based scan (stdlib `difflib` +only) - same style as the other tests/unit/test_docs_*.py checks. +""" + +from __future__ import annotations + +import difflib +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TUTORIAL = ROOT / "docs" / "tutorials" / "weekly-updates.md" + +ARROW = "# <-" + + +def yaml_blocks(md_text: str) -> list[str]: + blocks = [] + lines = md_text.splitlines() + i = 0 + while i < len(lines): + if lines[i].strip() == "```yaml": + j = i + 1 + body = [] + while j < len(lines) and lines[j].strip() != "```": + body.append(lines[j]) + j += 1 + blocks.append("\n".join(body)) + i = j + 1 + else: + i += 1 + return blocks + + +def is_complete_workflow(block: str) -> bool: + return bool( + re.search(r"^name:\s", block, re.MULTILINE) + and re.search(r"^on:", block, re.MULTILINE) + and re.search(r"^jobs:", block, re.MULTILINE) + ) + + +def workflow_name(block: str) -> str: + match = re.search(r"^name:\s*(.+?)\s*$", block, re.MULTILINE) + assert match, "expected every complete workflow block to have a name: line" + return match.group(1) + + +def strip_trailing_comment(line: str) -> str: + """Everything from the first ` #` onward is a comment in this + tutorial's own style (no line's real YAML content contains a literal + `#`) - stripped so the diff below compares code, not commentary.""" + return re.sub(r"\s+#.*$", "", line) + + +def changed_line_indices(prev_block: str, new_block: str) -> set[int]: + """Indices (into new_block's own lines) that a line-level diff + (comment-stripped) considers inserted/replaced relative to + prev_block - i.e. genuinely new or changed content, not just an + unchanged line that happens to sit at a different position (a + trailing comment added/removed/changed doesn't count either, since + both sides are stripped first).""" + prev_lines = [strip_trailing_comment(line) for line in prev_block.splitlines()] + new_lines = [strip_trailing_comment(line) for line in new_block.splitlines()] + matcher = difflib.SequenceMatcher(a=prev_lines, b=new_lines, autojunk=False) + changed = set() + for tag, _, _, j1, j2 in matcher.get_opcodes(): + if tag in ("insert", "replace"): + changed.update(range(j1, j2)) + return changed + + +def test_tutorial_has_complete_workflow_examples(): + text = TUTORIAL.read_text(encoding="utf-8") + blocks = [b for b in yaml_blocks(text) if is_complete_workflow(b)] + assert len(blocks) >= 2, "expected at least the main workflow and the auto-merge workflow" + + +def test_arrow_marks_exactly_the_changed_lines_within_each_lineage(): + text = TUTORIAL.read_text(encoding="utf-8") + blocks = [b for b in yaml_blocks(text) if is_complete_workflow(b)] + + by_name: dict[str, list[str]] = {} + for block in blocks: + by_name.setdefault(workflow_name(block), []).append(block) + + offenders = [] + for name, occurrences in by_name.items(): + # The first occurrence of a workflow is its own introduction step, + # manually curated rather than diffed against nothing - see the + # module docstring. + for prev_block, new_block in zip(occurrences, occurrences[1:], strict=False): + new_lines = new_block.splitlines() + changed = changed_line_indices(prev_block, new_block) + for idx, line in enumerate(new_lines): + if line.strip() == "": + continue # a blank line can never carry a comment + has_arrow = ARROW in line + is_changed = idx in changed + if is_changed and not has_arrow: + offenders.append(f"{name!r}: changed line has no {ARROW!r}: {line!r}") + elif has_arrow and not is_changed: + offenders.append(f"{name!r}: unchanged line still carries {ARROW!r}: {line!r}") + assert offenders == [], "\n".join(offenders) + + +def test_final_recap_has_no_arrows(): + text = TUTORIAL.read_text(encoding="utf-8") + blocks = [b for b in yaml_blocks(text) if is_complete_workflow(b)] + + by_name: dict[str, list[str]] = {} + for block in blocks: + by_name.setdefault(workflow_name(block), []).append(block) + + offenders = [] + for name, occurrences in by_name.items(): + last = occurrences[-1] + if ARROW in last: + offenders.append(name) + assert offenders == [], f"final recap block(s) still contain {ARROW!r}: {offenders}" From fa172fa5b7a8b7c1f00c2ccda530662b4235fda5 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:53:49 +0200 Subject: [PATCH 09/12] docs CI job: pin actionlint's download script by commit SHA, verify checksum download-actionlint.bash was fetched from rhysd/actionlint@main - pin it to the commit the v1.7.12 tag actually points at instead (verified via gh api repos/rhysd/actionlint/git/refs/tags/v1.7.12), so a compromised main can't swap in a different script for this job to run. The script itself performs no checksum verification, so add one: fetch the same pinned release's published actionlint__checksums.txt and matching linux_amd64 archive, verify the archive's sha256, then diff the binary it contains against what the script installed. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/check_action.yml | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check_action.yml b/.github/workflows/check_action.yml index b2395b6..69e8421 100644 --- a/.github/workflows/check_action.yml +++ b/.github/workflows/check_action.yml @@ -22,14 +22,46 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install actionlint + - name: Install actionlint (pinned, checksum-verified) env: ACTIONLINT_VERSION: '1.7.12' + # Commit the v1.7.12 tag points at (verified via `gh api + # repos/rhysd/actionlint/git/refs/tags/v1.7.12`) - the download + # script itself is fetched from this exact commit instead of + # `main`, so a compromised `main` can't swap in a different + # script for this job to run. + ACTIONLINT_SCRIPT_SHA: '914e7df21a07ef503a81201c76d2b11c789d3fca' run: | + set -euo pipefail mkdir -p ./bin - bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) "$ACTIONLINT_VERSION" ./bin + + curl -sSL -o /tmp/download-actionlint.bash \ + "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_SCRIPT_SHA}/scripts/download-actionlint.bash" + bash /tmp/download-actionlint.bash "$ACTIONLINT_VERSION" ./bin echo "$PWD/bin" >> "$GITHUB_PATH" + # The download script above does not verify a checksum itself, + # so do it here: fetch the same release's published + # actionlint__checksums.txt plus the matching platform + # archive, verify the archive's sha256, then diff the binary it + # contains against what the script just installed. + ARCHIVE="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -sSL -o "/tmp/actionlint_${ACTIONLINT_VERSION}_checksums.txt" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + curl -sSL -o "/tmp/$ARCHIVE" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$ARCHIVE" + ( + cd /tmp + # sha256sum -c needs the checked file's name (as recorded in + # the checksums file) to match what's actually on disk - kept + # identical to the release asset's own filename above so this + # never silently checks the wrong file. + grep "$ARCHIVE\$" "actionlint_${ACTIONLINT_VERSION}_checksums.txt" | sha256sum -c - + ) + mkdir -p /tmp/actionlint-verify + tar -xzf "/tmp/$ARCHIVE" -C /tmp/actionlint-verify actionlint + cmp ./bin/actionlint /tmp/actionlint-verify/actionlint + - name: Render documented workflow examples (uv and Poetry) and lint them run: python3 .github/scripts/lint_docs_workflows.py From 4cdb23bc556051eefdf89bee1e1aa66a965bd78e Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:54:00 +0200 Subject: [PATCH 10/12] Make managed-issue identity survive a repo rename parse_managed_issues() required _MANAGED_BY_FOOTER as an exact body substring, which embeds this repo's own name/URL - the update-poetry-dependencies -> test-gated-python-updates rename silently broke recognition of every issue create-issues had already opened. Add a third hidden marker, , with no repo-specific content, as the primary "this issue is managed" signal going forward. For an issue a run created before the marker existed, fall back to matching the footer's fixed English sentence with its markdown link (repo name/URL) ignored, so nothing already open gets orphaned by this fix either. Every issue this action renders now always carries the marker. Regression tests cover both an old-footer issue surviving a simulated rename via the marker, and a pre-marker issue still recognized via the footer-text fallback alone. Co-Authored-By: Claude Fable 5.1 --- tests/unit/test_github_issues.py | 54 ++++++++++++++++++++++++++++++++ updater/github_issues.py | 52 +++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_github_issues.py b/tests/unit/test_github_issues.py index 49dd77d..e4fd05a 100644 --- a/tests/unit/test_github_issues.py +++ b/tests/unit/test_github_issues.py @@ -44,6 +44,16 @@ def _outcome(**overrides) -> PackageOutcome: return PackageOutcome(**base) +_OLD_FOOTER_BEFORE_RENAME = ( + "_This issue is managed automatically by the " + "[update-poetry-dependencies]" + "(https://github.com/friedrichwilken/update-poetry-dependencies) " + "action's `create-issues` feature: it is updated on every run while the " + "package keeps failing or being held back, and closed automatically " + "once it no longer is. It should not be edited by hand._" +) + + def _managed_body(package: str, version: str = "", kind: str = "") -> str: """A realistic managed-issue body: all three of the pkg marker, the state marker, and the managed-by footer - what `render_issue_body` @@ -224,6 +234,49 @@ def test_parse_managed_issues_copy_pasted_body_is_accepted_edge_case(): assert len(managed) == 1 +def test_parse_managed_issues_survives_a_repo_rename(): + """Regression test for the real bug found by review after + friedrichwilken/update-poetry-dependencies -> test-gated-python-updates: + identity used to require _MANAGED_BY_FOOTER as an exact substring, which + embeds the repo name/URL - a rename silently broke recognition of every + issue a pre-rename run had created. The hidden managed marker carries no + repo-specific content, so it survives a rename unchanged.""" + raw = [ + { + "number": 6, + "body": ( + "\nsome body text\n\n" + f"{_OLD_FOOTER_BEFORE_RENAME}\n" + "\n" + "" + ), + } + ] + managed = parse_managed_issues(raw) + assert len(managed) == 1 + assert managed[0].package == "idna" + + +def test_parse_managed_issues_recognizes_pre_marker_issues_via_footer_text_alone(): + """An issue created by a run from *before* the managed marker existed + at all (old repo name in the footer link, no managed marker) must still + be recognized - via the footer's fixed sentence, link ignored - so this + fix does not orphan every issue already open when it ships.""" + raw = [ + { + "number": 7, + "body": ( + "\n" + f"{_OLD_FOOTER_BEFORE_RENAME}\n" + "" + ), + } + ] + managed = parse_managed_issues(raw) + assert len(managed) == 1 + assert managed[0].package == "six" + + # --- plan_issue_actions: decision table ------------------------------------ @@ -533,6 +586,7 @@ def test_render_issue_body_contains_marker_versions_and_links(): ) assert "" in body assert "" in body + assert "" in body assert "3.0" in body and "4.0" in body assert "boom output" in body assert "https://example/run/1" in body diff --git a/updater/github_issues.py b/updater/github_issues.py index bbbfe78..97e957a 100644 --- a/updater/github_issues.py +++ b/updater/github_issues.py @@ -8,9 +8,19 @@ is free to change); a state marker, ``, which records what the last run reported for that package so `plan_issue_actions` can tell whether -a comment is warranted without an extra `gh` call; and the managed-by -footer text (`_MANAGED_BY_FOOTER`). Every issue this action itself creates -always carries all three, so this is only ever a *stricter* check, never a +a comment is warranted without an extra `gh` call; and *either* a hidden +managed marker, `` (`_MANAGED_MARKER`), +*or* the managed-by footer's own fixed English sentence with its markdown +link (repo name and URL) ignored (`_MANAGED_FOOTER_TEXT_RE`) - deliberately +not an exact match against the current `_MANAGED_BY_FOOTER` string, which +embeds this repo's own name/URL: matching on that alone made identity +silently depend on the repo never being renamed (found the hard way - see +`test_github_issues.py`'s regression test and issue #39). The hidden +managed marker is the primary, rename-proof signal going forward; the +footer-text fallback (regex, link ignored) keeps recognizing issues a +pre-marker run already created, without requiring `gh` edits to backfill +them. Every issue this action itself creates always carries the marker +(and the footer text), so this is only ever a *stricter* check, never a missed real one - see `parse_managed_issues`. Requiring all three (rather than the pkg marker alone) rules out a document that merely quotes the pkg marker syntax as an example (see the false positive noted on @@ -89,6 +99,13 @@ _PKG_MARKER_RE = re.compile(r"") _STATE_MARKER_RE = re.compile(r"") +# A third hidden marker, carrying no payload, used as the primary "this +# issue is managed" signal (see the module docstring and +# _MANAGED_FOOTER_TEXT_RE below for why this - not the footer text alone - +# is what identity should rest on). +_MANAGED_MARKER = "" +_MANAGED_MARKER_RE = re.compile(re.escape(_MANAGED_MARKER)) + # A real pkg marker's payload is always normalize_name()'s output: lowercase # letters, digits and single hyphens only. Found by testing list_open_managed # against this repo's own issue #28, which - being the design issue for this @@ -109,6 +126,20 @@ "once it no longer is. It should not be edited by hand._" ) +# A fallback identity signal for an issue created by a run *before* +# _MANAGED_MARKER existed: the footer's own fixed English sentence, with +# its markdown link - `[]()`, the one part that +# silently changed when this repo was renamed - matched generically rather +# than pinned to any specific name/URL. New issues always carry +# _MANAGED_MARKER too (see _issue_footer_lines), so this regex only ever +# matters for issues already open when this fix shipped. +_MANAGED_FOOTER_TEXT_RE = re.compile( + r"This issue is managed automatically by the \[[^\]]*\]\([^)]*\) " + r"action's `create-issues` feature: it is updated on every run while the " + r"package keeps failing or being held back, and closed automatically " + r"once it no longer is\. It should not be edited by hand\." +) + @dataclass class ManagedIssue: @@ -245,10 +276,14 @@ def parse_managed_issues(raw_issues: list[dict]) -> list[ManagedIssue]: marker whose payload looks like a real normalized package name (see `_VALID_NORMALIZED_NAME_RE`; rules out e.g. a documentation placeholder such as this repo's own issue #28, which quotes the marker syntax as an - example), a state marker, and the managed-by footer text - (`_MANAGED_BY_FOOTER`) - see the module docstring for why all three are - required. Anything short of that is silently dropped: it is not one - this action manages, and must never be touched (this is what keeps + example), a state marker, and *either* the hidden managed marker + (`_MANAGED_MARKER`) *or* the managed-by footer's own fixed sentence with + its markdown link ignored (`_MANAGED_FOOTER_TEXT_RE`) - never an exact + match against `_MANAGED_BY_FOOTER` itself, which embeds this repo's own + name/URL and would otherwise make identity depend on the repo never + being renamed again - see the module docstring for the full reasoning. + Anything short of that is silently dropped: it is not one this action + manages, and must never be touched (this is what keeps `plan_issue_actions` from ever closing or editing an unrelated issue, regardless of what search query found it).""" managed = [] @@ -265,7 +300,7 @@ def parse_managed_issues(raw_issues: list[dict]) -> list[ManagedIssue]: state_match = _STATE_MARKER_RE.search(body) if not state_match: continue - if _MANAGED_BY_FOOTER not in body: + if not _MANAGED_MARKER_RE.search(body) and not _MANAGED_FOOTER_TEXT_RE.search(body): continue managed.append( ManagedIssue( @@ -455,6 +490,7 @@ def _issue_footer_lines( lines.append(f"Last seen: {last_seen}") lines.append("") lines.append(_MANAGED_BY_FOOTER) + lines.append(_MANAGED_MARKER) lines.append(f"") return lines From d417343ecdf6f2fe003b119b84913768f4601e67 Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 08:56:55 +0200 Subject: [PATCH 11/12] Docs: short lists instead of run-on paragraphs; first-run prerequisite in tutorial step 1 Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++--- docs/tutorials/weekly-updates.md | 52 ++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 13d7c47..25b8d00 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ jobs: Using Poetry? Change `test-command` to `'poetry run pytest'` — everything else auto-detects. Full walkthrough: [tutorial](docs/tutorials/weekly-updates.md). -The default `GITHUB_TOKEN` above won't trigger your CI on the PR it opens — see [token and permissions](docs/manual/token-and-permissions.md) for why, and for a PAT that fixes it. PR creation also needs the repo setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** (off by default) — same page. +Before the first run: +- Turn on **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** (off by default). +- Want your CI to run on the PR? The default `GITHUB_TOKEN` can't trigger it: [use a PAT](docs/manual/token-and-permissions.md). -## What you get -A pull request with a report, e.g.: +## What you get: one PR with a report ```markdown ## ✅ Updated @@ -67,4 +68,3 @@ A pull request with a report, e.g.: - [Manual](docs/manual/README.md) — full reference for every input and output. - [How is this different from Dependabot / Renovate?](docs/manual/comparison.md) - [Migrating from v1](docs/manual/migrating-from-v1.md) -- [This repo's own weekly workflow](.github/workflows/update_dependencies.yml) — a live example. diff --git a/docs/tutorials/weekly-updates.md b/docs/tutorials/weekly-updates.md index b52f392..874f1f7 100644 --- a/docs/tutorials/weekly-updates.md +++ b/docs/tutorials/weekly-updates.md @@ -33,7 +33,12 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} # <- lets it push the branch and open the PR ``` -**What you should see:** on the next scheduled run (or a manual `workflow_dispatch`), a job named `update` runs, and — if any of your top-level packages have updates that pass `test-command` — a pull request titled "Update and successfully test packages" against your default branch, opened with the built-in `GITHUB_TOKEN`. +**Before the first run:** turn on **Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"**. It is off by default, and without it GitHub refuses the PR. + +**What you should see:** + +- A job named `update` runs: on Monday, or when you press "Run workflow". +- If any update passes your tests: a PR titled "Update and successfully test packages". ## 2. Try it safely first @@ -65,7 +70,12 @@ jobs: Trigger it with `workflow_dispatch`, then open the run and scroll to its job summary — the same report a real run would put in the PR body is right there. -**What you should see:** the job summary shows a "✅ Updated" table, a "🛑 Failed" table, and/or a one-line "⏭ No update available" list — whichever apply — but no branch is pushed and no PR appears; `git status` in the job is clean the whole time. See [`dry-run`](../manual/dry-run.md) and [the report](../manual/pr-report.md). +**What you should see:** + +- The job summary shows the report: "✅ Updated", "🛑 Failed", "⏭ No update available". +- No branch is pushed and no PR appears. + +More: [`dry-run`](../manual/dry-run.md), [the report](../manual/pr-report.md). Once you're happy with what you see, remove the `dry-run: 'true'` line (or set it to `'false'`). @@ -98,9 +108,14 @@ jobs: github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} # <- same PAT, for gh pr create/edit ``` -Create a fine-grained PAT (or GitHub App token) with `contents: write` and `pull-requests: write`, and store it as the `DEPS_UPDATE_TOKEN` repository secret. Either way — this token or the plain `GITHUB_TOKEN` — the repository setting **Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests"** must also be on. Full detail: [Token and permissions](../manual/token-and-permissions.md). +To set it up: -**What you should see:** the next PR this action opens (or updates) now also shows your repository's normal required checks running against it, the way any other PR would. +1. Create a fine-grained PAT (or a GitHub App token) for this repo with **Contents** and **Pull requests** set to read and write. +2. Store it as the repository secret `DEPS_UPDATE_TOKEN`. + +More: [token and permissions](../manual/token-and-permissions.md). + +**What you should see:** your normal CI checks now run on the action's PR, like on any other PR. ## 4. Labels, title prefix, base branch, concurrency @@ -177,7 +192,12 @@ jobs: github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` -**What you should see:** if your project has several updatable packages and they all still pass together, the run's log shows exactly one test invocation instead of one per package — the PR report is identical either way, plus `tested_in_batch: true` in `report-json`. See [`strategy`](../manual/strategies.md). +**What you should see:** + +- One test run in the log instead of one per package, when everything passes together. +- The same PR report as before. + +More: [`strategy`](../manual/strategies.md). ## 6. Beyond the declared constraint: `allow-major` @@ -217,7 +237,12 @@ jobs: github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` -**What you should see:** for a package with a capped constraint and a newer release available, the PR now either shows it updated with `(raised)` next to its bump, or — if the raised attempt failed its tests — a new "⚠️ Held back" table naming it, with the plain in-range update applied instead. See [`allow-major`](../manual/allow-major.md). +**What you should see,** for a package with a capped constraint and a newer release: + +- It is updated, with `(raised)` next to its bump. Or: +- It shows up in a new "⚠️ Held back" table, and the normal in-range update is applied instead. + +More: [`allow-major`](../manual/allow-major.md). ## 7. Track what fails: `create-issues` @@ -260,7 +285,13 @@ jobs: github_token: ${{ secrets.DEPS_UPDATE_TOKEN }} ``` -**What you should see:** the first time a package fails, a new issue titled `: update to fails (...)`, carrying a hidden identity marker and the `dependencies` label. It gets updated silently on later still-failing runs, and closes itself automatically once the package updates cleanly. See [`create-issues`](../manual/create-issues.md). +**What you should see:** + +- The first time a package fails: a new issue, `: update to fails (...)`, with the `dependencies` label. +- While it keeps failing: the same issue is updated. No duplicates. +- Once it updates cleanly: the issue closes itself. + +More: [`create-issues`](../manual/create-issues.md). ## 8. Keep the rest fresh: `update-transitive` @@ -309,7 +340,10 @@ jobs: **Re-sync note:** if `test-command` itself runs `uv run ...`, it re-syncs the environment first using **uv's own** default group selection, not this action's narrower `without-groups` one. If a test genuinely depends on the group being absent, use `uv run --no-sync ...` or `.venv/bin/python` directly instead. See [Package managers § `uv run` in `test-command` re-syncs](../manual/package-managers.md#uv-run-in-test-command-re-syncs). -**What you should see:** an extra commit, `Update transitive dependencies`, when anything transitive had room to move — and, once `without-groups: dev` is set, any package that lives only in your `dev` group no longer appears in `report-json` at all. +**What you should see:** + +- An extra commit, `Update transitive dependencies`, whenever something transitive could move. +- With `without-groups: dev`: packages that live only in `dev` are left alone and are not in the report. ## 9. Hands-off: auto-merge the PR when CI is green @@ -345,7 +379,7 @@ This only fires on a real `pull_request` event, which needs the PAT from step 3 A PR from a fork never receives this workflow's secrets, so `gh pr merge` could not authenticate even if the branch-name check above matched one — the extra repository-owner condition just makes that explicit rather than relying on it implicitly. -**What you should see:** once your required checks pass on the update PR, it merges itself — no click required. If "Allow auto-merge" is off, `gh pr merge --auto` fails loudly instead of merging early; turn the setting on and re-run. +**What you should see:** once the required checks pass, the PR merges itself. If "Allow auto-merge" is off, the step fails loudly instead: turn the setting on and re-run. ## 10. The final workflow From 0f0979e0d7fadd84b38b6239912fbfffaeb6133b Mon Sep 17 00:00:00 2001 From: Friedrich Wilken Date: Fri, 18 Sep 2026 09:51:51 +0200 Subject: [PATCH 12/12] Docs: say what the self-update workflow does instead of 'dogfoods' Co-Authored-By: Claude Fable 5.1 --- docs/manual/maintaining.md | 2 +- docs/tutorials/weekly-updates.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manual/maintaining.md b/docs/manual/maintaining.md index 00308e9..10951bd 100644 --- a/docs/manual/maintaining.md +++ b/docs/manual/maintaining.md @@ -50,7 +50,7 @@ The `docs` CI job renders every complete workflow example from `README.md`/`docs ## This repo's own dependency updates -[`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) dogfoods the action on itself (`uses: ./`, uv backend) and needs a `DEPS_UPDATE_TOKEN` secret (see [Token and permissions](token-and-permissions.md)) to get CI running on the PRs it opens; it falls back to `github.token` otherwise. +[`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) runs the action on this repo itself every week (`uses: ./`, uv backend) - "dogfooding": using your own product and needs a `DEPS_UPDATE_TOKEN` secret (see [Token and permissions](token-and-permissions.md)) to get CI running on the PRs it opens; it falls back to `github.token` otherwise. ## Release process diff --git a/docs/tutorials/weekly-updates.md b/docs/tutorials/weekly-updates.md index 874f1f7..44b0559 100644 --- a/docs/tutorials/weekly-updates.md +++ b/docs/tutorials/weekly-updates.md @@ -462,4 +462,4 @@ Every input above, in the manual: - how the loop, the fixed branch and the PR itself behave — [how-it-works.md](../manual/how-it-works.md) - the PR body / job summary this produces — [pr-report.md](../manual/pr-report.md) -This repository dogfoods a version of this same workflow on itself — see [`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) for a complete, currently-running example. +This repository runs this same workflow on itself every week — see [`update_dependencies.yml`](../../.github/workflows/update_dependencies.yml) for a complete, currently-running example.