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.

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.

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. See claude.md § Cooldown on every install.

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)[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)[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).