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
Configfield that overrides it.repomatic initis 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 readpyproject.tomlat 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.pyholds the anchor to a heading ofdocs/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_agerather 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 bytests/test_workflows.py. A downstream repository gets the block of its own window: in therelease.yamlcaller_generate_release_callerwrites, and in the headersgenerate_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 byCOOLDOWN_DOCS_URLand not byclaude.md: a downstream repository holds no copy of that file.Note
A workflow-level
env:block cannot referenceneeds, which is why the window is a literal here instead of ametadatajob output: themetadatajob runsuvxto compute its own outputs, so anything sourced from it would leave that bootstrap install ungated.
- 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 tomainsince 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:
objectParsed trigger information from a canonical workflow.
- 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:
- 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_callconfiguration of the parsed workflow from its other triggers.Caution
The returned
WorkflowTriggerInfoholds 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:
- 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:
objectBundle 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 theignore_pathsfilter. Skipped for the workflows ofworkflow_paths.
- ignore_paths: list[str]¶
Removed from every workflow’s
paths:list by exact string match. Skipped for the workflows ofworkflow_paths.
- 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_calltriggers verbatim and delegates to the upstream workflow viauses:.workflow_dispatchis not injected: workflows that should expose manual dispatch declare it in the canonical definition. Declaredworkflow_callinputs and secrets are forwarded explicitly viawith:andsecrets:.Canonical
paths:filters are adapted via paths_spec (seePathsSpec).When commit_sha is provided, the
uses:reference is SHA-pinned (@sha # version), secure-by-default from the first commit. Thesync-action-pinsjob 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. WhenNone, produces@version.paths_spec (
PathsSpec|None) – Full paths-adaptation spec; defaults to no adaptation.with_permissions (
bool) – Emit an explicit permissions contract: a top-levelpermissions: {}plus, on the managed job, the scopes the reusable workflow needs (seecanonical_caller_permissions()). Set when the downstream file carries extra jobs of its own, whose customsteps: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. Onlyrelease.yamlreads it, to carry over the extraneeds:edges of itsreleaselane; 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 ofrelease.yaml.Nonerenders the canonical defaults.
- Return type:
- 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_callerseparates 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, andextract_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 initis 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
existingargument that carries a consumer’sneeds:edges across a sync reached only one of them: every downstreamrepomatic initsilently dropped the edge while the test suite, which drove the other copy, stayed green.tests/test_workflow_sync.pypins 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 theuses:refs.version (
str) – Version reference for theuses: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, seegenerate_thin_caller().
- Return type:
- Returns:
The content to write, and the current content of target (
Nonewhen it does not exist yet) so a caller can skip an unchanged write.- Raises:
ValueError – If filename declares no
workflow_calltrigger.
- repomatic.github.workflow_sync.GENERATED_CALLER_JOBS: Final[frozenset[str]] = frozenset({'build', 'publish-pypi', 'release'})¶
Every job the generated downstream
release.yamldefines.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 canonicalreleaselane names inneeds: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.
- 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.
- 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:
- Return type:
- 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:
- 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,ontriggers,concurrency, the cooldownenv: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 (seePathsSpec). When the resulting list is empty, the entirepaths: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:
- Return type:
- Returns:
Raw header text.
- Raises:
FileNotFoundError – If the workflow file is not bundled.
ValueError – If no
jobs:line is found.
