repomatic.github.workflow_sync module

Generation and sync for downstream workflows.

Downstream repositories consuming reusable workflows from kdeldycke/repomatic manually write caller workflows that often miss triggers like workflow_dispatch. This module generates and synchronizes those callers by parsing the canonical workflow definitions. The checks of a caller live in repomatic.lint_repo.workflows, which lint-repo runs.

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(config=None)[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 default block is asserted verbatim against every checked-in workflow by tests/test_workflows.py. A downstream repository gets the block of its own window: in the release.yaml caller _generate_release_caller writes, and in the headers generate_workflow_header() syncs.

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.

Parameters:

config (Config | None) – The configuration of the repository the block lands in, or None for the default window.

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.

repomatic.github.workflow_sync.canonical_caller_permissions(filename)[source]

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

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)[source]

Extract trigger information from a bundled canonical workflow.

Separates the workflow_call configuration of the parsed workflow from its other triggers.

Caution

The returned WorkflowTriggerInfo holds mutable dicts taken from the shared parse of _canonical: 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=(), 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.

source_paths: tuple[str, ...] = ()

Substituted in for the canonical repomatic/** glob in every workflow that references it. Empty drops the glob without substitution.

extra_paths: list[str]

Appended to every workflow’s paths: list, after the source substitution and the ignore_paths filter. Skipped for the workflows of workflow_paths.

ignore_paths: list[str]

Removed from every workflow’s paths: list by exact string match. Skipped for the workflows of workflow_paths.

workflow_paths: dict[str, list[str]]

Per-workflow override keyed by filename. The value is the complete paths: list of that workflow, and the other knobs do not apply to it.

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 (Mapping[str, Any] | None) – The parsed 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.

  • config (Config | None) – The target repository’s configuration, which sets the defaults of the configurable inputs and the cooldown window of release.yaml. None renders the canonical defaults.

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.

  • config (Config | None) – The target repository’s configuration, see generate_thin_caller().

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.canonical_workflow(data, repo='kdeldycke/repomatic')[source]

Identify the canonical upstream workflow a parsed thin caller delegates to.

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

Parameters:
Return type:

str | None

Returns:

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

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

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

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, or when the file cannot be read or parsed.

repomatic.github.workflow_sync.extract_extra_jobs(content, repo='kdeldycke/repomatic', *, data=None)[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.

  • data (Mapping[str, Any] | None) – The parse of content, when the caller already holds it. Parsed here when None.

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.generate_workflow_header(filename, paths_spec=None, config=None)[source]

Return the raw header of a canonical workflow.

The header is everything before the jobs: line: name, on triggers, concurrency, the cooldown env: block, 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.

The cooldown block is rendered for config: the canonical header carries the default window, and the downstream copy exports its own.

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

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

  • config (Config | None) – The target repository’s configuration, or None for the default cooldown window.

Return type:

str

Returns:

Raw header text.

Raises: