repomatic.github.workflow_sync module¶

Generation, sync, and lint for downstream workflows.

Downstream repositories consuming reusable workflows from kdeldycke/repomatic manually write caller workflows that often miss triggers like workflow_dispatch. This module provides tools to generate, synchronize, and lint those callers by parsing the canonical workflow definitions.

render_thin_caller_for_target() is the single entry point that turns a canonical workflow into a downstream file on disk; repomatic init drives it.

Generating and reshaping workflow content in Python, rather than hand-maintaining YAML, keeps logic out of the platform-specific GitHub Actions surface: a tested generator that fails loudly beats a static YAML artifact that can silently drift, and the smaller GHA surface eases a future migration to another CI platform. _render_publish_pypi_job derives each downstream publish-pypi job from the canonical release.yaml this way.

Caution

PyYAML destroys formatting and comments on round-trip. Until we find a layout-preserving YAML parsing and rendering solution, we use raw text extraction to manipulate workflow files while preserving formatting and comments.

Todo

Swap the two reusable-workflow lanes in release.yaml to GitHub’s $/ self-repository syntax, and drop their suppression, once actionlint accepts the form: it rejects $/ as a malformed ref today, per rhysd/actionlint#711 and rhysd/actionlint#732. The publish-pypi step keeps ./ either way, since $/ resolves against the workflow’s own commit rather than the release commit it checks out.

repomatic.github.workflow_sync.CALLER_INPUT_DEFAULTS: Final[dict[str, dict[str, str]]] = {'unsubscribe.yaml': {'max-unsubscribes': 'notification_max_unsubscribes', 'months': 'notification_months'}}¶

Caller inputs whose default a repository may set in [tool.repomatic].

Maps a canonical workflow to its configurable inputs, and each input to the Config field that overrides it.

repomatic init is the only moment the value can be applied: a reusable workflow runs on the caller’s runner with no checkout of the caller’s repository, so nothing downstream can read pyproject.toml at run time. The generated caller therefore carries the literal, behind the dispatch input so a manual run still wins:

with:
  max-unsubscribes: ${{ inputs.max-unsubscribes || '600' }}

A repository leaving the default alone gets the plain passthrough, so adding a configurable input churns no existing caller.

repomatic.github.workflow_sync.COOLDOWN_DOCS_URL: Final[str] = 'https://repomatic.net/workflows#install-time-cooldown'¶

Published section holding the rationale of the workflow-level cooldown.

Cited by cooldown_env_block(), whose caution says why a page rather than a file. tests/test_workflows.py holds the anchor to a heading of docs/workflows.md.

repomatic.github.workflow_sync.cooldown_env_block()[source]¶

Render the supply-chain cooldown env: block every workflow carries.

Rendered from minimum_release_age rather than written by hand, so the literal in the YAML has exactly one source. The same text is asserted verbatim against every checked-in workflow by tests/test_workflows.py, and emitted into the downstream release.yaml caller by _generate_release_caller.

Caution

The comment travels into every downstream repository, so it must read true there too. It deliberately does not name tests/test_workflows.py: that file exists only here, and a synced copy would point its readers at a path they do not have. Keep any wording added below equally context-free, and name a repomatic-private path only in a comment that never ships. For the same reason the rationale is cited by COOLDOWN_DOCS_URL and not by claude.md: a downstream repository holds no copy of that file.

Note

A workflow-level env: block cannot reference needs, which is why the window is a literal here instead of a metadata job output: the metadata job runs uvx to compute its own outputs, so anything sourced from it would leave that bootstrap install ungated.

Return type:

str

Returns:

The comment and env: mapping, newline-terminated, ready to splice above a workflow’s jobs: line.

repomatic.github.workflow_sync.PERMISSION_RANK: Final[dict[str, int]] = {'none': 0, 'read': 1, 'write': 2}¶

Relative strength of the permissions: levels GitHub accepts.

Used to union the same scope granted at different levels across the jobs of a canonical workflow, keeping the most permissive one.

repomatic.github.workflow_sync.DEFAULT_VERSION: Final[str] = 'main'¶

Default version reference for upstream workflows.

For release builds (e.g., repomatic==5.11.0), this resolves to the corresponding tag (v5.11.0). For development builds (5.11.1.dev0), it falls back to main since the tag does not exist yet.

class repomatic.github.workflow_sync.WorkflowTriggerInfo(name, filename, non_call_triggers, call_inputs, call_secrets, has_workflow_call, concurrency, raw_concurrency)[source]¶

Bases: object

Parsed trigger information from a canonical workflow.

name: str¶

Workflow display name from the name: field.

filename: str¶

Workflow filename (e.g., release.yaml).

non_call_triggers: dict[str, Any]¶

All triggers except workflow_call, preserving their configuration.

call_inputs: dict[str, Any]¶

Inputs defined under workflow_call.inputs.

call_secrets: dict[str, Any]¶

Secrets defined under workflow_call.secrets.

has_workflow_call: bool¶

Whether the workflow defines a workflow_call trigger.

concurrency: dict[str, Any] | None¶

Parsed concurrency configuration, or None if absent.

raw_concurrency: str | None¶

Raw text of the concurrency block, preserving formatting and comments.

class repomatic.github.workflow_sync.LintResult(message, is_issue, level=AnnotationLevel.WARNING)[source]¶

Bases: object

Result of a single lint check.

message: str¶

Human-readable description of the finding.

is_issue: bool¶

Whether this result represents a problem.

level: AnnotationLevel = 'warning'¶

Severity level for GitHub Actions annotations.

repomatic.github.workflow_sync.workflow_triggers(data)[source]¶

Extract a parsed workflow’s on: mapping.

Note

PyYAML follows YAML 1.1, where a bare on key parses as the boolean True while a quoted "on" stays a string. Both spellings occur in the wild, so every reader of a workflow’s triggers has to try the boolean key first and the string key second. Resolving that here once keeps the quirk from being re-remembered at each call site.

Parameters:

data (object) – The result of yaml.safe_load on a workflow file.

Return type:

dict[str, Any]

Returns:

The trigger mapping, empty when data is not a mapping or declares no triggers.

repomatic.github.workflow_sync.canonical_caller_permissions(filename: str) → dict[str, str][source]¶

Union the job-level permissions: scopes of a canonical workflow.

Memoized like extract_trigger_info(), and under the same read-only contract on the shared result.

A caller job hands its own permissions down to the reusable workflow it calls, and the called workflow’s jobs are capped by them: they cannot escalate beyond what the caller granted. The canonical workflows pin a top-level permissions: {}, so a job without its own block needs nothing and the union of the job-level blocks is the complete set the caller has to forward.

A scope appearing at different levels across jobs resolves to the most permissive one, so no job is starved by another’s narrower grant.

Parameters:

filename (str) – Canonical workflow filename (e.g., autofix.yaml).

Return type:

dict[str, str]

Returns:

Scope-to-level mapping, sorted by scope. Empty when no job declares permissions, meaning the caller forwards nothing.

Raises:

FileNotFoundError – If the workflow file is not bundled.

repomatic.github.workflow_sync.extract_trigger_info(filename: str) → WorkflowTriggerInfo[source]¶

Extract trigger information from a bundled canonical workflow.

Parses the workflow YAML and separates workflow_call configuration from other triggers.

Memoized: the workflow lint asks for the same canonical’s triggers once per check, per downstream file.

Caution

The returned WorkflowTriggerInfo is shared between callers and holds mutable dicts: treat it as read-only, the way every reader does today.

Parameters:

filename (str) – Workflow filename (e.g., release.yaml).

Return type:

WorkflowTriggerInfo

Returns:

Parsed trigger information.

Raises:

FileNotFoundError – If the workflow file is not bundled.

class repomatic.github.workflow_sync.PathsSpec(source_paths=None, extra_paths=<factory>, ignore_paths=<factory>, workflow_paths=<factory>)[source]¶

Bases: object

Bundle of downstream paths: adaptation knobs.

Each field maps to a [tool.repomatic.workflow] option.

Parameters:
  • source_paths (list[str] | None) – Substituted in for the canonical repomatic/** glob in every workflow that references it. None drops the glob without substitution.

  • extra_paths (list[str]) – Appended to every workflow’s paths: list (after source substitution and ignore_paths filtering, before render). Skipped for workflows listed in workflow_paths.

  • ignore_paths (list[str]) – Removed from every workflow’s paths: list by exact string match. Skipped for workflows listed in workflow_paths.

  • workflow_paths (dict[str, list[str]]) – Per-workflow override keyed by filename. The value is treated as the complete paths: list for that workflow; the other knobs do not apply.

source_paths: list[str] | None = None¶
extra_paths: list[str]¶
ignore_paths: list[str]¶
workflow_paths: dict[str, list[str]]¶
repomatic.github.workflow_sync.generate_thin_caller(filename, repo='kdeldycke/repomatic', version='main', commit_sha=None, paths_spec=None, with_permissions=False, existing=None, config=None)[source]¶

Generate a thin caller workflow for a reusable canonical workflow.

The generated caller mirrors the canonical workflow’s non-workflow_call triggers verbatim and delegates to the upstream workflow via uses:. workflow_dispatch is not injected: workflows that should expose manual dispatch declare it in the canonical definition. Declared workflow_call inputs and secrets are forwarded explicitly via with: and secrets:.

Canonical paths: filters are adapted via paths_spec (see PathsSpec).

When commit_sha is provided, the uses: reference is SHA-pinned (@sha # version), secure-by-default from the first commit. The sync-action-pins job bumps it once a newer release clears the cooldown.

Parameters:
  • filename (str) – Canonical workflow filename (e.g., release.yaml).

  • repo (str) – Upstream repository (default: kdeldycke/repomatic).

  • version (str) – Version reference (default: main).

  • commit_sha (str | None) – Full 40-character commit SHA for the version tag. When provided, produces @sha # version. When None, produces @version.

  • paths_spec (PathsSpec | None) – Full paths-adaptation spec; defaults to no adaptation.

  • with_permissions (bool) – Emit an explicit permissions contract: a top-level permissions: {} plus, on the managed job, the scopes the reusable workflow needs (see canonical_caller_permissions()). Set when the downstream file carries extra jobs of its own, whose custom steps: are what make the top-level key worth pinning. Both halves ship together: the top-level {} alone would starve the managed call, which GitHub aborts at startup the moment a nested job asks for a scope the caller never granted.

  • existing (str | None) – Current content of the downstream file, when it already exists. Only release.yaml reads it, to carry over the extra needs: edges of its release lane; every other caller regenerates whole.

Return type:

str

Returns:

Complete YAML content for the thin caller workflow.

Raises:

ValueError – If the workflow does not support workflow_call.

repomatic.github.workflow_sync.EXTRA_JOBS_SEPARATOR: Final[str] = '\n\n'¶

Gap between the last managed lane and the downstream extras below it.

Exactly one blank line, matching how _generate_release_caller separates its own jobs. Both sides are trimmed before it is applied, because neither end is stable on its own: the release caller ends on a trailing blank line where a plain thin caller does not, and extract_extra_jobs() slices from the end of the last managed job body, so it returns however many blank lines the file already had. Joining those two as-is added one blank line per sync, without bound.

repomatic.github.workflow_sync.render_thin_caller_for_target(filename, target, *, repo='kdeldycke/repomatic', version='main', commit_sha=None, paths_spec=None, config=None)[source]¶

Render the complete downstream content of target, extras included.

The single seam between a canonical workflow and a file on disk: read what is already there, carry over what only the downstream copy knows, render the managed lanes, and re-attach the extras. repomatic init is the only caller, so a preservation argument can only ever be wired up once.

Caution

Do not inline this back into a caller. It previously existed as two near-identical copies, and the existing argument that carries a consumer’s needs: edges across a sync reached only one of them: every downstream repomatic init silently dropped the edge while the test suite, which drove the other copy, stayed green. tests/test_workflow_sync.py pins the seam to a single call site.

Reads target itself rather than taking its content, so a caller cannot forget to hand over the state that preservation depends on.

Parameters:
  • filename (str) – Canonical workflow filename (e.g. release.yaml).

  • target (Path) – Destination path, read when it already exists.

  • repo (str) – Upstream repository for the uses: refs.

  • version (str) – Version reference for the uses: refs.

  • commit_sha (str | None) – Full 40-character commit SHA for SHA-pinned refs.

  • paths_spec (PathsSpec | None) – Full paths-adaptation spec; defaults to no adaptation.

Return type:

tuple[str, str | None]

Returns:

The content to write, and the current content of target (None when it does not exist yet) so a caller can skip an unchanged write.

Raises:

ValueError – If filename declares no workflow_call trigger.

repomatic.github.workflow_sync.GENERATED_CALLER_JOBS: Final[frozenset[str]] = frozenset({'build', 'publish-pypi', 'release'})¶

Every job the generated downstream release.yaml defines.

The canonical entry may hold repomatic-local jobs beyond these three (its own pack-plugin, for one), and only these three are copied downstream. Anything the canonical release lane names in needs: outside this set has to be dropped, or the generated file would reference a job that does not exist there. See _merge_release_needs.

repomatic.github.workflow_sync.identify_canonical_workflow(workflow_path, repo='kdeldycke/repomatic')[source]¶

Identify if a workflow is a thin caller for a canonical upstream workflow.

Scans jobs for a uses: reference matching the upstream repository pattern.

Parameters:
  • workflow_path (Path) – Path to the workflow file.

  • repo (str) – Upstream repository to match against.

Return type:

str | None

Returns:

Canonical workflow filename, or None if not a thin caller.

repomatic.github.workflow_sync.extract_extra_jobs(content, repo='kdeldycke/repomatic')[source]¶

Extract extra downstream jobs from an existing thin-caller workflow.

Parses the file with YAML to identify the managed thin-caller job (the one whose uses: references the upstream repository), then returns all raw text after that job: blank lines, comments, and additional job definitions.

Uses raw text slicing (not YAML round-tripping) to preserve formatting and comments, consistent with the rest of the module.

Parameters:
  • content (str) – Full workflow file content.

  • repo (str) – Upstream repository to match against.

Return type:

str

Returns:

Raw text of extra jobs (empty string when there are none).

repomatic.github.workflow_sync.extras_define_jobs(extra)[source]¶

Whether an extras fragment holds actual job definitions.

A fragment can be comments and blank lines only (a trailing note kept after the managed lanes): that content is worth carrying over verbatim, but it must not flip the caller into the explicit-permissions contract reserved for real downstream jobs.

Return type:

bool

repomatic.github.workflow_sync.check_has_workflow_dispatch(workflow_path)[source]¶

Check that a workflow has a workflow_dispatch trigger.

Parameters:

workflow_path (Path) – Path to the workflow file.

Return type:

LintResult

Returns:

Lint result.

repomatic.github.workflow_sync.check_version_pinned(workflow_path, repo='kdeldycke/repomatic')[source]¶

Check that a thin caller pins to a version tag, not @main.

Parameters:
  • workflow_path (Path) – Path to the workflow file.

  • repo (str) – Upstream repository to match against.

Return type:

LintResult

Returns:

Lint result.

repomatic.github.workflow_sync.check_triggers_match(workflow_path, canonical_filename)[source]¶

Check that a thin caller’s triggers match the canonical workflow.

Verifies that the caller includes all non-workflow_call triggers defined in the canonical workflow.

Parameters:
  • workflow_path (Path) – Path to the caller workflow file.

  • canonical_filename (str) – Filename of the canonical upstream workflow.

Return type:

LintResult

Returns:

Lint result.

repomatic.github.workflow_sync.check_secrets_passed(workflow_path, canonical_filename)[source]¶

Check that a thin caller passes all required secrets explicitly.

Verifies that every secret declared by the canonical workflow is forwarded by the caller, either via explicit secrets: mapping or via secrets: inherit.

Parameters:
  • workflow_path (Path) – Path to the caller workflow file.

  • canonical_filename (str) – Filename of the canonical upstream workflow.

Return type:

LintResult

Returns:

Lint result.

repomatic.github.workflow_sync.generate_workflow_header(filename, paths_spec=None)[source]¶

Return the raw header of a canonical workflow.

The header is everything before the jobs: line: name, on triggers, concurrency, and any comments.

Each paths: block in the header is rewritten using paths_spec: upstream source references substituted, optional extras appended, ignored entries stripped, or replaced wholesale via a per-workflow override (see PathsSpec). When the resulting list is empty, the entire paths: block is removed. Comments outside the rewritten blocks are preserved verbatim; comments inside an entry block are not supported.

Parameters:
  • filename (str) – Canonical workflow filename (e.g., tests.yaml).

  • paths_spec (PathsSpec | None) – Full paths-adaptation spec; defaults to no adaptation.

Return type:

str

Returns:

Raw header text.

Raises:
repomatic.github.workflow_sync.run_workflow_lint(workflow_dir, repo='kdeldycke/repomatic', fatal=False)[source]¶

Lint all workflow files in a directory.

For thin callers (workflows that delegate to a canonical upstream workflow via uses:), runs caller-specific checks: version pinning, trigger match, and secrets passed. For standalone workflows, runs check_has_workflow_dispatch() to flag missing manual triggers.

Thin callers are exempt from check_has_workflow_dispatch() because check_triggers_match() is authoritative: a thin caller mirrors its canonical workflow exactly, and some canonical workflows (e.g., cancel-runs.yaml) intentionally lack workflow_dispatch.

Parameters:
  • workflow_dir (Path) – Directory containing workflow YAML files.

  • repo (str) – Upstream repository to match against.

  • fatal (bool) – If True, return exit code 1 when issues are found.

Return type:

int

Returns:

Exit code (0 for clean, 1 if fatal and issues found).