tests.test_workflows module

Tests for GitHub Actions workflow files consistency.

tests.test_workflows.METADATA_INDEX_RE = re.compile('fromJSON\\(needs\\.[\\w-]+\\.outputs\\.metadata\\)\\.(?P<key>\\w+)\\[')

A workflow expression that indexes into one key of the metadata output.

tests.test_workflows.test_workflow_has_concurrency(workflow_name)[source]

Verify that required workflows have a concurrency block.

Return type:

None

tests.test_workflows.test_concurrency_group_format(workflow_name)[source]

Verify that concurrency group uses PR number or branch ref.

Return type:

None

tests.test_workflows.test_cancel_in_progress_protects_releases(workflow_name)[source]

Verify that cancel-in-progress protects release commits.

Return type:

None

tests.test_workflows.test_event_scoped_group_isolates_events(workflow_name)[source]

Verify that event-scoped workflows include event_name in the group.

Return type:

None

tests.test_workflows.test_unique_group_protects_releases(workflow_name)[source]

Verify workflows using unique groups protect releases via github.sha.

Return type:

None

tests.test_workflows.test_exempt_workflows_no_concurrency(workflow_name)[source]

Verify that exempt workflows really carry no concurrency block.

The exemption list must track reality: a workflow that gains a conformant block belongs in the derived WORKFLOWS_WITH_CONCURRENCY set, where the group-format tests actually inspect it.

Return type:

None

tests.test_workflows.TOOL_CACHE_KEY_SUFFIX = "${{ github.job_workflow_sha || hashFiles('repomatic/tooling/tool_registry.py') }}"

The only valid ending for a tool-cache key derived from the registry file.

tests.test_workflows.test_tool_cache_keys_rotate_downstream()[source]

Every registry-derived cache key must work from a caller’s workspace.

The cache steps live in reusable workflows called cross-repo, where the checkout holds the caller’s tree: hashFiles('repomatic/…') matches nothing there and returns an empty string, collapsing the key to a constant. actions/cache skips the save on an exact primary-key hit, so a constant key freezes every downstream tool cache at its first write. github.job_workflow_sha is the reusable workflow file’s own commit SHA, which rotates exactly when the uses: pin (and with it the pinned tool versions) moves, and is empty on direct upstream runs, where the file hash takes over.

Return type:

None

tests.test_workflows.test_all_workflows_discovered()[source]

Verify that workflow discovery is working correctly.

Return type:

None

tests.test_workflows.test_exemption_list_matches_what_is_on_disk()[source]

Every exempt workflow really lacks a concurrency block, and vice versa.

The exemption set states an intention; WORKFLOWS_WITHOUT_CONCURRENCY_BLOCK reads the files. Without this tie, a workflow could sit in the exempt list while carrying a block, which is how debug.yaml went unchecked: the parametrized tests skip it, and the test that should have noticed asserted only that its YAML parsed.

Return type:

None

tests.test_workflows.test_every_job_caps_its_runtime()[source]

Every job that occupies a runner must declare timeout-minutes.

Without one, a hung step holds its runner for GITHUB_DEFAULT_JOB_TIMEOUT minutes before the platform reclaims it. The macOS and Windows pools are capped per account and shared across every repository in it, so one stuck cell there starves all the others for the rest of those six hours: the cost of the omission lands on projects that have nothing to do with the workflow that hung.

Callers of a reusable workflow are the one exemption, because GitHub rejects the key on them (see REUSABLE_CALLER_KEYS). Their runtime is still bounded, by the caps on the jobs of the workflow they call, which this same sweep covers.

Return type:

None

tests.test_workflows.test_job_timeouts_stay_within_bounds()[source]

No cap may be absent-by-another-name: zero, negative, or effectively the platform default.

A cap above MAX_JOB_TIMEOUT buys back almost none of the runner time the requirement exists to reclaim, so it needs the ceiling raised deliberately rather than one job quietly opting out.

Return type:

None

tests.test_workflows.test_reusable_callers_carry_no_forbidden_keys()[source]

A uses: job may only declare REUSABLE_CALLER_KEYS.

GitHub fails the whole workflow at startup on any other key, so this catches a caller that grew a timeout-minutes (or a runs-on, or steps) before actionlint runs in CI.

Return type:

None

tests.test_workflows.SITE_DEPLOY_COMPARISON_RE = re.compile("site_deploy\\s*==\\s*'([^']*)'")

A deploy-target literal a workflow gate compares site_deploy against.

tests.test_workflows.test_deploy_gates_name_a_declared_target()[source]

Each site.deploy target has exactly the jobs it needs, and no more.

SITE_DEPLOY_TARGETS promises one deploy job per target, so the two halves must agree in both directions and neither is checkable from the other side. A gate naming a target the frozenset does not carry can never fire, and a workflow whose only deploy job is gated that way publishes nothing while running green: Config.__post_init__ rejects the unknown value, but nothing reads the gate. A target declared with no gate behind it is the same failure approached from the other end, and is what a repository would hit the day it opted into it.

Return type:

None

tests.test_workflows.test_version_bump_branches_ignored(workflow_name)[source]

Heavy PR-triggered workflows must list every VERSION_BUMP_BRANCHES member under pull_request.branches-ignore.

Return type:

None

tests.test_workflows.test_metadata_gate_skips_version_bump_branches(workflow_name)[source]

The metadata job’s if: expression must encode the same set of branch names as VERSION_BUMP_BRANCHES via a contains(fromJSON(...)) check on github.head_ref.

All PR-triggered workflows that ignore version-bump PRs need this gate because pull_request.branches-ignore only filters the PR’s base branch (always main), so the head-ref check is the only mechanism that actually skips these PRs.

Return type:

None

tests.test_workflows.test_tests_metadata_gate_skips_manual_bumps()[source]

tests.yaml’s metadata job must short-circuit on every MANUAL_VERSION_BUMP_COMMIT_PREFIXES member, but must not gate on the [changelog] Post-release bump prefix: that push carries the release commit, and the post-release-bump commit is the only post-merge test point for the release-frozen tree.

Return type:

None

tests.test_workflows.test_metadata_gate_skips_version_bump_commits(workflow_name)[source]

The metadata job’s if: expression must short-circuit on the single CHANGELOG_COMMIT_PREFIX clause, so post-merge push events with any machine-authored version-machinery commit at their head skip the entire job graph. The prefix stands in for every VERSION_BUMP_COMMIT_PREFIXES member, which test_changelog_prefix_is_the_machinery_invariant holds.

Return type:

None

tests.test_workflows.test_autofix_independent_jobs_repeat_version_bump_gate(job_name)[source]

Metadata-independent autofix jobs carry their own version-bump gate.

Return type:

None

tests.test_workflows.test_changelog_prefix_is_the_machinery_invariant()[source]

Every machine-authored version-machinery commit shape carries the prefix.

The single-clause workflow gates above are only sound while every version-bump commit message starts with CHANGELOG_COMMIT_PREFIX; this is the invariant that lets them stop enumerating message shapes.

Return type:

None

tests.test_workflows.test_version_bump_branches_match_changelog_workflow()[source]

VERSION_BUMP_BRANCHES must equal the set of branches actually created by changelog.yaml’s peter-evans/create-pull-request steps.

prepare-release is hard-coded; the bump-version job templates ${{ matrix.part }}-version-increment over matrix.part = [major, minor], so the full set is {prepare-release, major-version-increment, minor-version-increment}.

Return type:

None

tests.test_workflows.test_bump_matrix_parts_are_the_ones_the_cli_accepts()[source]

changelog.yaml’s bump matrix may only name a part the CLI takes.

The matrix value reaches pr-sync --part and close-stale-bump-pr --part, both typed on BUMP_PARTS. Adding a third part to the YAML alone would leave the job failing on a rejected value, in a workflow that only runs on a schedule.

Return type:

None

tests.test_workflows.test_release_commit_prefix_in_changelog_workflow()[source]

Verify that changelog.yaml uses the same release commit prefix.

Return type:

None

tests.test_workflows.test_version_increments_runs_on_expected_events()[source]

Verify that bump-version job runs on all expected event types.

The bump-version job depends on the metadata job, which gates execution. It runs on push events (to recreate PRs before they conflict), schedule, workflow_dispatch, and successful workflow_run events.

Return type:

None

tests.test_workflows.test_prepare_release_runs_on_workflow_run()[source]

The prepare-release job must run on workflow_run events (the PR-refresh backstop).

workflow_run fires after every “🚀 Build & release” — so after every push to main — and re-bases the release PR onto the current HEAD. Without it, a push that misses changelog.yaml’s paths: filter (a commit touching only skills, a composite action, or a markdown file the filter does not list) leaves the PR stale on its prior base. The job may exclude schedule (that trigger serves bump-version only) but must not exclude workflow_run.

Return type:

None

tests.test_workflows.test_workflow_run_references_match_workflow_names()[source]

Every workflow_run trigger must watch an existing workflow name.

GitHub matches the workflows: filter against the exact name: of the watched workflow, and silently drops the trigger when nothing matches. A rename is enough to kill it: adding emojis to workflow names in 2026-02 left changelog.yaml watching the old Build & release name, so its post-release re-trigger never fired again.

Return type:

None

tests.test_workflows.test_bump_allowance_jobs_fetch_tags(job_name)[source]

Tag-sensitive changelog jobs must check out with fetch-tags: true.

is_version_bump_allowed resolves the latest release from Git tags. A job that evaluates it without fetching tags falls back to “allow”, which neutralizes the close-stale-bump-pr cleanup and leaves orphan version-bump PRs open on main.

Return type:

None

tests.test_workflows.test_post_release_commit_in_changelog_workflow()[source]

Verify that changelog.yaml uses the correct post-release commit message.

Return type:

None

tests.test_workflows.test_version_bump_commit_in_changelog_workflow()[source]

Verify that changelog.yaml uses the correct version bump commit message.

Return type:

None

tests.test_workflows.PR_SYNC_ENV_KEYS = frozenset({'GH_TOKEN'})

Values every pr-sync step passes through env: rather than its run: line.

The token is here because gh reads it from the environment and secrets are not ambient. Everything else the step used to relay (title, body, commit message, assignee) is now resolved inside pr-sync itself: rendered from the template, or read from ambient variables like GITHUB_ACTOR. A step may declare more (bump-version adds PR_BRANCH/PR_PART to keep matrix.part out of its command line, the dependency steps a diff-table file), never fewer.

tests.test_workflows.pr_sync_branch(step)[source]

Return the head branch a pr-sync step targets.

Mirrors the CLI’s own resolution: an explicit --branch wins (resolving the shell indirection bump-version uses to keep matrix.part out of its command line), and otherwise the branch defaults to the template name.

Return type:

str

tests.test_workflows.test_pull_requests_are_opened_by_the_cli_not_an_action()[source]

No workflow may open a pull request with a third-party action.

The drift guard for the migration off peter-evans/create-pull-request: a new job copy-pasting the action from an old example fails here by name.

Return type:

None

tests.test_workflows.test_pr_sync_steps_pass_everything_through_env()[source]

Every pr-sync step names a branch and takes its values from env:.

Return type:

None

tests.test_workflows.test_pr_sync_templates_declare_their_labels()[source]

Every template a pr-sync step renders carries frontmatter labels.

Labels moved from workflow YAML into template frontmatter; a template without them opens an unlabelled pull request, silently, so the gap is caught here by template name instead.

Return type:

None

tests.test_workflows.test_sync_repomatic_routes_upgrades_to_one_branch()[source]

sync-repomatic runs init --upgrade, and both outcomes share a branch.

The upgrade_version output of the init step picks between the two pr-sync steps, so exactly one runs, and both converge the same branch: a plain sync and an upgrade never stand as two pull requests.

Return type:

None

tests.test_workflows.test_pr_sync_steps_are_template_driven()[source]

Every pr-sync step names a template rather than relaying rendered text.

The template is what lets the command resolve title, body, commit message, branch, labels and draft state on its own; a step passing --title/--body explicitly would resurrect the $GITHUB_OUTPUT relay and its step-output size ceiling.

Return type:

None

tests.test_workflows.test_version_bumps_relock_before_committing()[source]

Every version bump must re-lock before the commit that captures it.

bump-my-version rewrites pyproject.toml but not uv.lock, so a bump committed without a following uv lock leaves the tree with the project version ahead of its own lock entry. uv run --frozen then reinstalls the project on the next sync to close that gap, and the release build runs two nested syncs: the workflow’s, then the one repomatic run opens for a needs_venv tool. The second deletes the console script the first had just written, which Windows refuses as a sharing violation and which took the Windows binaries out of a release.

Return type:

None

tests.test_workflows.test_broken_links_skips_post_release_commits()[source]

Verify that broken-links job skips post-release version bump commits.

Return type:

None

tests.test_workflows.test_docs_trigger_covers_included_root_files()[source]

The docs paths: filter must fire on each file the docs pull in.

A page that includes a file from outside docs/ publishes content the docs/** entry does not watch. With no entry of its own, a push touching just that file skips the workflow, and the page stays stale until something else touches a listed path. Downstream thin callers inherit this list verbatim, and their docs include the same root files.

Return type:

None

tests.test_workflows.test_docs_trigger_covers_translated_readmes()[source]

The docs paths: filter must fire on a translated readme.

check-broken-links crawls every file metadata reports under doc_files, which globs all Markdown, so a readme.{lang}.md is already inside the crawl. The trigger is what decides whether the crawl runs at all: with readme.md as the only readme entry, a push touching just a translation skipped the workflow, and those links went unchecked until something else happened to touch a listed path. Downstream thin callers inherit this list verbatim, so the gap reached every awesome list keeping a translation beside its English readme.

Return type:

None

tests.test_workflows.test_cloudflare_deploy_fails_after_publishing_a_dropped_file()[source]

A dropped file is a dead link, so the job must end red, after the deploy.

The offload exits 1 when it drops a file, and continue-on-error lets the deploy publish everything else first. Only the last step, keyed on the offload’s outcome, turns the run red: without it, a dead link would sit behind a green run with a warning, which is how one stayed for years.

Return type:

None

tests.test_workflows.iter_workflow_actions(workflow)[source]

Yield all action references (uses: statements) from a workflow.

tests.test_workflows.iter_all_actions()[source]

Yield all action references from all workflow files.

Sorted so parametrize IDs are stable across processes (xdist collects in every worker and aborts on a mismatch).

tests.test_workflows.test_action_uses_full_semantic_version(workflow_name, job_name, step_name, action)[source]

Verify that all actions use full semantic versions (vX.Y.Z), not major-only.

Return type:

None

tests.test_workflows.iter_jobs_with_runners()[source]

Yield all jobs with their runner configurations.

Sorted so parametrize IDs are stable across processes (xdist collects in every worker and aborts on a mismatch).

tests.test_workflows.test_every_job_runs_on_a_test_axis(workflow_name, job_name, runs_on)[source]

Every literal runs-on: names an image the test matrix also covers.

There is no second set to be an exception to any more: ubuntu-slim was the last image outside the axes, and it lost the A/B that justified it. Deriving the expectation from KNOWN_RUNNERS rather than from a literal keeps this from failing on a fleet migration instead of on the drift it exists to catch.

Return type:

None

tests.test_workflows.test_binary_build_targets_are_the_test_axes()[source]

The Nuitka build fleet is exactly the set of images the suite runs on.

The companion to test_every_job_runs_on_a_test_axis(), which can only see a literal runs-on:. The compile-binaries job takes its runner from a matrix expression fed by NUITKA_BUILD_TARGETS, so that test skips it and the build fleet would otherwise be the one place an untracked image could sit unnoticed.

The equality is the invariant, in both directions: a published binary is built on an image the suite is validated against, and an image the suite covers is one binaries can be built on. Adding a target therefore means widening the test axes in repomatic.matrix_axes, never editing this fleet alone. binary.py’s own docstring states the rule; this holds it.

Return type:

None

tests.test_workflows.test_prerelease_python_label_reaches_the_job_name()[source]

The test job titles itself from python-label, falling back to the version.

repomatic show-metadata attaches python-label to the cells whose Python is unreleased and to no others (see PRERELEASE_LABEL_SUFFIX), so the fallback is not decoration: on every released cell the key resolves to an empty string, and the job would be titled py with nothing after it. The metadata half of the contract is held by tests/test_metadata.py::test_unstable_python_versions_carry_a_prerelease_label.

Return type:

None

tests.test_workflows.test_resolution_cell_is_read_by_the_test_job()[source]

A matrix cell carrying a resolution key resolves the project with it.

[tool.repomatic] test-matrix.full-include adds such a cell to run every declared dependency floor. With no step reading the key, the cell installs the lockfile like its twin and verifies nothing. With no trace of the key in the job name, the two cells cannot be told apart.

Return type:

None

tests.test_workflows.test_lowest_versions_cell_runs_on_the_floor_python()[source]

This repository runs one stable lowest-direct cell, on its lowest Python.

The oldest dependencies meet the oldest interpreter there, which is the environment a declared floor promises to support. When the Python floor moves, this cell has to move with it.

Return type:

None

tests.test_workflows.test_workflows_index_structured_metadata_only()[source]

A workflow indexes a metadata key only when its value is a JSON structure.

show-metadata --format github-json joins a list of strings with spaces (see format_github_value()). An expression that indexes such a string gives an empty value and raises nothing, so a step gated on cli_scripts[0] is skipped on every run without a word.

Return type:

None

tests.test_workflows.iter_jobs_with_steps()[source]

Yield (workflow_name, job_name, steps) for every job that has steps.

Reusable-workflow calls (uses: jobs) carry no steps, so they are skipped: they inherit no toolchain and provision none.

tests.test_workflows.test_uv_invoking_jobs_discovered()[source]

The uv-invocation matcher must find jobs (guards against a dead regex).

Return type:

None

tests.test_workflows.test_uv_invoking_job_provisions_setup_uv(workflow_name, job_name)[source]

Every job that runs uv/uvx must provision uv via astral-sh/setup-uv.

GitHub Actions jobs share no state: each runs on a fresh runner and inherits nothing from the jobs before it, so a job that shells out to uv must install uv itself. This cycle’s cancel-runs.yaml rewrite from a bash body to a repomatic CLI call dropped the job’s setup-uv step, and every PR-close run failed with uvx: command not found. This test locks the invariant across all workflows so a uv-invoking job can never ship without its own uv provisioning again.

Return type:

None

tests.test_workflows.iter_all_conditions()[source]

Yield (file_path, line, condition) for every if: in the Actions tree.

Covers the composite actions alongside the workflows: both ship downstream and GitHub Actions evaluates their if: by the same rule.

Sorted so parametrize IDs are stable across processes (xdist collects in every worker and aborts on a mismatch).

Return type:

Iterator[tuple[str, int, str]]

tests.test_workflows.test_conditions_discovered()[source]

The condition walker must find conditions (guards against a dead walk).

Return type:

None

tests.test_workflows.test_condition_wrapper_spans_the_whole_value(file_path, line, condition)[source]

A condition using ${{ }} holds nothing outside its braces.

GitHub Actions evaluates an if: value as an expression only when the value is entirely one ${{ … }}. Any character outside the braces switches it to string interpolation, and the resulting non-empty string is truthy, so the step silently stops gating and runs unconditionally.

A folded block scalar is the trap, because it appends a trailing newline: a wrapped expression under if: > becomes that expression plus a newline, and the character outside the braces is invisible in the diff. Eleven steps of the sync-deps job in autofix.yaml were gated this way, all of them always-on until this test was written.

Write a multi-line condition bare instead, with no wrapper at all: a bare if: value is evaluated as an expression whatever whitespace trails it. A leading ! (as in !cancelled()) rules out the bare single-line form, since YAML reads it as a tag indicator, so those stay either folded-and-bare or single-line-and-wrapped.

Return type:

None

Verify that every exportable workflow has a symlink in repomatic/data/.

Return type:

None

Verify only workflow, agent, and composite-action files are symlinks in repomatic/data/.

Scoped to the top level of repomatic/data/. Skills live one directory down, as a folder per skill, and are covered by test_skill_symlinks_resolve_correctly().

Return type:

None

Verify that workflow symlinks in repomatic/data/ point to the correct targets.

Return type:

None

Verify that composite-action symlinks in repomatic/data/ resolve correctly.

Uses the registry as the source of truth: every FileEntry whose target sits under .github/actions/ must be backed by a symlink at the declared source path that resolves to the declared target file.

Return type:

None

Verify each bundled skill folder mirrors its .claude/skills/ original.

A skill ships as a whole folder, so repomatic/data/skills/{id}/ must be a real directory whose every leaf is a symlink to the file of the same relative path under .claude/skills/. uv_build refuses a symlinked directory in package data and fails the wheel, while symlinked files are dereferenced into it normally.

Return type:

None

Verify that agent symlinks in repomatic/data/ point to the correct targets.

Return type:

None

tests.test_workflows.test_release_build_matrix_outputs_degrade_to_empty_string()[source]

_release-build.yaml matrix outputs must emit '' (not "null") for null.

GitHub Actions serializes a null value through toJSON(...) as the literal string "null", which is truthy in expressions. A caller that guards its strategy.matrix with ... || '{"include":[]}' then never reaches the fallback, and fromJSON("null") aborts the run with Unexpected value ''. Every *_matrix workflow_call output must therefore degrade to an empty string for a null source (the <value> && toJSON(<value>) || '' pattern), so the caller’s fallback fires and the job skips cleanly.

Return type:

None

tests.test_workflows.test_release_lanes_split_jobs()[source]

The build lane owns the package build; the engine lane owns the rest.

Splitting build-package (and the squash guard) into _release-build.yaml is what lets release.yaml’s publish-pypi depend on the wheel alone. The engine keeps the binary, tag, and release-finalization jobs and must no longer define build-package.

Return type:

None

tests.test_workflows.test_shippability_gate_blocks_the_build_lane()[source]

build-package must stay downstream of the lint-deps gate.

The gate is only a gate through this edge. A lint-deps job that merely reports leaves build-package running, which sets package_built and lets the caller’s publish-pypi ship a wheel nobody can install: a [tool.uv.sources] override never reaches the published metadata, so there is no other job in the lane that could notice.

Skipping build-package is what closes the whole lane. package_built resolves false, so publish-pypi never fires, and the failed lane skips the engine call, taking create-tag, create-release and publish-release with it.

Return type:

None

tests.test_workflows.test_publish_pypi_does_not_touch_the_github_release()[source]

The publish-pypi lane must not edit the GitHub release (ordering race).

The GitHub release is created by the engine’s create-release job, several jobs deeper than the caller’s fast publish-pypi job. A gh release edit in publish-pypi therefore raced the release’s creation, failed with “release not found”, and (under continue-on-error) silently dropped the PyPI availability admonition. The admonition is now baked into the notes at draft creation, so publish-pypi owns only the PyPI upload and needs no contents write.

Return type:

None

tests.test_workflows.test_create_release_bakes_pypi_admonition_at_creation()[source]

The engine bakes the PyPI availability admonition into the draft notes.

create-release writes the notes while the release is still a draft, so the admonition lands there: this avoids both the cross-lane race (above) and the immutable-release locks that apply once publish-release flips the draft. The body falls back to the plain notes for non-PyPI projects, whose release_notes_with_admonition is empty.

Return type:

None

tests.test_workflows.test_release_engine_lane_is_reusable(lane)[source]

Each RELEASE_ENGINE_WORKFLOWS lane is a reusable workflow shipped here.

The generated release.yaml calls these lanes by cross-repo uses:, so each must exist in .github/workflows/ and declare a workflow_call trigger.

Return type:

None

tests.test_workflows.test_release_engine_lanes_not_materialized_downstream()[source]

The engine lanes are referenced cross-repo, never deployed as files.

They must not be downstream FileEntry file_ids (the release entry’s file_id is release.yaml), and the entry’s recorded source must be one of the lanes so WORKFLOW_SOURCES and the concept stay consistent.

Return type:

None

tests.test_workflows.COOLDOWN_EXEMPT_JOBS: dict[str, frozenset[str]] = {'tests.yaml': frozenset({'test-package-install'})}

Jobs allowed to override the workflow-wide cooldown, and why.

test-package-install installs the freshly published artifact on purpose, so a cooldown makes the question it exists to answer unanswerable. See docs/workflows.md § Install-time cooldown for the full exemption roster.

tests.test_workflows.test_workflow_declares_cooldown_env(workflow)[source]

Every workflow carries the cooldown env: block, verbatim.

The block is rendered from [tool.repomatic] minimum-release-age, so this is what keeps the YAML literal tied to its single source of truth: YAML cannot read the config itself. A workflow without it silently resolves packages published seconds ago.

Return type:

None

tests.test_workflows.test_cooldown_block_reads_true_downstream()[source]

The cooldown comment cites the published page, never a repository file.

The block lands above the jobs: line of every generated workflow, in repositories that hold none of this project’s files.

Return type:

None

tests.test_workflows.test_arch_aliases_mirror_extra_platforms()[source]

tests.yaml’s runner check carries extra-platforms’ aliases verbatim.

The check runs on the runner image’s own interpreter, with no dependency install: reaching extra_platforms means resolving a Python through uv, which transparently prefers an x86_64 interpreter on aarch64, the very thing that job exists to catch. So the alias data is copied into the YAML, and this pins the copy to its source the way test_workflow_declares_cooldown_env() pins the cooldown block.

Return type:

None

tests.test_workflows.CLI_INVOCATION_RE = re.compile('uvx\\s+--no-progress\\s+--from\\s+\\.\\s+repomatic|uv\\s+--no-progress\\s+run\\s+--frozen\\s+--\\s+repomatic')

Any way a workflow can invoke this project’s CLI from the local checkout.

tests.test_workflows.test_local_cli_invocation_is_contiguous(workflow)[source]

Every local CLI invocation is the exact one-line form the freeze rewrites.

freeze_cli_version is a literal string replacement, so an invocation a YAML folded scalar wraps across two lines is invisible to it: the release would ship that step still pointing at the local checkout, which downstream has no copy of. The same blind spot hides the step from a line-oriented grep, which is how one survived the migration off uvx --from . and failed only once CI reached it.

Keep the invocation on one line and wrap elsewhere in the command.

Return type:

None

tests.test_workflows.test_local_cli_jobs_discovered()[source]

The local-CLI matcher must find jobs (guards against a dead constant).

Return type:

None

tests.test_workflows.test_local_cli_job_checks_out_repo(workflow_name, job_name)[source]

Every job running the CLI from uv.lock must check out the repository.

uv run --frozen resolves the project from the working directory, so without a checkout there is no pyproject.toml, no uv.lock, and no console script: the step dies on Failed to spawn: repomatic.

The publish-release job shipped without one on the assumption that the freeze had rewritten its invocation to the PyPI-resolved uvx form. It had, on the release commit — but a rebase-merged release PR pushes the freeze and unfreeze commits together, and Actions runs the workflow at the push head, so the unfrozen form is what executes upstream. See repomatic/release/prepare_release.py for the contract.

Return type:

None

tests.test_workflows.test_checkout_precedes_artifact_download(workflow_name, job_name)[source]

A job downloading run artifacts must check out before it downloads.

actions/checkout deletes the contents of its target directory whenever that directory holds no .git of its own, which is exactly the state a fresh workspace is in after actions/download-artifact has written to it:

Deleting the contents of ‘/home/runner/work/{repo}/{repo}’

So the download silently loses everything it fetched, and the job carries on against an empty tree. publish-release acquired a checkout below its binary download this way, which would have left the next release an unpublished draft with no binary attached.

Reversing the two is the whole fix: a checkout into a pristine workspace has nothing to delete, and the download then writes into the checked-out tree.

Return type:

None

tests.test_workflows.test_cooldown_override_is_declared(workflow)[source]

No job weakens the cooldown without being listed as a known exemption.

A job-level env: silently outranks the workflow-level block, so an undeclared override is the one way the cooldown can disappear while every other check still passes.

Return type:

None

tests.test_workflows.test_every_setup_uv_step_pins_a_version(path)[source]

Every setup-uv step declares the uv version it installs.

Two reasons this must hold everywhere rather than mostly. Without the input, setup-uv installs the newest uv satisfying required-version, so the tool enforcing every cooldown arrives without one. And repomatic.release.version_sync._SETUP_UV_VERSION_RE scans lazily from the uses: line to the next version:, so a step missing the input would silently borrow the following step’s and sync-workflow-pins would rewrite the wrong line.

Return type:

None

tests.test_workflows.test_setup_uv_pins_agree()[source]

All setup-uv pins name one uv version.

sync-workflow-pins resolves a single version per package, so a split pin would leave one of the two silently un-bumped.

Return type:

None

tests.test_workflows.test_report_step_outputs_are_consumed()[source]

A report emitted to a step output must be read back somewhere.

--output-format github-actions is the hand-off shape: the command spills a report to a file and names it as a <key>_file step output, for a later step to feed into a pull request body (REPOMATIC_DIFF_TABLE_FILE, --template-arg-file). format-images lost its summary table to exactly that gap in 7.11.0: the step emitting markdown_file survived the collapse of the PR-publishing steps, but its consumer did not, and a body-less template rendered as if the report never existed. An output nobody references is that orphan shape by construction, whatever the key.

Return type:

None

tests.test_workflows.RELEASE_ASSET_ARTIFACT_PREFIX = 'release-asset-'

Prefix the engine’s extra-assets job matches run artifacts on.

tests.test_workflows.test_plugin_archive_is_a_declared_release_asset()[source]

The packed plugin’s filename is one of the declared release assets.

pack-plugin writes ARCHIVE_NAME by default and release.yaml passes no --output, so renaming the constant on its own would leave release-assets and the upload path: naming a file no job produces. The test below then holds that filename equal to the run-artifact name and the needs: edge, which is what makes the handover checkable end to end from a Python constant TOML and YAML cannot read.

Return type:

None

tests.test_workflows.test_declared_release_assets_are_space_free()[source]

No declared filename can contain a space.

The engine passes the list through a space-separated environment variable and word-splits it in bash, so a space silently turns one filename into two and the completeness check passes vacuously.

Return type:

None

tests.test_workflows.test_declared_release_asset_has_a_producing_job(asset)[source]

Every declared asset is produced and handed over by a job in release.yaml.

Three spellings have to agree for the handover to work, in three files no single tool reads at once: the filename in [tool.repomatic] release-assets, the release-asset-<filename> run-artifact name, and the needs: edge that makes the artifact exist before the engine looks for it. Any one of them drifting fails the release at extra-assets, or worse, publishes an immutable release without the asset.

Return type:

None