# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for workflow sync module."""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
import yaml
from repomatic.bundle import get_data_content
from repomatic.cli.github import unsubscribe_threads_cmd
from repomatic.components import (
ALL_WORKFLOW_FILES,
NON_REUSABLE_WORKFLOWS,
RELEASE_ENGINE_WORKFLOWS,
REUSABLE_WORKFLOWS,
WORKFLOW_SOURCES,
)
from repomatic.config import Config, WorkflowConfig
from repomatic.github import workflow_sync as ws
from repomatic.github.workflow_files import load_workflow, workflow_triggers
from repomatic.github.workflow_sync import (
PathsSpec,
WorkflowTriggerInfo,
_adapt_trigger_paths,
_needs_yaml_quote,
_split_yaml_quote,
_substitute_source_paths,
canonical_caller_permissions,
canonical_workflow,
cooldown_env_block,
extract_extra_jobs,
extract_trigger_info,
extras_define_jobs,
generate_thin_caller,
generate_workflow_header,
render_thin_caller_for_target,
)
from repomatic.lint_repo.context import LintContext
from repomatic.lint_repo.workflows import check_workflow_permissions
from repomatic.pyproject import derive_source_paths, resolve_source_paths
from repomatic.tooling.runner import run_tool
from repomatic.upstream import (
DEFAULT_REPO,
UPSTREAM_ASSET_GLOB,
UPSTREAM_SOURCE_GLOB,
UPSTREAM_SOURCE_PREFIX,
)
from tests.conftest import (
PROJECT_ROOT,
WORKFLOWS_WITH_CONCURRENCY_BLOCK,
WORKFLOWS_WITHOUT_CONCURRENCY_BLOCK,
skip_unless_tool_runs,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Any
[docs]
def test_reusable_workflows_sorted() -> None:
"""Verify reusable workflows are sorted."""
assert list(REUSABLE_WORKFLOWS) == sorted(REUSABLE_WORKFLOWS)
[docs]
def test_all_workflow_files_sorted() -> None:
"""Verify all workflow files are sorted."""
assert list(ALL_WORKFLOW_FILES) == sorted(ALL_WORKFLOW_FILES)
[docs]
def test_non_reusable_subset_of_all() -> None:
"""Verify non-reusable workflows are a subset of all workflows."""
assert NON_REUSABLE_WORKFLOWS <= set(ALL_WORKFLOW_FILES)
[docs]
def test_reusable_subset_of_all() -> None:
"""Verify reusable workflows are a subset of all workflows."""
assert set(REUSABLE_WORKFLOWS) <= set(ALL_WORKFLOW_FILES)
[docs]
def test_no_overlap() -> None:
"""Verify no overlap between reusable and non-reusable sets."""
assert not (set(REUSABLE_WORKFLOWS) & NON_REUSABLE_WORKFLOWS)
[docs]
def test_union_is_all() -> None:
"""Verify union of reusable and non-reusable equals all."""
assert set(REUSABLE_WORKFLOWS) | NON_REUSABLE_WORKFLOWS == set(ALL_WORKFLOW_FILES)
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_reusable_has_workflow_call(filename: str) -> None:
"""Verify all reusable workflows have workflow_call trigger."""
info = extract_trigger_info(WORKFLOW_SOURCES.get(filename, filename))
assert info.has_workflow_call is True
[docs]
@pytest.mark.parametrize("filename", sorted(NON_REUSABLE_WORKFLOWS))
def test_non_reusable_no_workflow_call(filename: str) -> None:
"""Verify non-reusable workflows lack workflow_call trigger."""
info = extract_trigger_info(filename)
assert info.has_workflow_call is False
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_returns_trigger_info(filename: str) -> None:
"""Verify return type is WorkflowTriggerInfo."""
source = WORKFLOW_SOURCES.get(filename, filename)
info = extract_trigger_info(source)
assert isinstance(info, WorkflowTriggerInfo)
assert info.filename == source
assert isinstance(info.name, str)
assert len(info.name) > 0
[docs]
@pytest.mark.parametrize(
("filename", "secret"),
(
("autofix.yaml", "REPOMATIC_PAT"),
("changelog.yaml", "REPOMATIC_PAT"),
("unsubscribe.yaml", "REPOMATIC_NOTIFICATIONS_PAT"),
),
)
def test_reusable_workflow_declares_its_secret(filename: str, secret: str) -> None:
"""A reusable workflow declares the secret its callers pass."""
assert secret in extract_trigger_info(filename).call_secrets
[docs]
def test_release_has_secrets() -> None:
"""Verify release.yaml defines secrets.
`PYPI_TOKEN` was removed once the publish step migrated to OIDC-based
Trusted Publishing via the `publish-pypi` composite action invoked from
the caller workflow. See pypi/warehouse#11096 for context.
"""
info = extract_trigger_info("_release-engine.yaml")
assert "PYPI_TOKEN" not in info.call_secrets
assert "REPOMATIC_PAT" in info.call_secrets
# The complete workflow_call input surface every thin-caller-deployed reusable
# workflow forwards. unsubscribe.yaml (config-gated, off by default) forwards its
# dispatch inputs. The release engine (recorded as release.yaml's source) takes
# the build lane's metadata from the generated release.yaml, whose `with:` block
# comes from the bundled release.yaml of the running version: a first adoption
# the cooldown steps back to a release whose engine predates the input writes a
# caller GitHub rejects, until the pin catches up. The rest forward nothing.
EXPECTED_THIN_CALLER_CALL_INPUTS: dict[str, set[str]] = {
"release.yaml": {"metadata"},
"unsubscribe.yaml": {"max-unsubscribes", "dry-run", "months"},
}
LANE_CALL_INPUTS: dict[str, frozenset[str]] = {
"release.yaml": frozenset(("metadata",)),
}
"""Inputs carrying data from another lane of the same run, not configuration.
The release engine reads the metadata the build lane computed, which its caller
passes on: no `workflow_dispatch` counterpart can supply it.
"""
# release.yaml is excluded: its multi-lane caller is synthesized by
# _generate_release_caller, not the single-job thin delegation checked here.
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_no_python_literals_in_yaml(filename: str) -> None:
"""Verify generated YAML contains no Python dict/list literals.
Regression test for a bug where ``_render_trigger_value`` fell through to
``str()`` on nested dicts, producing ``{'key': 'value'}`` instead of
block-style YAML.
Kept alongside the two linter passes below, which do not subsume it.
actionlint rejects a leaked literal only where the workflow schema
constrains the value it landed in (a flow mapping under `schedule:`, which
takes a list); the same leak inside a free-form value, a `paths:` entry or
an input default, is well-formed YAML that both linters accept. This scans
the rendered text wherever it appears, and names the defect directly.
"""
content = generate_thin_caller(filename)
assert "{'" not in content, f"{filename}: Python dict literal found in output"
assert "'}" not in content, f"{filename}: Python dict literal found in output"
[docs]
@pytest.mark.parametrize(
"value",
(
pytest.param(["kiwi", "mango"], id="scalar-list"),
pytest.param([{"fruit": "papaya", "city": "Lima"}], id="two-key-item"),
pytest.param(
{"harvest": [{"fruit": "papaya", "city": "Lima"}, "kiwi"]},
id="nested-two-key-item",
),
pytest.param({"kiwi": None}, id="empty-key"),
pytest.param("papaya", id="scalar"),
),
)
def test_render_trigger_value_round_trips(value: Any) -> None:
"""A trigger value of any shape renders as YAML that parses back to itself.
No bundled workflow carries most of these shapes, so rendering the bundled
callers leaves them unchecked.
"""
assert yaml.safe_load(ws._render_trigger_value(value, indent=4)) == value
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_generated_caller_ends_with_one_newline(filename: str) -> None:
"""Every generated caller ends with exactly one trailing newline.
Nothing downstream of the generator collapses trailing whitespace:
`repomatic.init.run._InitRun.write` writes callers with
`normalize=False`, because their exact bytes carry downstream-owned jobs
verbatim. A stray blank line therefore reaches every consuming repository.
`release.yaml` shipped one for exactly that reason, alone among the callers:
its release lane already ends with the blank separator that
`_generate_release_caller` then appended a second time, so the file ended
`}}\\n\\n`.
"""
content = generate_thin_caller(filename)
trailing = len(content) - len(content.rstrip("\n"))
assert trailing == 1, (
f"{filename}: generated caller ends with {trailing} trailing newlines, "
"expected exactly 1"
)
PINNED_CALLER_SHA = "0" * 40
"""Placeholder commit SHA for the pinned `uses:` rendering.
actionlint checks the *shape* of a `uses:` reference without resolving it, so a
synthetic SHA exercises that branch with no network lookup and no tie to a
commit that has to keep existing.
"""
def _materialize_thin_callers(root: Path) -> list[str]:
"""Write every generated thin caller under *root* as a real workflow tree.
Both `uses:` renderings are covered: the bare `@version` tag ref, and the
`@sha # version` form `repomatic init` writes into a downstream repo. The
two differ in the trailing comment on the `uses:` line, so linting one
leaves the other's rendering unchecked.
Files land under a `.github/workflows/` path because actionlint keys part
of its rule set on that location.
:param root: Directory to build the workflow trees under.
:return: Every written path, ready to hand to a linter.
"""
written: list[str] = []
for variant, commit_sha in (("tag-ref", None), ("sha-pinned", PINNED_CALLER_SHA)):
workflows = root / variant / ".github" / "workflows"
workflows.mkdir(parents=True)
for filename in REUSABLE_WORKFLOWS:
content = generate_thin_caller(
filename, version="v9.9.9", commit_sha=commit_sha
)
target = workflows / filename
target.write_text(content, encoding="UTF-8")
written.append(str(target))
return written
[docs]
@pytest.mark.once
@pytest.mark.real_http
def test_generated_callers_pass_yamllint(tmp_path: Path) -> None:
"""Generated thin callers must clear the yamllint rules CI enforces.
A thin caller is the one artifact this repository generates and never
materializes into its own tree: `.github/workflows/` here holds the
canonical workflows, so the `lint` job's yamllint step never sees a
generated file. The first checkout that does is a downstream repo, a
release later, which makes a regression here something a user reports
rather than something CI catches. Running the pinned yamllint over the
output moves that verdict back to the commit that introduced it.
In practice that is the 120-column cap and little else: a native config
replaces yamllint's defaults rather than layering over them, and the
bundled `yamllint.yaml` sets `line-length` alone. It is still the rule
worth guarding, being the one a renderer breaches by widening a single
interpolated value, and the one `tests/test_prepare_release.py`
re-implements in Python to cover the freeze path. Any rule the bundled
config grows later is covered here for free.
Marked `once`: it resolves yamllint through uvx, so one runner suffices.
"""
skip_unless_tool_runs("yamllint")
assert run_tool("yamllint", extra_args=_materialize_thin_callers(tmp_path)) == 0, (
"yamllint rejects generated thin-caller output; its diagnostics are in "
"the captured stdout above. Fix the renderers in "
"repomatic/github/workflow_sync.py rather than any workflow in this "
"repository: this output only ever lands in a downstream checkout."
)
[docs]
@pytest.mark.once
@pytest.mark.real_http
def test_generated_callers_pass_actionlint(tmp_path: Path) -> None:
"""Generated thin callers must be workflows GitHub will actually accept.
Companion to the yamllint pass, and the stronger half of the pair: it
reads the workflow schema, so it sees what neither a YAML parser nor a
column cap can. A `uses:` reference stripped of its `@ref`, a `${{ }}`
expression naming a context property that does not exist, a trigger value
whose type is wrong for the key it sits under: each parses cleanly, and
each fails at startup on the runner. For a generated caller that means
failing in someone else's repository.
Marked `once`: it downloads the pinned actionlint binary, so one runner
suffices.
"""
skip_unless_tool_runs("actionlint")
assert (
run_tool("actionlint", extra_args=_materialize_thin_callers(tmp_path)) == 0
), (
"actionlint rejects generated thin-caller output; its diagnostics are "
"in the captured stdout above. Fix the renderers in "
"repomatic/github/workflow_sync.py rather than any workflow in this "
"repository: this output only ever lands in a downstream checkout."
)
[docs]
def test_changelog_has_workflow_run() -> None:
"""Verify changelog.yaml has workflow_run trigger."""
info = extract_trigger_info("changelog.yaml")
assert "workflow_run" in info.non_call_triggers
[docs]
def test_autolock_has_schedule() -> None:
"""Verify autolock.yaml has schedule trigger."""
info = extract_trigger_info("autolock.yaml")
assert "schedule" in info.non_call_triggers
[docs]
def test_nonexistent_file() -> None:
"""Raise FileNotFoundError for missing workflow."""
with pytest.raises(FileNotFoundError):
extract_trigger_info("nonexistent.yaml")
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_generates_valid_yaml(filename: str) -> None:
"""Verify generated content is valid YAML."""
content = generate_thin_caller(filename)
data = yaml.safe_load(content)
assert isinstance(data, dict)
# release.yaml is excluded: it is call-only upstream, so its caller synthesizes
# triggers instead of mirroring (see test_release_thin_caller_synthesizes_triggers).
[docs]
@pytest.mark.parametrize(
"filename", [f for f in REUSABLE_WORKFLOWS if f != "release.yaml"]
)
def test_mirrors_canonical_dispatch(filename: str) -> None:
"""Caller's workflow_dispatch presence mirrors canonical, no synthesis."""
content = generate_thin_caller(filename)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
canonical = extract_trigger_info(filename)
canonical_has_dispatch = "workflow_dispatch" in canonical.non_call_triggers
assert ("workflow_dispatch" in triggers) is canonical_has_dispatch
[docs]
def test_release_thin_caller_synthesizes_triggers() -> None:
"""The release.yaml caller synthesizes the standard push + dispatch triggers.
`_generate_release_caller` synthesizes the standard release triggers rather
than mirroring the canonical release.yaml's own (so a dogfooding-only trigger
added upstream never leaks into downstream callers). The weekly full-fleet
`schedule` is the one canonical trigger carried over, verbatim.
"""
content = generate_thin_caller("release.yaml")
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
assert "workflow_dispatch" in triggers
assert triggers["push"] == {"branches": ["main"]}
canonical_schedule = extract_trigger_info("release.yaml").non_call_triggers[
"schedule"
]
assert triggers["schedule"] == canonical_schedule
# The engine lane is call-only.
assert extract_trigger_info("_release-engine.yaml").non_call_triggers == {}
[docs]
def test_release_caller_emits_concurrency() -> None:
"""The release.yaml caller carries the entry workflow's concurrency group.
Unlike a simple thin caller, the multi-lane release caller must declare
concurrency on its push-triggered entry: the concurrency-bearing engine lane
runs behind ``needs: build``, so a block there joins its group too late to
cancel superseded runs. The group protects release commits via github.sha.
"""
content = generate_thin_caller("release.yaml", version="v9.9.9")
data = yaml.safe_load(content)
assert "concurrency" in data
group = data["concurrency"]["group"]
assert "github.sha" in group
assert "[changelog] Release" in group
assert "[changelog] Post-release" in group
assert data["concurrency"]["cancel-in-progress"] is True
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
@pytest.mark.repo_maintenance
def test_correct_uses_ref(filename: str) -> None:
"""Verify correct uses reference in job."""
content = generate_thin_caller(filename)
source = WORKFLOW_SOURCES.get(filename, filename)
expected = f"{DEFAULT_REPO}/.github/workflows/{source}@main"
assert expected in content
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_starts_with_document_marker(filename: str) -> None:
"""Verify generated YAML starts with --- document marker."""
content = generate_thin_caller(filename)
assert content.startswith("---\n")
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_quoted_on_key(filename: str) -> None:
"""Verify the on key is quoted as ``"on":``."""
content = generate_thin_caller(filename)
assert '"on":' in content
[docs]
@pytest.mark.parametrize("filename", ("autofix.yaml", "changelog.yaml", "lint.yaml"))
def test_thin_caller_passes_secrets_explicitly(filename: str) -> None:
"""A thin caller names each secret instead of inheriting them all."""
content = generate_thin_caller(filename)
assert "secrets: inherit" not in content
assert "REPOMATIC_PAT: ${{ secrets.REPOMATIC_PAT }}" in content
[docs]
def test_release_passes_secrets_explicitly() -> None:
"""Verify release.yaml thin caller passes secrets explicitly.
`PYPI_TOKEN` is no longer in the secrets surface: PyPI uploads run via
OIDC Trusted Publishing inside the caller-side `publish-pypi` job.
"""
content = generate_thin_caller("release.yaml")
assert "secrets: inherit" not in content
assert "PYPI_TOKEN" not in content
assert "REPOMATIC_PAT: ${{ secrets.REPOMATIC_PAT }}" in content
[docs]
def test_release_thin_caller_emits_publish_pypi_job() -> None:
"""The release.yaml caller emits the publish-pypi job gated on the build lane."""
content = generate_thin_caller("release.yaml", version="v9.9.9")
assert " publish-pypi:" in content
# publish-pypi depends only on the build lane so the wheel ships to PyPI right
# after it is built, not after the whole engine (binaries, scan) completes.
assert "needs: build" in content
# The gate is decoupled from the overall run result: always() + package_built
# let a healthy wheel publish even when an unrelated job (like binary tests)
# fails the run. Both signals come from the build lane.
assert "always()" in content
assert "needs.build.outputs.package_built == 'true'" in content
assert "needs.build.outputs.release_commits_matrix" in content
assert "id-token: write" in content
assert f"{DEFAULT_REPO}/.github/actions/[email protected]" in content
assert (
"artifact-name: ${{ github.event.repository.name }}-${{ matrix.short_sha }}"
in content
)
[docs]
def test_release_thin_caller_emits_build_and_engine_lanes() -> None:
"""Verify the release.yaml caller calls both reusable lanes with pinned refs.
The build lane (`_release-build.yaml`) feeds publish-pypi; the engine lane
(`_release-engine.yaml`) runs binaries and finalization. Both are pinned to
the requested version and the engine lane is gated on the build lane so it
can download the run-scoped wheel.
"""
content = generate_thin_caller("release.yaml", version="v9.9.9")
for lane in RELEASE_ENGINE_WORKFLOWS:
assert f"{DEFAULT_REPO}/.github/workflows/{lane}@v9.9.9" in content, (
f"generated release.yaml must reference the {lane} engine lane"
)
# The engine lane waits for the build lane (run-scoped wheel handoff).
data = yaml.safe_load(content)
assert data["jobs"]["release"]["needs"] == "build"
[docs]
def test_release_thin_caller_publish_pypi_uses_sha_pin() -> None:
"""Verify SHA-pinned commit form propagates to the publish-pypi action ref."""
sha = "1234567890abcdef1234567890abcdef12345678"
content = generate_thin_caller("release.yaml", version="v9.9.9", commit_sha=sha)
assert f"{DEFAULT_REPO}/.github/actions/publish-pypi@{sha} # v9.9.9" in content
[docs]
def test_non_release_thin_caller_omits_publish_pypi_job() -> None:
"""Verify non-release thin callers do not gain a publish-pypi job."""
for filename in REUSABLE_WORKFLOWS:
if filename == "release.yaml":
continue
content = generate_thin_caller(filename)
assert "publish-pypi:" not in content, (
f"{filename} unexpectedly emits a publish-pypi job"
)
[docs]
def test_release_thin_caller_publish_pypi_omits_checkout() -> None:
"""The downstream publish-pypi job should not require an explicit checkout.
The cross-repo composite action `uses:` form is fetched by GitHub
automatically: dropping `actions/checkout` keeps the generator free of
third-party action SHA pins (which `sync-action-pins` would not see, since
the generator's source is `.py`, not `.yaml`).
"""
content = generate_thin_caller("release.yaml")
assert "actions/checkout" not in content
[docs]
def test_release_thin_caller_publish_pypi_sourced_from_release_yaml() -> None:
"""The publish-pypi job body must be sourced from the bundled release.yaml.
Deriving it from a `.yaml` file (rather than building it in Python) keeps
any future third-party SHA pin in the job body visible to `sync-action-pins`.
The canonical entry carries the job with a local `./` action ref that the
generator reshapes for downstream callers.
"""
release_yaml = get_data_content("release.yaml")
parsed = yaml.safe_load(release_yaml)
job = parsed["jobs"]["publish-pypi"]
assert job["permissions"]["id-token"] == "write"
# The bundled source dogfoods the local action ref; the swap to the pinned
# cross-repo form happens at render time, not in the source.
assert "./.github/actions/publish-pypi" in release_yaml
[docs]
def test_release_thin_caller_publish_pypi_keeps_canonical_runner() -> None:
"""The generated downstream job inherits the canonical runner verbatim.
The body is carried through from release.yaml unchanged except for the
checkout drop and the action-ref pin, so downstream callers run on whichever
image repomatic itself uses. The expectation is read from that source rather
than written here: pinning the literal made this fail as a fleet migration
rather than as the drift it exists to catch.
"""
source = yaml.safe_load(get_data_content("release.yaml"))
canonical = source["jobs"]["publish-pypi"]["runs-on"]
assert not canonical.endswith("-latest"), (
f"release.yaml's publish-pypi runs on {canonical!r}, a floating alias "
"that GitHub repoints without a commit here."
)
content = generate_thin_caller("release.yaml", version="v9.9.9")
assert f"runs-on: {canonical}" in content
assert "ubuntu-latest" not in content
def _patch_release_yaml(monkeypatch, body: str) -> None:
"""Override only the bundled release.yaml, delegating other names to disk.
`get_data_content` also fetches `_release-engine.yaml` for trigger
extraction; a blanket override would feed the test body to that call and
break `extract_trigger_info`.
"""
real_get = ws.get_data_content
def fake_get(name: str) -> str:
if name == "release.yaml":
return body
return real_get(name)
monkeypatch.setattr(ws, "get_data_content", fake_get)
[docs]
def test_release_thin_caller_rewrites_local_action_ref(monkeypatch) -> None:
"""Render rewrites the canonical local action ref and drops the checkout.
The bundled release.yaml dogfoods the in-tree action via a local `./` ref
(checking itself out first); the downstream caller must instead pin the
cross-repo composite action (so its OIDC `job_workflow_ref` resolves to its
own release.yaml) and needs no checkout.
"""
_patch_release_yaml(
monkeypatch,
"---\n"
"jobs:\n"
" build:\n"
" uses: ./.github/workflows/_release-build.yaml\n"
" publish-pypi:\n"
" permissions:\n"
" id-token: write\n"
" runs-on: ubuntu-slim\n"
" steps:\n"
" - uses: actions/checkout@abc123\n"
" - uses: ./.github/actions/publish-pypi\n"
" with:\n"
" artifact-name: x\n"
" release:\n"
" uses: ./.github/workflows/_release-engine.yaml\n",
)
content = generate_thin_caller("release.yaml", version="v9.9.9")
assert f"{DEFAULT_REPO}/.github/actions/[email protected]" in content
assert "./.github/actions/publish-pypi" not in content
assert "actions/checkout" not in content
# Both reusable lanes are pinned to the requested version.
assert f"{DEFAULT_REPO}/.github/workflows/[email protected]" in content
assert f"{DEFAULT_REPO}/.github/workflows/[email protected]" in content
[docs]
def test_release_thin_caller_drops_self_repository_ignores() -> None:
"""No downstream caller inherits the canonical entry's zizmor suppressions.
The canonical `release.yaml` keeps workspace-relative `uses: ./…` refs and
silences zizmor's `self-repository` audit inline on each one. Downstream
callers get pinned cross-repo refs, which the audit never fires on, so the
suppression is scaffolding: carried through it would trail a second `#`
comment past the version pin and push the line over yamllint's limit.
"""
canonical = get_data_content("release.yaml")
assert "zizmor: ignore[self-repository]" in canonical, (
"The canonical release.yaml no longer carries the inline suppression "
"this test exists to keep out of downstream callers."
)
content = generate_thin_caller(
"release.yaml", version="v9.9.9", commit_sha="a" * 40
)
assert "zizmor:" not in content
over_limit = [line for line in content.split("\n") if len(line) > 120]
assert not over_limit, over_limit
[docs]
def test_release_thin_caller_unrecognized_job_raises(monkeypatch) -> None:
"""Render fails loudly when release.yaml's publish-pypi job lacks the action.
A canonical entry reshaped in a way the renderer was not updated to handle
(no local action ref in the job) must surface as an error, not a silent
no-op that emits a malformed workflow.
"""
_patch_release_yaml(
monkeypatch,
"---\n"
"jobs:\n"
" build:\n"
" uses: ./.github/workflows/_release-build.yaml\n"
" publish-pypi:\n"
" permissions:\n"
" id-token: write\n"
" steps:\n"
" - run: echo no-action-ref-here\n"
" release:\n"
" uses: ./.github/workflows/_release-engine.yaml\n",
)
with pytest.raises(RuntimeError, match="publish-pypi"):
generate_thin_caller("release.yaml", version="v9.9.9")
[docs]
def test_custom_version() -> None:
"""Verify custom version in uses reference."""
content = generate_thin_caller("lint.yaml", version="v5.8.0")
assert f"{DEFAULT_REPO}/.github/workflows/[email protected]" in content
[docs]
@pytest.mark.repo_maintenance
def test_custom_repo() -> None:
"""Verify custom repo in uses reference."""
content = generate_thin_caller("lint.yaml", repo="myorg/myworkflows")
assert "myorg/myworkflows/.github/workflows/lint.yaml@main" in content
[docs]
def test_non_reusable_raises() -> None:
"""Raise ValueError for non-reusable workflow."""
with pytest.raises(ValueError, match="workflow_call"):
generate_thin_caller("tests.yaml")
[docs]
def test_has_jobs_section() -> None:
"""Verify generated YAML has jobs section."""
content = generate_thin_caller("lint.yaml")
data = yaml.safe_load(content)
assert "jobs" in data
[docs]
def test_has_name() -> None:
"""Verify generated YAML has name field."""
content = generate_thin_caller("lint.yaml")
data = yaml.safe_load(content)
assert "name" in data
[docs]
def test_identifies_thin_caller() -> None:
"""Identify a thin caller workflow."""
content = (
"---\nname: Lint\njobs:\n lint:\n"
f" uses: {DEFAULT_REPO}/.github/workflows/[email protected]\n"
)
assert canonical_workflow(_parsed(content)) == "lint.yaml"
[docs]
def test_returns_none_for_non_caller() -> None:
"""Return None for non-caller workflow."""
content = (
"---\nname: Custom\njobs:\n build:\n runs-on: ubuntu-latest\n"
" steps:\n - run: echo hello\n"
)
assert canonical_workflow(_parsed(content)) is None
[docs]
def test_canonical_workflow_matches_a_custom_repo() -> None:
"""Match with custom repo."""
content = (
"---\nname: Lint\njobs:\n lint:\n"
" uses: myorg/myrepo/.github/workflows/[email protected]\n"
)
assert canonical_workflow(_parsed(content), repo="myorg/myrepo") == "lint.yaml"
# ---------------------------------------------------------------------------
# extract_extra_jobs
# ---------------------------------------------------------------------------
[docs]
def test_extract_extra_jobs_odd_indent_body_stays_managed() -> None:
"""A hand-edited 3-space body line does not truncate the managed job.
The body walk used to require exactly 4+ spaces, so a 3-space line ended
it early and the managed job's tail leaked into the extras, which the
next sync duplicated below the regenerated lanes.
"""
content = (
f"---\nname: Release\njobs:\n\n release:\n"
f" uses: {DEFAULT_REPO}/.github/workflows/[email protected]\n"
" # Hand-edited three-space comment line.\n"
" secrets:\n"
" REPOMATIC_PAT: ${{ secrets.REPOMATIC_PAT }}\n"
"\n"
" mine:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo hello\n"
)
extra = extract_extra_jobs(content)
assert "REPOMATIC_PAT" not in extra
assert "three-space comment" not in extra
assert "mine:" in extra
FOUR_SPACE_CALLER = (
"---\nname: Lint\njobs:\n"
" lint:\n"
f" uses: {DEFAULT_REPO}/.github/workflows/[email protected]\n"
"\n"
" # Ship the papaya build.\n"
" deploy:\n"
" needs: lint\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo papaya\n"
)
"""A thin caller indenting its jobs by four spaces, with one downstream job."""
[docs]
def test_thin_caller_sync_keeps_the_jobs_of_a_four_space_caller(
tmp_path: Path,
) -> None:
"""A sync of a four-space caller keeps its downstream job a job."""
target = tmp_path / "lint.yaml"
target.write_text(FOUR_SPACE_CALLER, encoding="UTF-8")
sync_caller("lint.yaml", target)
synced = load_workflow(target.read_text(encoding="UTF-8"))
assert synced is not None
assert synced["jobs"]["deploy"]["needs"] == "lint"
assert synced["jobs"]["deploy"]["steps"] == [{"run": "echo papaya"}]
[docs]
def sync_caller(
filename: str,
target: Path,
version: str = "v1.2.3",
commit_sha: str | None = None,
paths_spec: PathsSpec | None = None,
) -> None:
"""Render *filename* onto *target*, the way `repomatic init` syncs one file.
Wraps the single render seam so a test never re-implements the read-render-
write dance that `_init_workflows` performs, which is how the two used to
drift apart.
"""
content, _existing = render_thin_caller_for_target(
filename,
target,
repo=DEFAULT_REPO,
version=version,
commit_sha=commit_sha,
paths_spec=paths_spec,
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="UTF-8")
def _parsed(text: str) -> dict:
"""Parse a workflow fixture, failing the test when it is not a mapping."""
data = load_workflow(text)
assert data is not None
return data
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_every_reusable_workflow_renders(tmp_path: Path, filename: str) -> None:
"""Each reusable workflow renders into a caller carrying the version ref."""
target = tmp_path / filename
sync_caller(filename, target, version="v5.8.0")
assert "v5.8.0" in target.read_text(encoding="UTF-8")
[docs]
def test_sync_overwrites(tmp_path: Path) -> None:
"""A file that is not a caller at all is replaced wholesale.
Nothing in it parses as a managed lane, so there are no extras to carry
over and the render starts from the canonical workflow.
"""
target = tmp_path / "lint.yaml"
target.write_text("old content", encoding="UTF-8")
sync_caller("lint.yaml", target)
assert "old content" not in target.read_text(encoding="UTF-8")
[docs]
def test_non_reusable_workflow_refuses_to_render(tmp_path: Path) -> None:
"""A workflow with no `workflow_call` trigger cannot become a caller.
`tests.yaml` is copied downstream header-first by `_init_workflows`, never
rendered as a caller, so asking for one is a programming error rather than
a file to write.
"""
with pytest.raises(ValueError, match="workflow_call"):
sync_caller("tests.yaml", tmp_path / "tests.yaml")
[docs]
def test_canonical_workflow_reads_a_generated_caller() -> None:
"""A thin caller the generator writes names the workflow it calls."""
content = generate_thin_caller("lint.yaml", version="v5.8.0")
assert canonical_workflow(_parsed(content)) == "lint.yaml"
# ---------------------------------------------------------------------------
# Concurrency extraction tests
# ---------------------------------------------------------------------------
# `extract_trigger_info` reads the *bundled* copy of a workflow, so both rosters
# are narrowed to what ships: `self-maintenance.yaml` and `_release-build.yaml`
# live in `.github/workflows/` but have no counterpart under `repomatic/data/`,
# and asking for one raises FileNotFoundError rather than reporting an absent
# concurrency block.
BUNDLED_WORKFLOWS = frozenset(
p.name for p in (PROJECT_ROOT / "repomatic" / "data").glob("*.yaml")
)
BUNDLED_WITH_CONCURRENCY = tuple(
name for name in WORKFLOWS_WITH_CONCURRENCY_BLOCK if name in BUNDLED_WORKFLOWS
)
"""Bundled workflows that define a concurrency block."""
BUNDLED_WITHOUT_CONCURRENCY = tuple(
name for name in WORKFLOWS_WITHOUT_CONCURRENCY_BLOCK if name in BUNDLED_WORKFLOWS
)
"""Bundled workflows that do not.
`_release-engine.yaml` is here by design: it delegates concurrency to the
push-triggered `release.yaml` entry that calls it (see
`_generate_release_caller`).
"""
[docs]
@pytest.mark.parametrize("filename", BUNDLED_WITH_CONCURRENCY)
def test_concurrency_present(filename: str) -> None:
"""Verify concurrency is extracted for workflows that define it."""
info = extract_trigger_info(filename)
assert info.concurrency is not None
assert info.raw_concurrency is not None
assert "concurrency:" in info.raw_concurrency
[docs]
@pytest.mark.parametrize("filename", BUNDLED_WITHOUT_CONCURRENCY)
def test_concurrency_absent(filename: str) -> None:
"""Verify concurrency is None for workflows without it."""
info = extract_trigger_info(filename)
assert info.concurrency is None
assert info.raw_concurrency is None
[docs]
def test_concurrency_preserves_expressions() -> None:
"""Verify raw concurrency preserves ``${{ }}`` expressions."""
info = extract_trigger_info("lint.yaml")
assert info.raw_concurrency is not None
assert "${{" in info.raw_concurrency
# ---------------------------------------------------------------------------
# Thin caller omits concurrency
# ---------------------------------------------------------------------------
# release.yaml is excluded: its multi-lane caller carries concurrency because
# the engine lane it calls joins its group too late to cancel queued runs. See
# test_release_caller_emits_concurrency.
[docs]
@pytest.mark.parametrize(
"filename", [f for f in REUSABLE_WORKFLOWS if f != "release.yaml"]
)
def test_thin_caller_omits_concurrency(filename: str) -> None:
"""Verify simple thin callers never include concurrency.
A simple thin caller's single job joins the reusable workflow's own
concurrency group immediately when called via ``workflow_call``, so
duplicating the block in the caller is unnecessary.
"""
content = generate_thin_caller(filename)
assert "concurrency:" not in content
# ---------------------------------------------------------------------------
# Thin caller omits upstream-only paths but keeps universal entries
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize("filename", REUSABLE_WORKFLOWS)
def test_thin_caller_drops_upstream_source_paths(filename: str) -> None:
"""Thin callers drop `repomatic/`-prefixed paths even without source_paths.
Universal entries (`pyproject.toml`, workflow self-refs) are preserved so
trigger semantics carry over from the canonical workflow.
"""
content = generate_thin_caller(filename)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
for trigger_name, trigger_config in triggers.items():
if not isinstance(trigger_config, dict):
continue
for key in ("paths", "paths-ignore"):
for path in trigger_config.get(key, []) or []:
assert not path.startswith("repomatic/"), (
f"{filename}: trigger '{trigger_name}' kept upstream path"
f" '{path}' in {key}."
)
# ---------------------------------------------------------------------------
# SHA-pinned thin callers
# ---------------------------------------------------------------------------
FAKE_SHA = "072c7bbbcdd607011c6ca4fb9d5098532aee2dea"
[docs]
def test_thin_caller_sha_pinned() -> None:
"""Thin caller with ``commit_sha`` produces ``@sha # version``."""
content = generate_thin_caller("lint.yaml", version="v6.8.0", commit_sha=FAKE_SHA)
assert f"@{FAKE_SHA} # v6.8.0" in content
[docs]
def test_thin_caller_sha_none_fallback() -> None:
"""Thin caller without ``commit_sha`` produces ``@version``."""
content = generate_thin_caller("lint.yaml", version="v6.8.0", commit_sha=None)
assert "@v6.8.0" in content
assert f"@{FAKE_SHA}" not in content
[docs]
def test_thin_caller_sha_pinned_yaml_valid() -> None:
"""SHA-pinned thin caller is valid YAML with comment stripped."""
content = generate_thin_caller("lint.yaml", version="v6.8.0", commit_sha=FAKE_SHA)
data = yaml.safe_load(content)
jobs = data.get("jobs", {})
uses = next(iter(jobs.values()))["uses"]
# YAML parser strips the # comment — value is just the SHA part.
assert uses.endswith(f"@{FAKE_SHA}")
assert "v6.8.0" not in uses
[docs]
def test_canonical_workflow_sha_pinned() -> None:
"""``canonical_workflow`` recognizes SHA-pinned thin callers."""
content = generate_thin_caller("lint.yaml", version="v6.8.0", commit_sha=FAKE_SHA)
assert canonical_workflow(_parsed(content)) == "lint.yaml"
# ---------------------------------------------------------------------------
# Header generation tests
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Source path substitution tests
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("paths", "source_paths", "expected"),
[
pytest.param(
[UPSTREAM_SOURCE_GLOB, "tests/**", "pyproject.toml"],
["extra_platforms"],
["extra_platforms/**", "tests/**", "pyproject.toml"],
id="glob-replaced-in-place",
),
pytest.param(
[UPSTREAM_SOURCE_GLOB, "pyproject.toml"],
["pkg_a", "pkg_b"],
["pkg_a/**", "pkg_b/**", "pyproject.toml"],
id="glob-fans-out-to-several",
),
# An upstream path that is not the source glob has no downstream
# counterpart to become, so it is dropped rather than translated.
pytest.param(
[
UPSTREAM_SOURCE_GLOB,
f"{UPSTREAM_SOURCE_PREFIX}data/labels.toml",
"config.json",
],
["my_pkg"],
["my_pkg/**", "config.json"],
id="upstream-specific-dropped",
),
# Downstream `.claude/` holds synced copies no downstream test reads.
pytest.param(
[UPSTREAM_SOURCE_GLOB, UPSTREAM_ASSET_GLOB, "tests/**"],
["my_pkg"],
["my_pkg/**", "tests/**"],
id="upstream-assets-dropped",
),
pytest.param(
["tests/**", "pyproject.toml", "uv.lock", "changelog.md"],
["my_pkg"],
["tests/**", "pyproject.toml", "uv.lock", "changelog.md"],
id="universal-paths-untouched",
),
pytest.param(
[UPSTREAM_SOURCE_GLOB, "pyproject.toml"],
[],
["pyproject.toml"],
id="no-source-paths-drops-the-glob",
),
],
)
def test_substitute_source_paths(
paths: list[str], source_paths: list[str], expected: list[str]
) -> None:
"""Upstream source globs are retargeted; everything else is kept or dropped."""
assert _substitute_source_paths(paths, source_paths) == expected
[docs]
@pytest.mark.parametrize(
("config", "filename", "spec", "expected"),
[
pytest.param(
{"branches": ["main"], "paths": [UPSTREAM_SOURCE_GLOB, "pyproject.toml"]},
"tests.yaml",
PathsSpec(source_paths=("extra_platforms",)),
{"branches": ["main"], "paths": ["extra_platforms/**", "pyproject.toml"]},
id="source-paths-substituted",
),
pytest.param(
{
"branches": ["main"],
"paths": [
UPSTREAM_SOURCE_GLOB,
"pyproject.toml",
"repomatic/data/labels.toml",
],
},
"tests.yaml",
PathsSpec(),
{"branches": ["main"], "paths": ["pyproject.toml"]},
id="no-source-paths-keeps-universal",
),
# Emptying the list drops the key: an empty `paths:` matches nothing,
# which would silently disable the trigger.
pytest.param(
{
"branches": ["main"],
"paths": [UPSTREAM_SOURCE_GLOB, "repomatic/data/labels.toml"],
},
"tests.yaml",
PathsSpec(),
{"branches": ["main"]},
id="all-upstream-drops-the-key",
),
pytest.param(
{"branches": ["main"]},
"tests.yaml",
PathsSpec(source_paths=("extra_platforms",)),
{"branches": ["main"]},
id="no-paths-key-passes-through",
),
pytest.param(
{"paths": [UPSTREAM_SOURCE_GLOB, "pyproject.toml"]},
"tests.yaml",
PathsSpec(
source_paths=("my_pkg",),
# The already-present entry is deduped rather than repeated.
extra_paths=["install.sh", "pyproject.toml"],
),
{"paths": ["my_pkg/**", "pyproject.toml", "install.sh"]},
id="extra-paths-appended",
),
pytest.param(
{"paths": [UPSTREAM_SOURCE_GLOB, "pyproject.toml", "uv.lock"]},
"tests.yaml",
PathsSpec(source_paths=("my_pkg",), ignore_paths=["uv.lock"]),
{"paths": ["my_pkg/**", "pyproject.toml"]},
id="ignore-paths-stripped",
),
pytest.param(
{"paths": [UPSTREAM_SOURCE_GLOB, "pyproject.toml", "uv.lock"]},
"tests.yaml",
PathsSpec(
source_paths=("my_pkg",),
extra_paths=["never-applied.sh"],
ignore_paths=["pyproject.toml"],
workflow_paths={"tests.yaml": ["install.sh", "packages.toml"]},
),
{"paths": ["install.sh", "packages.toml"]},
id="override-wins-over-every-other-knob",
),
pytest.param(
{"paths": [UPSTREAM_SOURCE_GLOB, "pyproject.toml"]},
"lint.yaml",
PathsSpec(
source_paths=("my_pkg",),
workflow_paths={"tests.yaml": ["install.sh"]},
),
{"paths": ["my_pkg/**", "pyproject.toml"]},
id="override-scoped-to-its-own-file",
),
],
)
def test_adapt_trigger_paths(
config: dict, filename: str, spec: PathsSpec, expected: dict
) -> None:
"""Every `PathsSpec` knob, applied to one trigger's config block."""
assert _adapt_trigger_paths(config, filename, spec) == expected
# ---------------------------------------------------------------------------
# Thin caller with source paths
# ---------------------------------------------------------------------------
[docs]
def test_thin_caller_release_with_source_paths() -> None:
"""Verify release.yaml thin caller has no path filters.
release.yaml only has ``push`` (no ``paths:``) and ``workflow_call``,
so ``source_paths`` has no effect.
"""
content = generate_thin_caller(
"release.yaml", paths_spec=PathsSpec(source_paths=("extra_platforms",))
)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
push_config = triggers.get("push", {})
# push trigger has branches but no paths.
assert "paths" not in push_config
assert "pull_request" not in triggers
[docs]
def test_thin_caller_changelog_with_source_paths() -> None:
"""Verify changelog.yaml thin caller keeps universal paths unchanged.
changelog.yaml has no upstream source glob, so source_paths has no effect
beyond keeping all paths intact.
"""
content = generate_thin_caller(
"changelog.yaml", paths_spec=PathsSpec(source_paths=("extra_platforms",))
)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
push_config = triggers.get("push", {})
assert "paths" in push_config
assert "changelog.md" in push_config["paths"]
[docs]
def test_thin_caller_lint_no_paths_with_source_paths() -> None:
"""Verify workflows without paths don't gain paths from source_paths."""
content = generate_thin_caller(
"lint.yaml", paths_spec=PathsSpec(source_paths=("extra_platforms",))
)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
push_config = triggers.get("push", {})
# lint.yaml has no paths filter in canonical, so none in thin caller.
assert "paths" not in push_config
# ---------------------------------------------------------------------------
# Header generation with source paths
# ---------------------------------------------------------------------------
[docs]
def test_tests_yaml_runs_on_bundled_assets_upstream_only() -> None:
"""Upstream Tests run on a skill or agent edit, downstream Tests do not.
The bundled skills and agents reach the package through symlinks, so an
edit to one changes no `repomatic/**` path, while the suite reads them all.
"""
canonical = yaml.safe_load(
(PROJECT_ROOT / ".github" / "workflows" / "tests.yaml").read_text(
encoding="UTF-8"
)
)
# PyYAML reads the quoted `"on":` key as a string, a bare `on:` as `True`.
triggers = canonical.get("on") or canonical[True]
for trigger in ("push", "pull_request"):
assert UPSTREAM_ASSET_GLOB in triggers[trigger]["paths"]
header = generate_workflow_header(
"tests.yaml", paths_spec=PathsSpec(source_paths=("my_pkg",))
)
assert UPSTREAM_ASSET_GLOB not in header
assert "my_pkg/**" in header
[docs]
@pytest.mark.parametrize(
("value", "expected"),
(
# Indicators that open something other than a scalar.
("**/*.py", True),
("*.py", True),
("&anchor", True),
("!tag", True),
("{flow}", True),
("[seq]", True),
# Plain but coerced away from str, which an indicator table would miss.
("yes", True),
("null", True),
("1.0", True),
("", True),
# Ordinary path filters stay unquoted, so canonical blocks do not churn.
("pyproject.toml", False),
("uv.lock", False),
("content/**", False),
("docs/**", False),
(".github/workflows/tests.yaml", False),
),
)
def test_needs_yaml_quote(value: str, expected: bool) -> None:
"""Only entries that would not round-trip as plain scalars are flagged."""
assert _needs_yaml_quote(value) is expected
# ---------------------------------------------------------------------------
# derive_source_paths tests
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("pyproject_data", "expected"),
(
pytest.param(
{"project": {"name": "orange-grove"}}, ["orange_grove"], id="hyphens"
),
pytest.param(
{"project": {"name": "blood_orange"}}, ["blood_orange"], id="underscores"
),
pytest.param({"project": {"name": "papaya"}}, ["papaya"], id="simple-name"),
pytest.param({"project": {}}, [], id="no-name"),
pytest.param({}, [], id="empty-pyproject"),
),
)
def test_derive_source_paths(pyproject_data: dict[str, Any], expected) -> None:
"""The source path is the project name as an importable package."""
assert derive_source_paths(pyproject_data) == expected
# ---------------------------------------------------------------------------
# resolve_source_paths tests
# ---------------------------------------------------------------------------
[docs]
def test_resolve_source_paths_explicit_config() -> None:
"""Use explicitly configured source paths."""
config = Config(workflow=WorkflowConfig(source_paths=["custom_src"]))
result = resolve_source_paths(config)
assert result == ["custom_src"]
[docs]
def test_resolve_source_paths_none_derives() -> None:
"""Auto-derive when config is None."""
config = Config(workflow=WorkflowConfig(source_paths=None))
pyproject_data = {"project": {"name": "papaya-press"}}
result = resolve_source_paths(config, pyproject_data)
assert result == ["papaya_press"]
[docs]
def test_resolve_source_paths_empty_list_returns_none() -> None:
"""Return None when explicitly set to empty list."""
config = Config(workflow=WorkflowConfig(source_paths=[]))
result = resolve_source_paths(config)
assert result is None
[docs]
def test_resolve_source_paths_no_name_returns_none() -> None:
"""Return None when no project name and no config."""
config = Config(workflow=WorkflowConfig(source_paths=None))
pyproject_data: dict[str, Any] = {"project": {}}
result = resolve_source_paths(config, pyproject_data)
assert result is None
# ---------------------------------------------------------------------------
# _split_yaml_quote
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("scalar", "expected"),
[
("plain", ("plain", "")),
('"quoted"', ("quoted", '"')),
("'single'", ("single", "'")),
("", ("", "")),
('"', ('"', "")),
("'", ("'", "")),
('""', ("", '"')),
('"mixed\'"', ("mixed'", '"')),
("path/with/slashes", ("path/with/slashes", "")),
],
)
def test_split_yaml_quote(scalar: str, expected: tuple[str, str]) -> None:
"""Strip outer matching quotes; pass through plain scalars."""
assert _split_yaml_quote(scalar) == expected
# ---------------------------------------------------------------------------
# Thin caller with full paths_spec
# ---------------------------------------------------------------------------
[docs]
def test_thin_caller_paths_spec_ignore_strips_canonical() -> None:
"""Thin caller strips `ignore_paths` entries from every paths block."""
spec = PathsSpec(ignore_paths=["uv.lock"])
content = generate_thin_caller("changelog.yaml", paths_spec=spec)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
assert "uv.lock" not in triggers["push"]["paths"]
# Other canonical entries survive.
assert "changelog.md" in triggers["push"]["paths"]
[docs]
def test_thin_caller_paths_spec_per_workflow_override_replaces_wholesale() -> None:
"""Per-workflow override replaces the thin caller's paths list verbatim."""
spec = PathsSpec(
extra_paths=["never-applied.sh"],
workflow_paths={"changelog.yaml": ["only.sh", "just-this.toml"]},
)
content = generate_thin_caller("changelog.yaml", paths_spec=spec)
data = yaml.safe_load(content)
triggers = workflow_triggers(data)
assert triggers["push"]["paths"] == ["only.sh", "just-this.toml"]
# ---------------------------------------------------------------------------
# Header generation: extra knob behavior
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("filename", "expected"),
(
# Every autofix job that writes opens a pull request; setup-guide adds
# the issues scope, fix-vulnerable-deps reads the Dependabot alerts, and
# sync-deps deletes the superseded HTTP caches.
(
"autofix.yaml",
{
"actions": "write",
"contents": "write",
"issues": "write",
"pull-requests": "write",
"security-events": "read",
},
),
# The labellers only read the tree, so contents stays at read while the
# scopes they do write are unioned in.
(
"labels.yaml",
{"contents": "read", "issues": "write", "pull-requests": "write"},
),
# Every lint job inherits the canonical top-level `permissions: {}`, so
# a caller has nothing to forward.
("lint.yaml", {}),
),
)
def test_canonical_caller_permissions(filename: str, expected: dict[str, str]) -> None:
"""Union the job-level scopes a caller has to forward."""
assert canonical_caller_permissions(filename) == expected
[docs]
@pytest.mark.parametrize(
("levels", "expected"),
(
(("read", "write"), "write"),
(("write", "read"), "write"),
(("none", "read"), "read"),
(("read", "none"), "read"),
(("read", "read"), "read"),
(("write", "write"), "write"),
),
)
def test_canonical_caller_permissions_keeps_most_permissive(
monkeypatch: pytest.MonkeyPatch, levels: tuple[str, str], expected: str
) -> None:
"""Resolve a scope granted at odds across jobs to its strongest level.
Order of appearance must not matter: a narrower grant later in the file
cannot walk back a wider one already collected.
"""
first, second = levels
stub = (
"---\njobs:\n"
f" a:\n permissions:\n contents: {first}\n"
f" b:\n permissions:\n contents: {second}\n"
)
monkeypatch.setattr(
"repomatic.github.workflow_sync.get_data_content", lambda _name: stub
)
assert canonical_caller_permissions("stub.yaml") == {"contents": expected}
[docs]
def test_generate_thin_caller_omits_permissions_by_default() -> None:
"""Leave a caller with no extra jobs exactly as before.
Its single managed job inherits the repository default, which the reusable
workflow's own `permissions:` blocks then cap. Pinning `{}` here would buy
nothing and starve that call.
"""
content = generate_thin_caller("autofix.yaml", DEFAULT_REPO, "v1.2.3")
assert "permissions" not in content
assert yaml.safe_load(content).get("permissions") is None
[docs]
def test_generate_thin_caller_emits_both_permission_halves() -> None:
"""Pin least privilege at the top and forward the scopes the call needs."""
content = generate_thin_caller(
"autofix.yaml", DEFAULT_REPO, "v1.2.3", with_permissions=True
)
data = yaml.safe_load(content)
assert data["permissions"] == {}
assert data["jobs"]["autofix"]["permissions"] == {
"actions": "write",
"contents": "write",
"issues": "write",
"pull-requests": "write",
"security-events": "read",
}
# The permissions block must not displace the call's own arguments.
assert data["jobs"]["autofix"]["uses"].startswith(DEFAULT_REPO)
assert "REPOMATIC_PAT" in data["jobs"]["autofix"]["secrets"]
[docs]
def test_generate_thin_caller_spells_out_an_empty_forward() -> None:
"""Emit `permissions: {}` on the job when no upstream job asks for a scope."""
content = generate_thin_caller(
"lint.yaml", DEFAULT_REPO, "v1.2.3", with_permissions=True
)
data = yaml.safe_load(content)
assert data["permissions"] == {}
assert data["jobs"]["lint"]["permissions"] == {}
_PACKER_JOB = (
"\n packer:\n runs-on: ubuntu-slim\n steps:\n - run: echo pack\n"
)
"""A downstream asset-building job, appended below the managed lanes."""
def _downstream_release(needs: list[str]) -> str:
"""A downstream `release.yaml` carrying {data}`_PACKER_JOB` and *needs*.
Rewrites the `release` lane's `needs:` only: `publish-pypi` declares the
same `needs: build` earlier in the file, so a blind replace would hit it.
"""
content = generate_thin_caller("release.yaml", DEFAULT_REPO, "v1.2.3")
head, marker, tail = content.partition(" release:\n")
block = " needs:\n" + "".join(f" - {name}\n" for name in needs)
return head + marker + tail.replace(" needs: build\n", block, 1) + _PACKER_JOB
[docs]
def test_release_caller_always_denies_permissions(tmp_path: Path) -> None:
"""The canonical entry's deny-by-default reaches every downstream copy.
Unlike a plain thin caller, whose contract only appears once extra jobs do,
`release.yaml` is copied from an entry that always carries the key.
"""
target = tmp_path / "release.yaml"
sync_caller("release.yaml", target)
assert yaml.safe_load(target.read_text(encoding="UTF-8"))["permissions"] == {}
# A downstream job appended below the managed lanes must not loosen it.
target.write_text(
target.read_text(encoding="UTF-8") + _PACKER_JOB, encoding="UTF-8"
)
sync_caller("release.yaml", target)
data = yaml.safe_load(target.read_text(encoding="UTF-8"))
assert data["permissions"] == {}
assert "packer" in data["jobs"]
[docs]
@pytest.mark.parametrize(
("downstream_needs", "expected"),
(
pytest.param(["build", "packer"], ["build", "packer"], id="extra-edge-kept"),
pytest.param(["build"], "build", id="canonical-only-stays-scalar"),
pytest.param(["build", "ghost"], "build", id="stale-edge-dropped"),
pytest.param(["build", "publish-pypi"], "build", id="managed-lane-not-echoed"),
),
)
def test_release_caller_preserves_downstream_needs(
tmp_path: Path, downstream_needs: list[str], expected: str | list[str]
) -> None:
"""Carry the consumer's own `needs:` edges across a sync.
A repo building a `release-assets` file gates the engine on the job packing
it. That edge sits on a lane this renderer regenerates, so without carrying
it over every `repomatic init` would silently drop the gate. Edges naming a
managed lane or a vanished job are dropped instead of echoed back.
"""
target = tmp_path / "release.yaml"
target.write_text(_downstream_release(downstream_needs), encoding="UTF-8")
sync_caller("release.yaml", target)
data = yaml.safe_load(target.read_text(encoding="UTF-8"))
assert data["jobs"]["release"]["needs"] == expected
# The packing job itself always survives, whatever the edge resolved to.
assert "packer" in data["jobs"]
[docs]
@pytest.mark.parametrize(
("existing", "expected"),
(
pytest.param({"jobs": ["release"]}, (), id="jobs-a-list"),
pytest.param({"jobs": {"release": {"runs-on": "ubuntu-slim"}}}, (), id="none"),
pytest.param(
{"jobs": {"release": {"needs": {"papaya": True}}, "papaya": {}}},
(),
id="needs-a-mapping",
),
pytest.param(
{"jobs": {"release": {"needs": "papaya"}, "papaya": {}}},
("papaya",),
id="needs-a-string",
),
),
)
def test_downstream_release_needs_reads_a_string_or_a_list(
existing: dict[str, Any], expected: tuple[str, ...]
) -> None:
"""A lone `needs:` string counts as a one-item list, any other shape as none."""
assert ws._downstream_release_needs(existing, DEFAULT_REPO) == expected
[docs]
def test_release_caller_drops_repomatic_local_needs() -> None:
"""A canonical `needs:` edge on a repomatic-only job never reaches downstream.
The canonical entry gates its engine lane on `pack-plugin`, a job that exists
only upstream: the renderer copies the three managed lanes and leaves it
behind. Echoing the edge would emit a `needs:` on a job the generated file
does not define, which GitHub rejects at startup, breaking the release
workflow of every consumer at once.
"""
generated = generate_thin_caller("release.yaml", DEFAULT_REPO, "v1.2.3")
data = yaml.safe_load(generated)
canonical = yaml.safe_load(get_data_content("release.yaml"))
local_jobs = set(canonical["jobs"]) - set(data["jobs"])
assert local_jobs, (
"The canonical release.yaml no longer holds a repomatic-only job, so this "
"test has stopped covering anything. Drop it, or gate it on a new one."
)
needs = data["jobs"]["release"]["needs"]
needs = [needs] if isinstance(needs, str) else needs
assert not local_jobs.intersection(needs), (
f"Generated release.yaml gates `release` on "
f"{sorted(local_jobs.intersection(needs))}, "
"which it does not define."
)
# Every surviving edge names a job the generated file actually has.
assert set(needs) <= set(data["jobs"])
def _thin_caller_call_sites() -> set[tuple[str, str]]:
"""Every production call of `generate_thin_caller`, as (module, function).
Walks the AST rather than grepping, so a call spread over several lines or
reached through an alias is still seen.
"""
package = Path(__file__).parent.parent / "repomatic"
sites: set[tuple[str, str]] = set()
for module in sorted(package.rglob("*.py")):
tree = ast.parse(module.read_text(encoding="UTF-8"))
for parent in ast.walk(tree):
if not isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for node in ast.walk(parent):
if not isinstance(node, ast.Call):
continue
func = node.func
name = getattr(func, "attr", None) or getattr(func, "id", None)
if name == "generate_thin_caller":
sites.add((module.name, parent.name))
return sites
[docs]
def test_thin_caller_rendering_has_a_single_seam() -> None:
"""Only `render_thin_caller_for_target` may render a caller for a file.
That helper is where the downstream state a sync must carry over (extra
jobs, the consumer's own `needs:` edges) is read and passed on. A second
call site is a second place to forget one, which is exactly how the
`needs:` edge came to be dropped on the `repomatic init` path while the
suite driving the other copy stayed green.
Reach the renderer through the helper. If a caller genuinely needs raw
output with no file behind it, it is not a sync and should say so here.
"""
assert _thin_caller_call_sites() == {
("workflow_sync.py", "render_thin_caller_for_target")
}
# --- Configurable caller input defaults ---
UNSUBSCRIBE_WORKFLOW_PATH = PROJECT_ROOT / ".github" / "workflows" / "unsubscribe.yaml"
[docs]
@pytest.mark.parametrize("filename", ("release.yaml", "unsubscribe.yaml"))
def test_default_config_leaves_the_caller_untouched(filename: str) -> None:
"""Configuring nothing churns no caller: the two renders are identical."""
assert generate_thin_caller(filename, config=Config()) == (
generate_thin_caller(filename)
)
# --- Cooldown window of the generated files ---
TWO_WEEK_COOLDOWN = Config(minimum_release_age="2 weeks")
"""A repository whose cooldown window is not the default one."""