From 3acba61ddd3d9263c9f1f964b4d16f4e893edec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:09:37 +0000 Subject: [PATCH 01/11] Close Makefile masks and limit PyPI credentials in the release tests The independent review of #649 confirmed that .IGNORE, MAKEFLAGS (in the workflow env or the Makefile) and a `|| true`-style recipe line each let make test pass after a failure without any release test noticing, and that remote actions in other jobs could upload unseen. The make checks now reject those forms, and a new check keeps the pypi environment and id-token: write on push.yaml's Publish job alone, which covers uploads the text scan cannot read when they rely on trusted publishing. The scan comment and the test step locator's failure message now say what they cover and what to do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JEB5vwPLRB2PWBmRa --- .../core649-release-test-followups.changed.md | 1 + tests/core/test_release_tagging.py | 238 +++++++++++++++++- 2 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 changelog.d/core649-release-test-followups.changed.md diff --git a/changelog.d/core649-release-test-followups.changed.md b/changelog.d/core649-release-test-followups.changed.md new file mode 100644 index 000000000..6cab0f4c9 --- /dev/null +++ b/changelog.d/core649-release-test-followups.changed.md @@ -0,0 +1 @@ +The release tests now also fail if the root `Makefile` has a `.IGNORE` rule (with or without prerequisites) or a line that sets or exports `MAKEFLAGS`, if a recipe line of `make test` or of a prerequisite on its rule line contains `||` or ends in `; true`, `; exit 0` or `; :`, if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`, or if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`); a missing test step's failure message now says how to update the locator. diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index c8c3a1e5a..adba4b919 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -437,11 +437,13 @@ def pypi_uploading_recipe_lines(makefile: Path) -> list[str]: def test_only_push_publish_job_uploads_to_pypi(): # The checks above cover push.yaml's Publish job alone, so an upload in # any other job or workflow file would reach PyPI without them. - # Recognises the pypa publish action, twine upload and - # uv/poetry/flit/hatch/pdm/maturin publish in workflow steps, local - # composite action steps and Makefile recipes, and rejects reusable - # workflows from other repositories; not uploads inside scripts or remote - # actions. + # Recognises the pypa publish action (lower-case owner), and twine upload + # and uv/poetry/flit/hatch/pdm/maturin publish written as adjacent words, + # in workflow steps, composite action steps under .github/actions and root + # Makefile recipes, and rejects reusable workflows from other repositories. + # It misses uploads inside scripts and remote, Docker or JavaScript + # actions; test_only_publish_job_gets_pypi_credentials limits those that + # rely on trusted publishing. assert pypi_publishing_jobs(WORKFLOWS) == [("push.yaml", "Publish")] assert remote_reusable_workflow_jobs(WORKFLOWS) == [], ( "a job calls a reusable workflow from another repository" @@ -452,6 +454,61 @@ def test_only_push_publish_job_uploads_to_pypi(): ) +def pypi_environment(job: dict) -> bool: + # environment: is a name, or a mapping with a name and a url. + environment = job.get("environment") + if isinstance(environment, dict): + environment = environment.get("name") + return str(environment).lower() == "pypi" + + +def grants_id_token(permissions) -> bool: + return permissions == "write-all" or ( + isinstance(permissions, dict) and permissions.get("id-token") == "write" + ) + + +def pypi_credential_jobs(workflows: Path) -> list[tuple[str, str, list[str]]]: + """Each job that declares the pypi environment or an id-token: write grant. + + A job without permissions of its own is checked against the workflow's. + The environment name is compared ignoring case. + """ + return [ + (file_name, name, grants) + for file_name, workflow in load_workflows(workflows) + for name, job in (workflow.get("jobs") or {}).items() + if ( + grants := [ + grant + for grant, declared in [ + ("environment: pypi", pypi_environment(job)), + ( + "id-token: write", + grants_id_token( + job.get("permissions", workflow.get("permissions")) + ), + ), + ] + if declared + ] + ) + ] + + +def test_only_publish_job_gets_pypi_credentials(): + # Per the comment on push.yaml's Publish job, PyPI trusted publishing + # accepts uploads only from push.yaml in the pypi environment, and Publish + # declares that environment and id-token: write for it. The scan above + # cannot read inside a remote, Docker or JavaScript action, so this keeps + # both declarations off every other job. It rests on PyPI's + # trusted-publisher settings, which these tests cannot read, and does not + # cover an upload that uses a stored API token. + assert pypi_credential_jobs(WORKFLOWS) == [ + ("push.yaml", "Publish", ["environment: pypi", "id-token: write"]) + ] + + def copy_workflows_with_pr_test_job_change(tmp_path: Path, change) -> None: shutil.copytree(WORKFLOWS, tmp_path, dirs_exist_ok=True) pr_workflow = tmp_path / "pr.yaml" @@ -498,6 +555,62 @@ def call_reusable_workflows(jobs: dict) -> None: assert remote_reusable_workflow_jobs(tmp_path) == [("pr.yaml", "Remote")] +@pytest.mark.parametrize( + ("change", "grants"), + [ + ({"environment": "pypi"}, ["environment: pypi"]), + ( + {"environment": {"name": "PyPI", "url": "https://pypi.org"}}, + ["environment: pypi"], + ), + ({"permissions": {"id-token": "write"}}, ["id-token: write"]), + ({"permissions": "write-all"}, ["id-token: write"]), + ( + {"environment": "pypi", "permissions": {"id-token": "write"}}, + ["environment: pypi", "id-token: write"], + ), + ], + ids=[ + "environment", + "environment-mapping", + "id-token", + "write-all", + "both", + ], +) +def test_pypi_credential_check_finds_another_job(tmp_path, change, grants): + copy_workflows_with_pr_test_job_change( + tmp_path, lambda jobs: jobs["Test"].update(change) + ) + + assert pypi_credential_jobs(tmp_path) == [ + ("pr.yaml", "Test", grants), + ("push.yaml", "Publish", ["environment: pypi", "id-token: write"]), + ] + + +def test_pypi_credential_check_applies_workflow_permissions(tmp_path): + shutil.copytree(WORKFLOWS, tmp_path, dirs_exist_ok=True) + workflow = { + "on": "push", + "permissions": {"id-token": "write"}, + "jobs": { + "Inherits": {"runs-on": "ubuntu-latest", "steps": [{"run": "true"}]}, + "Overrides": { + "runs-on": "ubuntu-latest", + "permissions": {"contents": "read"}, + "steps": [{"run": "true"}], + }, + }, + } + (tmp_path / "other.yaml").write_text(yaml.safe_dump(workflow)) + + assert pypi_credential_jobs(tmp_path) == [ + ("other.yaml", "Inherits", ["id-token: write"]), + ("push.yaml", "Publish", ["environment: pypi", "id-token: write"]), + ] + + @pytest.mark.parametrize("file_name", ["action.yml", "action.yaml"]) def test_pypi_upload_check_finds_upload_in_composite_action(tmp_path, file_name): action = tmp_path / "publish" / file_name @@ -544,7 +657,8 @@ def assert_test_job_fails_when_tests_fail(workflow: dict) -> None: only_step( steps, lambda step: step.get("run", "").split()[:2] == ["make", "test"], - "test", + "test (a step whose run starts with the words `make test`; if the " + "command moved, update this locator and the exact match below)", ) ] # A skipped step does not fail the job, so a condition on the step that @@ -573,25 +687,47 @@ def assert_test_job_fails_when_tests_fail(workflow: dict) -> None: "PYTEST_ADDOPTS" in (item.get("env") or {}) for item in [workflow, test_job, test_step] ), "Test sets PYTEST_ADDOPTS" + # make takes options from MAKEFLAGS, so MAKEFLAGS=-i lets make test pass + # after a recipe line fails, as `make -i test` would. + assert not any( + "MAKEFLAGS" in (item.get("env") or {}) + for item in [workflow, test_job, test_step] + ), "Test sets MAKEFLAGS" + + +# An assignment or export of MAKEFLAGS, such as `MAKEFLAGS += -i`. +MAKEFLAGS_ASSIGNMENT = re.compile(r"^\s*(export\s+)?MAKEFLAGS\b", re.MULTILINE) +# A recipe line that ends its own failure: `cmd || ...`, or one that ends in +# `; true`, `; exit 0` or `; :`. +MASKED_RECIPE_LINE = re.compile(r"\|\||;\s*(true|exit\s+0|:)\s*$") -def assert_make_test_fails_when_tests_fail( - rules: dict[str, tuple[list[str], list[str]]], -) -> None: +def assert_make_test_fails_when_tests_fail(makefile: Path) -> None: + rules = make_rules(makefile) prerequisites, _ = rules["test"] + # The .IGNORE special target, with or without prerequisites, and a + # MAKEFLAGS set in the Makefile (MAKEFLAGS += -i) both let make test pass + # after a recipe line fails. + assert ".IGNORE" not in rules, "the Makefile ignores recipe failures (.IGNORE)" + assert not MAKEFLAGS_ASSIGNMENT.search(makefile.read_text()), ( + "the Makefile sets MAKEFLAGS" + ) # make ignores a failing recipe line whose prefix (any mix of @, + and - # after the tab) includes -, so make test could pass after tests fail. for target in ["test", *prerequisites]: for line in rules.get(target, ([], []))[1]: prefix = line[: len(line) - len(line.lstrip("@+- \t"))] assert "-" not in prefix, f"make {target} ignores a failing recipe line" + assert not MASKED_RECIPE_LINE.search(line), ( + f"make {target} masks a failing command: {line}" + ) def test_test_job_cannot_pass_with_failing_tests(): workflow = yaml.safe_load(PUSH_WORKFLOW.read_text()) assert_test_job_fails_when_tests_fail(workflow) - assert_make_test_fails_when_tests_fail(make_rules(MAKEFILE)) + assert_make_test_fails_when_tests_fail(MAKEFILE) def find_test_step(workflow: dict) -> dict: @@ -642,6 +778,18 @@ def collect_tests_only_in_workflow(workflow: dict) -> None: workflow["env"] = {"PYTEST_ADDOPTS": "--collect-only"} +def ignore_make_errors_in_test_step(workflow: dict) -> None: + find_test_step(workflow)["env"] = {"MAKEFLAGS": "-i"} + + +def ignore_make_errors_in_test_job(workflow: dict) -> None: + workflow["jobs"]["Test"]["env"] = {"MAKEFLAGS": "-i"} + + +def ignore_make_errors_in_workflow(workflow: dict) -> None: + workflow["env"] = {"MAKEFLAGS": "-i"} + + @pytest.mark.parametrize( ("mutate", "violation"), [ @@ -657,6 +805,9 @@ def collect_tests_only_in_workflow(workflow: dict) -> None: (collect_tests_only_in_test_step, "Test sets PYTEST_ADDOPTS"), (collect_tests_only_in_test_job, "Test sets PYTEST_ADDOPTS"), (collect_tests_only_in_workflow, "Test sets PYTEST_ADDOPTS"), + (ignore_make_errors_in_test_step, "Test sets MAKEFLAGS"), + (ignore_make_errors_in_test_job, "Test sets MAKEFLAGS"), + (ignore_make_errors_in_workflow, "Test sets MAKEFLAGS"), ] ], ) @@ -695,4 +846,69 @@ def test_make_test_check_rejects_ignored_recipe_failure(tmp_path, target, prefix with pytest.raises( AssertionError, match=f"make {target} ignores a failing recipe line" ): - assert_make_test_fails_when_tests_fail(make_rules(makefile)) + assert_make_test_fails_when_tests_fail(makefile) + + +@pytest.mark.parametrize( + "run", + ["make -C . test", "uv run make test"], +) +def test_test_job_check_explains_missing_test_step(run): + workflow = yaml.safe_load(PUSH_WORKFLOW.read_text()) + find_test_step(workflow)["run"] = run + + with pytest.raises(AssertionError) as failure: + assert_test_job_fails_when_tests_fail(workflow) + + message = str(failure.value).splitlines()[0] + assert message.startswith("expected one test (a step whose run starts with") + assert "update this locator and the exact match below" in message + assert message.endswith("found []") + + +def write_makefile(tmp_path: Path, text: str) -> Path: + makefile = tmp_path / "Makefile" + makefile.write_text(text) + return makefile + + +@pytest.mark.parametrize("rule", [".IGNORE: test", ".IGNORE:"]) +def test_make_test_check_rejects_ignore_special_target(tmp_path, rule): + makefile = write_makefile(tmp_path, MAKEFILE.read_text() + f"\n{rule}\n") + + with pytest.raises(AssertionError, match=r"ignores recipe failures \(\.IGNORE\)"): + assert_make_test_fails_when_tests_fail(makefile) + + +@pytest.mark.parametrize( + "assignment", + ["MAKEFLAGS += -i", "export MAKEFLAGS = -i", " MAKEFLAGS := i"], +) +def test_make_test_check_rejects_makeflags_in_makefile(tmp_path, assignment): + makefile = write_makefile(tmp_path, f"{assignment}\n" + MAKEFILE.read_text()) + + with pytest.raises(AssertionError, match="the Makefile sets MAKEFLAGS"): + assert_make_test_fails_when_tests_fail(makefile) + + +@pytest.mark.parametrize( + ("target", "suffix"), + [ + ("test", " || true"), + ("test", " || exit 0"), + ("test", "; true"), + ("test", "; exit 0"), + ("test", " ; :"), + ("test-country-template", " || true"), + ], +) +def test_make_test_check_rejects_masked_recipe_line(tmp_path, target, suffix): + lines = MAKEFILE.read_text().splitlines() + recipe_start = next( + index for index, line in enumerate(lines) if line.startswith(f"{target}:") + ) + lines[recipe_start + 1] += suffix + makefile = write_makefile(tmp_path, "\n".join(lines) + "\n") + + with pytest.raises(AssertionError, match=f"make {target} masks a failing command"): + assert_make_test_fails_when_tests_fail(makefile) From 03fb9e794e69adb362ece6550eed92bf314899c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:48:57 +0000 Subject: [PATCH 02/11] Walk make test's whole prerequisite tree and reject Makefile settings that mask failures The review of #650 found that an indented .IGNORE:, override MAKEFLAGS, $(eval MAKEFLAGS ...), .ONESHELL, SHELL/.SHELLFLAGS, a prerequisite's prerequisite, a later rule line for the same target and a double-colon rule each let make test pass after pytest fails while the release tests stayed green. Take the review's guard and tighten it where GNU Make 4.3 probes showed further masks: any mention of .IGNORE, .ONESHELL or MAKEFLAGS (which also covers GNUMAKEFLAGS and `foo .IGNORE:`), a target-specific SHELL, a later rule line indented with spaces or naming other targets too, a recipe after `;` on the rule line, and order-only prerequisites. Apply the one-rule-line check to every Makefile target in the PyPI upload scan too, so that `build::` or a later `build:` line cannot hide a twine upload from it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Jdt6qgZLwHQRNYNF6 --- tests/core/test_release_tagging.py | 192 +++++++++++++++++++++++++---- 1 file changed, 167 insertions(+), 25 deletions(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index adba4b919..06d0a4606 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -440,7 +440,8 @@ def test_only_push_publish_job_uploads_to_pypi(): # Recognises the pypa publish action (lower-case owner), and twine upload # and uv/poetry/flit/hatch/pdm/maturin publish written as adjacent words, # in workflow steps, composite action steps under .github/actions and root - # Makefile recipes, and rejects reusable workflows from other repositories. + # Makefile recipes (each target must have one rule line that make_rules + # reads), and rejects reusable workflows from other repositories. # It misses uploads inside scripts and remote, Docker or JavaScript # actions; test_only_publish_job_gets_pypi_credentials limits those that # rely on trusted publishing. @@ -449,6 +450,9 @@ def test_only_push_publish_job_uploads_to_pypi(): "a job calls a reusable workflow from another repository" ) assert pypi_publishing_actions(ACTIONS) == [], "a composite action uploads to PyPI" + assert make_targets_make_rules_misreads(MAKEFILE) == [], ( + "a Makefile target needs exactly one single-colon rule line" + ) assert pypi_uploading_recipe_lines(MAKEFILE) == [], ( "a Makefile recipe uploads to PyPI" ) @@ -695,27 +699,68 @@ def assert_test_job_fails_when_tests_fail(workflow: dict) -> None: ), "Test sets MAKEFLAGS" -# An assignment or export of MAKEFLAGS, such as `MAKEFLAGS += -i`. -MAKEFLAGS_ASSIGNMENT = re.compile(r"^\s*(export\s+)?MAKEFLAGS\b", re.MULTILINE) +# A setting that lets make test pass after a recipe line fails: any mention of +# .IGNORE or .ONESHELL (with .ONESHELL only the last line's status counts), any +# mention of MAKEFLAGS, including GNUMAKEFLAGS (=, +=, override, export, +# $(eval ...)), or a SHELL or .SHELLFLAGS of its own, also target-specific +# (`test: SHELL = /bin/true`). Reading MAKEFLAGS, as `$(MAKEFLAGS)` does, also +# fails this check: a loud false positive, and the Makefile has no such line. +MAKE_SETTING_THAT_MASKS = re.compile( + r"\.(IGNORE|ONESHELL)\b|MAKEFLAGS|(? list[str]: + """Targets whose recipe make_rules may not read as make does. + + make_rules keeps only a target's last rule line, skips double-colon rules + and rule lines indented with spaces, and reads a recipe written after `;` + on the rule line as prerequisites. So each target needs exactly one rule + line, single-colon, starting in the first column, with no `;`. + """ + lines = {} + for line in makefile.read_text().splitlines(): + if rule := ANY_MAKE_RULE.match(line): + for target in rule.group(1).split(): + lines.setdefault(target, []).append(line) + return sorted( + target + for target, rule_lines in lines.items() + if len(rule_lines) != 1 + or not (rule := MAKE_RULE.match(rule_lines[0])) + or ";" in rule.group(2) + ) def assert_make_test_fails_when_tests_fail(makefile: Path) -> None: - rules = make_rules(makefile) - prerequisites, _ = rules["test"] - # The .IGNORE special target, with or without prerequisites, and a - # MAKEFLAGS set in the Makefile (MAKEFLAGS += -i) both let make test pass - # after a recipe line fails. - assert ".IGNORE" not in rules, "the Makefile ignores recipe failures (.IGNORE)" - assert not MAKEFLAGS_ASSIGNMENT.search(makefile.read_text()), ( - "the Makefile sets MAKEFLAGS" + assert not MAKE_SETTING_THAT_MASKS.search(makefile.read_text()), ( + "the Makefile can ignore recipe failures" ) - # make ignores a failing recipe line whose prefix (any mix of @, + and - - # after the tab) includes -, so make test could pass after tests fail. - for target in ["test", *prerequisites]: - for line in rules.get(target, ([], []))[1]: + rules = make_rules(makefile) + misread = make_targets_make_rules_misreads(makefile) + # make runs test's prerequisites at every depth, and their recipes. + checked, queue = [], ["test"] + while queue: + target = queue.pop() + if target in checked: + continue + checked.append(target) + assert target in rules and target not in misread, ( + f"make {target} needs exactly one single-colon rule line" + ) + prerequisites, recipe = rules[target] + # `|` separates order-only prerequisites, which make also runs. + queue += [name for name in prerequisites if name != "|"] + for line in recipe: + # make ignores a failing recipe line whose prefix (any mix of @, + + # and - after the tab) includes -. prefix = line[: len(line) - len(line.lstrip("@+- \t"))] assert "-" not in prefix, f"make {target} ignores a failing recipe line" assert not MASKED_RECIPE_LINE.search(line), ( @@ -872,25 +917,122 @@ def write_makefile(tmp_path: Path, text: str) -> Path: return makefile -@pytest.mark.parametrize("rule", [".IGNORE: test", ".IGNORE:"]) -def test_make_test_check_rejects_ignore_special_target(tmp_path, rule): - makefile = write_makefile(tmp_path, MAKEFILE.read_text() + f"\n{rule}\n") +@pytest.mark.parametrize( + "setting", + [ + ".IGNORE: test", + ".IGNORE:", + " .IGNORE:", + ".IGNORE: # c", + ".IGNORE:test", + "foo .IGNORE:", + "$(eval .IGNORE:)", + ".ONESHELL:", + "MAKEFLAGS += -i", + "export MAKEFLAGS = -i", + " MAKEFLAGS := i", + "override MAKEFLAGS += -i", + "$(eval MAKEFLAGS += -i)", + "GNUMAKEFLAGS += -i", + "SHELL := /bin/true", + "override SHELL = /bin/true", + ".SHELLFLAGS := -c true", + "test: SHELL = /bin/true", + ], +) +def test_make_test_check_rejects_setting_that_masks(tmp_path, setting): + makefile = write_makefile(tmp_path, f"{setting}\n" + MAKEFILE.read_text()) - with pytest.raises(AssertionError, match=r"ignores recipe failures \(\.IGNORE\)"): + with pytest.raises(AssertionError, match="the Makefile can ignore recipe failures"): assert_make_test_fails_when_tests_fail(makefile) +def edit_makefile(tmp_path: Path, old: str, new: str) -> Path: + text = MAKEFILE.read_text() + assert text.count(old) == 1 + return write_makefile(tmp_path, text.replace(old, new)) + + +TEMPLATE_RULE = "test-country-template:\n" + + @pytest.mark.parametrize( - "assignment", - ["MAKEFLAGS += -i", "export MAKEFLAGS = -i", " MAKEFLAGS := i"], + ("old", "new", "violation"), + [ + pytest.param( + TEMPLATE_RULE, + "test-country-template: deeper\n", + "make deeper needs exactly one single-colon rule line", + id="prerequisite-without-rule", + ), + pytest.param( + TEMPLATE_RULE, + "deeper:\n\tfalse || true\n\ntest-country-template: deeper\n", + "make deeper masks a failing command", + id="prerequisite-of-prerequisite", + ), + pytest.param( + "test: test-country-template\n", + "test: test-country-template | deeper\n", + "make deeper needs exactly one single-colon rule line", + id="order-only-prerequisite", + ), + pytest.param( + "build:\n", + "test: format\n\nbuild:\n", + "make test needs exactly one single-colon rule line", + id="later-rule-line", + ), + pytest.param( + "build:\n", + "format test:\n\nbuild:\n", + "make test needs exactly one single-colon rule line", + id="later-rule-line-with-other-target", + ), + pytest.param( + "build:\n", + " test:\n\ttrue\n\nbuild:\n", + "make test needs exactly one single-colon rule line", + id="later-indented-rule-line", + ), + pytest.param( + TEMPLATE_RULE, + "test-country-template::\n", + "make test-country-template needs exactly one single-colon rule line", + id="double-colon-rule", + ), + pytest.param( + TEMPLATE_RULE, + "test-country-template: ; false || true\n", + "make test-country-template needs exactly one single-colon rule line", + id="recipe-on-rule-line", + ), + ], ) -def test_make_test_check_rejects_makeflags_in_makefile(tmp_path, assignment): - makefile = write_makefile(tmp_path, f"{assignment}\n" + MAKEFILE.read_text()) +def test_make_test_check_rejects_recipe_it_cannot_read(tmp_path, old, new, violation): + makefile = edit_makefile(tmp_path, old, new) - with pytest.raises(AssertionError, match="the Makefile sets MAKEFLAGS"): + with pytest.raises(AssertionError, match=violation): assert_make_test_fails_when_tests_fail(makefile) +@pytest.mark.parametrize( + "new", + [ + pytest.param("build::\n\ttwine upload dist/*\n", id="double-colon-rule"), + pytest.param( + "build:\n\ttwine upload dist/*\n\nbuild: install\n", + id="later-rule-line", + ), + pytest.param("build: ; twine upload dist/*\n", id="recipe-on-rule-line"), + ], +) +def test_pypi_upload_check_rejects_recipe_it_cannot_read(tmp_path, new): + makefile = edit_makefile(tmp_path, "build:\n\tpython -m build\n", new) + + assert make_targets_make_rules_misreads(makefile) == ["build"] + + @pytest.mark.parametrize( ("target", "suffix"), [ From 1bda485331c971d65891da49f133d1794ed86c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:57:05 +0000 Subject: [PATCH 03/11] Narrow the changelog fragment to the Makefile forms the checks now catch The review found the fragment named forms the checks missed (an indented .IGNORE: and override MAKEFLAGS). With the wider guard, name exactly what it rejects: any mention of .IGNORE, .ONESHELL or MAKEFLAGS, a SHELL or .SHELLFLAGS assignment, a target without exactly one plain single-colon rule line, and the masked recipe forms at any prerequisite depth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Jdt6qgZLwHQRNYNF6 --- changelog.d/core649-release-test-followups.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/core649-release-test-followups.changed.md b/changelog.d/core649-release-test-followups.changed.md index 6cab0f4c9..eb761dab4 100644 --- a/changelog.d/core649-release-test-followups.changed.md +++ b/changelog.d/core649-release-test-followups.changed.md @@ -1 +1 @@ -The release tests now also fail if the root `Makefile` has a `.IGNORE` rule (with or without prerequisites) or a line that sets or exports `MAKEFLAGS`, if a recipe line of `make test` or of a prerequisite on its rule line contains `||` or ends in `; true`, `; exit 0` or `; :`, if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`, or if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`); a missing test step's failure message now says how to update the locator. +The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL` or `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`) or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific), if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it, if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`, if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`, or if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`); a missing test step's failure message now says how to update the locator. From 4006b6ca4e6e3b9faf85fa4dfee2f0a6fbd1fafc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:52:35 +0000 Subject: [PATCH 04/11] Let make_rules read past recipe comments and refuse syntax it can't follow A comment-only line, a conditional or .RECIPEPREFIX inside a recipe ended make_rules's recipe, so later recipe lines (an `|| true` or a twine upload) went unread while GNU make still ran them. make_rules now skips comment-only lines, as make does, and fails on conditionals, define blocks, .RECIPEPREFIX and trailing backslashes. Adds in-suite cases for each form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Kc7ibRJ5e1WVQfQD7 --- tests/core/test_release_tagging.py | 78 ++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 06d0a4606..62a081f50 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -405,22 +405,46 @@ def pypi_publishing_actions(actions: Path) -> list[str]: ] +# Make syntax that make_rules does not follow, so recipe lines after it could +# run unread: a conditional or define block, a changed recipe prefix, or a +# backslash at the end of a line. +MAKE_SYNTAX_MAKE_RULES_SKIPS = re.compile( + r"^ *((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b" + r"|\.RECIPEPREFIX|\\$", + re.MULTILINE, +) + + def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]: """Each target's prerequisites and recipe lines (without the tab). - Enough for this Makefile: it has no line continuations, includes or - pattern rules. + Reads rule lines that start in the first column with a single colon, and + the tab-indented lines after each one as its recipe. Like make, it reads + past blank lines and lines of just a comment among recipe lines; any + other line ends the recipe. It fails on a conditional, a define block, + .RECIPEPREFIX or a line that ends in a backslash, since recipe lines after + them could run unread. It misreads a double-colon, indented or repeated + rule line and a `;` recipe on the rule line, which + make_targets_make_rules_misreads reports, and it doesn't follow include + or pattern rules. """ + text = makefile.read_text() + assert not MAKE_SYNTAX_MAKE_RULES_SKIPS.search(text), ( + "make_rules cannot read a conditional, define block, .RECIPEPREFIX or " + "line continuation in the Makefile" + ) rules = {} recipe = [] - for line in makefile.read_text().splitlines(): + for line in text.splitlines(): if line.startswith("\t"): recipe.append(line[1:]) elif rule := MAKE_RULE.match(line): recipe = [] for target in rule.group(1).split(): rules[target] = (rule.group(2).split(), recipe) - elif line.strip(): + # make ignores blank lines and lines of just a comment among recipe + # lines, so neither ends a recipe. + elif line.strip() and not line.lstrip().startswith("#"): recipe = [] return rules @@ -1007,6 +1031,12 @@ def edit_makefile(tmp_path: Path, old: str, new: str) -> Path: "make test-country-template needs exactly one single-colon rule line", id="recipe-on-rule-line", ), + pytest.param( + "\tcoverage xml -i\n", + "# write the report\n\tcoverage xml -i || true\n", + "make test masks a failing command", + id="comment-line-before-masked-line", + ), ], ) def test_make_test_check_rejects_recipe_it_cannot_read(tmp_path, old, new, violation): @@ -1016,6 +1046,46 @@ def test_make_test_check_rejects_recipe_it_cannot_read(tmp_path, old, new, viola assert_make_test_fails_when_tests_fail(makefile) +@pytest.mark.parametrize( + ("old", "new"), + [ + pytest.param( + "\tcoverage xml -i\n", + "ifeq (1,1)\n\tcoverage xml -i || true\nendif\n", + id="conditional", + ), + pytest.param( + "all: install", ".RECIPEPREFIX = >\nall: install", id="recipeprefix" + ), + pytest.param( + "all: install", + "define SHELL\n/bin/true\nendef\n\nall: install", + id="define", + ), + pytest.param( + "\tcoverage xml -i\n", + "\tcoverage xml -i; \\\n\ttrue\n", + id="line-continuation", + ), + ], +) +def test_make_rules_rejects_syntax_it_cannot_follow(tmp_path, old, new): + makefile = edit_makefile(tmp_path, old, new) + + with pytest.raises(AssertionError, match="make_rules cannot read"): + make_rules(makefile) + + +def test_pypi_upload_check_reads_past_comment_line(tmp_path): + makefile = edit_makefile( + tmp_path, + "build:\n\tpython -m build\n", + "build:\n\tpython -m build\n# upload\n\ttwine upload dist/*\n", + ) + + assert pypi_uploading_recipe_lines(makefile) == ["build: twine upload dist/*"] + + @pytest.mark.parametrize( "new", [ From b788b3e062609802206028e96b35847bfedbc1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:52:35 +0000 Subject: [PATCH 05/11] Name the parser's refused syntax and comment handling in the fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Kc7ibRJ5e1WVQfQD7 --- changelog.d/core649-release-test-followups.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/core649-release-test-followups.changed.md b/changelog.d/core649-release-test-followups.changed.md index eb761dab4..279b93855 100644 --- a/changelog.d/core649-release-test-followups.changed.md +++ b/changelog.d/core649-release-test-followups.changed.md @@ -1 +1 @@ -The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL` or `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`) or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific), if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it, if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`, if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`, or if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`); a missing test step's failure message now says how to update the locator. +The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL` or `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`), or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific). They fail if it holds a conditional (`ifeq` … `endif`), a `define` block, `.RECIPEPREFIX` or a line that ends in `\`, which their recipe parser can't follow. They fail if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it. They fail if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`. The parser reads past comment-only lines inside a recipe, as make does. The tests also fail if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`. They fail if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`). A missing test step's failure message now says how to update the locator. From d040c080284df15628f4e356145e05086a7cefc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:11:33 +0000 Subject: [PATCH 06/11] Let make_rules refuse a directive after any whitespace, as make reads it GNU make skips any whitespace before a conditional or define, not only spaces, so a directive after a space and a tab, after a form feed, or after a tab outside a rule passed every check while make obeyed it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GrDhQfRQyGWsSRqqw --- tests/core/test_release_tagging.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 62a081f50..97deeb9ae 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -409,7 +409,7 @@ def pypi_publishing_actions(actions: Path) -> list[str]: # run unread: a conditional or define block, a changed recipe prefix, or a # backslash at the end of a line. MAKE_SYNTAX_MAKE_RULES_SKIPS = re.compile( - r"^ *((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b" + r"^[ \t\f\v]*((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b" r"|\.RECIPEPREFIX|\\$", re.MULTILINE, ) @@ -1067,6 +1067,21 @@ def test_make_test_check_rejects_recipe_it_cannot_read(tmp_path, old, new, viola "\tcoverage xml -i; \\\n\ttrue\n", id="line-continuation", ), + pytest.param( + "\tcoverage xml -i\n", + " \tifeq (1,1)\n\tcoverage xml -i || true\n \tendif\n", + id="conditional-after-space-and-tab", + ), + pytest.param( + "all: install", + "\fdefine SHELL\n/bin/true\n\fendef\n\nall: install", + id="define-after-form-feed", + ), + pytest.param( + "all: install", + "X = 1\n\tifeq (1,0)\n\tendif\n\nall: install", + id="tab-conditional-outside-rule", + ), ], ) def test_make_rules_rejects_syntax_it_cannot_follow(tmp_path, old, new): From 3937cf238ef96267ea1ee428a3a3d37416ab4357 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:11:35 +0000 Subject: [PATCH 07/11] Fail the make test check on any mention of PYTEST_ADDOPTS in the Makefile make exports a variable named in an export line to the recipes it runs, and pytest reads options from PYTEST_ADDOPTS, so export PYTEST_ADDOPTS = --collect-only let make test pass without running a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GrDhQfRQyGWsSRqqw --- tests/core/test_release_tagging.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 97deeb9ae..2793dbb96 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -727,10 +727,15 @@ def assert_test_job_fails_when_tests_fail(workflow: dict) -> None: # .IGNORE or .ONESHELL (with .ONESHELL only the last line's status counts), any # mention of MAKEFLAGS, including GNUMAKEFLAGS (=, +=, override, export, # $(eval ...)), or a SHELL or .SHELLFLAGS of its own, also target-specific -# (`test: SHELL = /bin/true`). Reading MAKEFLAGS, as `$(MAKEFLAGS)` does, also -# fails this check: a loud false positive, and the Makefile has no such line. +# (`test: SHELL = /bin/true`). +# Any mention of PYTEST_ADDOPTS fails it too. make passes a variable named +# in an `export` line to the commands it runs, and pytest adds the options +# in PYTEST_ADDOPTS to its command line, so `export PYTEST_ADDOPTS = +# --collect-only` lets make test pass without running tests. +# Reading MAKEFLAGS, as `$(MAKEFLAGS)` does, also fails this check: a loud +# false positive, and the Makefile has no such line. MAKE_SETTING_THAT_MASKS = re.compile( - r"\.(IGNORE|ONESHELL)\b|MAKEFLAGS|(? Path: "override SHELL = /bin/true", ".SHELLFLAGS := -c true", "test: SHELL = /bin/true", + "export PYTEST_ADDOPTS = --collect-only", + "test: export PYTEST_ADDOPTS = --collect-only", ], ) def test_make_test_check_rejects_setting_that_masks(tmp_path, setting): From efdb3cd468be8011521e8d4092c4796d74156ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:11:42 +0000 Subject: [PATCH 08/11] Name PYTEST_ADDOPTS among the Makefile mentions the fragment lists Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GrDhQfRQyGWsSRqqw --- changelog.d/core649-release-test-followups.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/core649-release-test-followups.changed.md b/changelog.d/core649-release-test-followups.changed.md index 279b93855..d652b5119 100644 --- a/changelog.d/core649-release-test-followups.changed.md +++ b/changelog.d/core649-release-test-followups.changed.md @@ -1 +1 @@ -The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL` or `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`), or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific). They fail if it holds a conditional (`ifeq` … `endif`), a `define` block, `.RECIPEPREFIX` or a line that ends in `\`, which their recipe parser can't follow. They fail if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it. They fail if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`. The parser reads past comment-only lines inside a recipe, as make does. The tests also fail if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`. They fail if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`). A missing test step's failure message now says how to update the locator. +The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL`, `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`) or `PYTEST_ADDOPTS`, or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific). They fail if it holds a conditional (`ifeq` … `endif`), a `define` block, `.RECIPEPREFIX` or a line that ends in `\`, which their recipe parser can't follow. They fail if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it. They fail if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`. The parser reads past comment-only lines inside a recipe, as make does. The tests also fail if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`. They fail if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`). A missing test step's failure message now says how to update the locator. From 92750f87095bc71ce26911b1421b25db4b2d4f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:40:05 +0000 Subject: [PATCH 09/11] Fail make_rules on line breaks that Python sees and make doesn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's splitlines breaks a line at a form feed, vertical tab, lone carriage return, \x1c-\x1e, \x85, 
 and 
, but GNU make ends a line only at \n. A recipe line such as `cmd $(if ,
)|| true` therefore reached make_rules as two lines, hiding its `|| true` or upload. make_rules now refuses those characters, reading raw bytes so a lone \r stays visible and allowing \r\n for CRLF checkouts on Windows. The docstring and assertion message now also name the directive-word recipe line that make_rules already refuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JgiWGYWii3moSdXzX --- tests/core/test_release_tagging.py | 33 +++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 2793dbb96..9077c1172 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -414,6 +414,13 @@ def pypi_publishing_actions(actions: Path) -> list[str]: re.MULTILINE, ) +# Python ends a line at \r, \v, \f, \x1c to \x1e, \x85, 
 and +# as well as at \n: read_text turns a lone \r into \n, and splitlines splits +# at the rest. make ends a line only at \n. So make_rules would read a recipe +# line such as `cmd $(if ,)|| true` as two lines and miss its +# `|| true` or upload. +MAKE_LINE_BREAK_MAKE_IGNORES = re.compile(r"\r(?!\n)|[\v\f\x1c-\x1e\x85

]") + def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]: """Each target's prerequisites and recipe lines (without the tab). @@ -422,16 +429,22 @@ def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]: the tab-indented lines after each one as its recipe. Like make, it reads past blank lines and lines of just a comment among recipe lines; any other line ends the recipe. It fails on a conditional, a define block, - .RECIPEPREFIX or a line that ends in a backslash, since recipe lines after - them could run unread. It misreads a double-colon, indented or repeated - rule line and a `;` recipe on the rule line, which + .RECIPEPREFIX, a line that ends in a backslash or a line break other than + a newline, since recipe lines after them could run unread. It also fails + on a recipe line that opens with a directive word, such as `else`, which + it can't tell from a directive. It misreads a double-colon, indented or + repeated rule line and a `;` recipe on the rule line, which make_targets_make_rules_misreads reports, and it doesn't follow include or pattern rules. """ + assert not MAKE_LINE_BREAK_MAKE_IGNORES.search(makefile.read_bytes().decode()), ( + "make_rules cannot read a line break other than \\n or \\r\\n in the Makefile" + ) text = makefile.read_text() assert not MAKE_SYNTAX_MAKE_RULES_SKIPS.search(text), ( - "make_rules cannot read a conditional, define block, .RECIPEPREFIX or " - "line continuation in the Makefile" + "make_rules cannot read a conditional, define block, .RECIPEPREFIX, " + "line continuation or recipe line that opens with a directive word in " + "the Makefile" ) rules = {} recipe = [] @@ -1089,6 +1102,16 @@ def test_make_test_check_rejects_recipe_it_cannot_read(tmp_path, old, new, viola "X = 1\n\tifeq (1,0)\n\tendif\n\nall: install", id="tab-conditional-outside-rule", ), + pytest.param( + "\tcoverage xml -i\n", + "\tcoverage xml -i $(if ,\f)|| true\n", + id="form-feed-inside-recipe-line", + ), + pytest.param( + "\tcoverage xml -i\n", + "\tcoverage xml -i $(if ,\r)|| true\n", + id="carriage-return-inside-recipe-line", + ), ], ) def test_make_rules_rejects_syntax_it_cannot_follow(tmp_path, old, new): From a3f9f42c4d1423e5711e1ae853fbef4d616f599f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:40:05 +0000 Subject: [PATCH 10/11] Name non-newline line breaks in the fragment's parser clause Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JgiWGYWii3moSdXzX --- changelog.d/core649-release-test-followups.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/core649-release-test-followups.changed.md b/changelog.d/core649-release-test-followups.changed.md index d652b5119..88e52c082 100644 --- a/changelog.d/core649-release-test-followups.changed.md +++ b/changelog.d/core649-release-test-followups.changed.md @@ -1 +1 @@ -The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL`, `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`) or `PYTEST_ADDOPTS`, or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific). They fail if it holds a conditional (`ifeq` … `endif`), a `define` block, `.RECIPEPREFIX` or a line that ends in `\`, which their recipe parser can't follow. They fail if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it. They fail if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`. The parser reads past comment-only lines inside a recipe, as make does. The tests also fail if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`. They fail if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`). A missing test step's failure message now says how to update the locator. +The release tests now also fail if the root `Makefile` mentions `.IGNORE`, `.ONESHELL`, `MAKEFLAGS` (also inside a longer name such as `GNUMAKEFLAGS`) or `PYTEST_ADDOPTS`, or assigns `SHELL` or `.SHELLFLAGS` (with any assignment operator, also target-specific). They fail if it holds a conditional (`ifeq` … `endif`), a `define` block, `.RECIPEPREFIX`, a line that ends in `\` or a line break other than a newline (such as a form feed), which their recipe parser can't follow. They fail if a target on any of its rule lines, or a prerequisite that `make test` runs at any depth, lacks exactly one single-colon rule line that starts in the first column with no `;` recipe on it. They fail if a recipe line of `make test` or of a prerequisite it runs at any depth (order-only included) contains `||` or ends in `; true`, `; exit 0` or `; :`. The parser reads past comment-only lines inside a recipe, as make does. The tests also fail if the workflow, the Test job or the push workflow's test step sets `MAKEFLAGS` in `env`. They fail if any workflow job other than `push.yaml`'s Publish declares `environment: pypi` or gets `id-token: write` (from its own `permissions`, the workflow's, or `write-all`). A missing test step's failure message now says how to update the locator. From 5b084b6082b23667437f07e3421a88916d5e4c83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:46:07 +0000 Subject: [PATCH 11/11] Write U+2028 and U+2029 as escapes in the line-break pattern The previous commit put the raw U+2028 and U+2029 characters into the pattern and dropped U+2029 from its comment. Escapes keep the source ASCII and readable; the pattern matches the same nine characters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JgiWGYWii3moSdXzX --- tests/core/test_release_tagging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 9077c1172..a5bffebce 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -414,12 +414,12 @@ def pypi_publishing_actions(actions: Path) -> list[str]: re.MULTILINE, ) -# Python ends a line at \r, \v, \f, \x1c to \x1e, \x85, 
 and +# Python ends a line at \r, \v, \f, \x1c to \x1e, \x85, \u2028 and \u2029 # as well as at \n: read_text turns a lone \r into \n, and splitlines splits # at the rest. make ends a line only at \n. So make_rules would read a recipe # line such as `cmd $(if ,)|| true` as two lines and miss its # `|| true` or upload. -MAKE_LINE_BREAK_MAKE_IGNORES = re.compile(r"\r(?!\n)|[\v\f\x1c-\x1e\x85

]") +MAKE_LINE_BREAK_MAKE_IGNORES = re.compile(r"\r(?!\n)|[\v\f\x1c-\x1e\x85\u2028\u2029]") def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]: