repomatic.github package¶
GitHub integration package: API clients, gh CLI wrapper, and helpers
for every GitHub surface repomatic touches (releases, issues, PRs, tokens,
workflows). See each submodule’s docstring for its scope.
Submodules¶
repomatic.github.actions module¶
GitHub Actions output formatting, annotations, and workflow events.
This module provides utilities for working with GitHub Actions: multiline output formatting, workflow annotations, event payload loading, and GitHub-specific constants and enums shared across multiple modules.
Note
Concurrency quirks addressed by the workflows
SHA-based groups (``release.yaml``): the block sits on the
push-triggered entry workflow, not the reusable _release-engine.yaml it
calls. GitHub decides run cancellation from the entry workflow’s group, and
a block on the engine lane (reached via needs: build) joins its group only
after the build lane finishes, too late to cancel queued or building runs.
cancel-in-progress is evaluated on the new workflow, not the old one. If
a regular commit is pushed while a release workflow is running, the new
workflow would cancel it (same group). Solution: release commits (freeze and
unfreeze) get a unique group keyed by github.sha, so they can never be
cancelled.
Event-scoped groups (``changelog.yaml``): changelog.yaml has
both push and workflow_run triggers. Without event_name in
the concurrency group, a fast-completing workflow_run event would
cancel the push event’s prepare-release job, then skip
prepare-release itself (guarded by if: event_name != 'workflow_run'),
so it would never run. Including event_name prevents cross-event
cancellation.
``workflow_run`` checkout ref: Always use github.sha (latest
commit on the default branch), never workflow_run.head_sha (the
commit that triggered the upstream workflow). After a release cycle
adds commits (freeze + unfreeze), head_sha is stale and produces
a tree that conflicts with current main.
- repomatic.github.actions.NULL_SHA = '0000000000000000000000000000000000000000'¶
The null SHA used by Git to represent a non-existent commit.
GitHub sends this value as the
beforeSHA when a tag is created, since there is no previous commit to compare against.
- repomatic.github.actions.MAX_STEP_OUTPUT_BYTES = 130048¶
Ceiling for a single
$GITHUB_OUTPUTvalue, in UTF-8 bytes.A step output only exists to be read by a later step, and the two ways of reading one both land it in the consumer’s environment:
env:mapping asteps.*.outputs.*expression, and action inputs, which the runner exports asINPUT_*. Linux caps a singleargv/envpstring atMAX_ARG_STRLEN, 32 pages, so a value past that makes the runner’sexecve()of/usr/bin/bashfail withE2BIGbefore the step’s own command exists:##[error]An error occurred trying to start process '/usr/bin/bash' with working directory '/home/runner/work/orchard/orchard'. Argument list too long
The 1 KiB reserve covers the
NAME=prefix the kernel counts as part of the same string, well beyond the longest name in use.This ceiling only binds a value the environment has to carry. A report that grows without bound belongs in a file instead: see
format_file_output(), which hands the consumer a path and leaves the content untrimmed.Caution
This is a transport limit, counted in bytes, and is deliberately looser than
repomatic.github.pr_body.GITHUB_BODY_MAX_CHARS, which is a content limit counted in UTF-16 code units. A value can clear this one and still be trimmed later byrepomatic.github.pr_body.build_pr_body(), which is the layer that leaves the reader a truncation notice.
- class repomatic.github.actions.WorkflowEvent(*values)[source]¶
Bases:
StrEnumWorkflow events that cause a workflow to run.
- branch_protection_rule = 'branch_protection_rule'¶
- check_run = 'check_run'¶
- check_suite = 'check_suite'¶
- create = 'create'¶
- delete = 'delete'¶
- deployment = 'deployment'¶
- deployment_status = 'deployment_status'¶
- discussion = 'discussion'¶
- discussion_comment = 'discussion_comment'¶
- fork = 'fork'¶
- gollum = 'gollum'¶
- issue_comment = 'issue_comment'¶
- issues = 'issues'¶
- label = 'label'¶
- merge_group = 'merge_group'¶
- milestone = 'milestone'¶
- page_build = 'page_build'¶
- project = 'project'¶
- project_card = 'project_card'¶
- project_column = 'project_column'¶
- public = 'public'¶
- pull_request = 'pull_request'¶
- pull_request_comment = 'pull_request_comment'¶
- pull_request_review = 'pull_request_review'¶
- pull_request_review_comment = 'pull_request_review_comment'¶
- pull_request_target = 'pull_request_target'¶
- push = 'push'¶
- registry_package = 'registry_package'¶
- release = 'release'¶
- repository_dispatch = 'repository_dispatch'¶
- schedule = 'schedule'¶
- status = 'status'¶
- watch = 'watch'¶
- workflow_call = 'workflow_call'¶
- workflow_dispatch = 'workflow_dispatch'¶
- workflow_run = 'workflow_run'¶
- class repomatic.github.actions.AnnotationLevel(*values)[source]¶
Bases:
EnumAnnotation levels for GitHub Actions workflow commands.
Mirrors the three levels GitHub supports, even where the codebase only emits a subset.
- ERROR = 'error'¶
- WARNING = 'warning'¶
- NOTICE = 'notice'¶
- class repomatic.github.actions.ReportAction(*values)[source]¶
Bases:
EnumWhat a job did to one item, as the markdown report spells it.
Each member’s value is the emoji-decorated label the report table shows, so rendering reads the label straight off the action instead of consulting a parallel mapping a new member could silently miss.
The members are the union of the vocabularies every report needs, and each report uses the subset that applies to it: the changelog-to-release-notes sync (
release_sync) never unsubscribes, and the notification sweep (unsubscribe) has nothing to call in sync. What they share is the outcome pair every dry-runnable sweep reports, which is why the vocabulary is defined once here rather than re-spelled per report.- DRY_RUN = '👁️ Dry-run'¶
- FAILED = '⚠️ Failed'¶
- SKIPPED = '✅ In sync'¶
- UNSUBSCRIBED = '🔕 Unsubscribed'¶
- UPDATED = '🔄 Updated'¶
- repomatic.github.actions.extract_workflow_filename(workflow_ref)[source]¶
Extract the workflow filename from
GITHUB_WORKFLOW_REF.
- repomatic.github.actions.generate_delimiter()[source]¶
Generate a unique delimiter for GitHub Actions multiline output.
GitHub Actions requires a unique delimiter to encode multiline values in
$GITHUB_OUTPUT. This function generates a random delimiter that is extremely unlikely to appear in the output content.The delimiter format is
GHA_DELIMITER_NNNNNNNNNwhere N is a digit, producing a 9-digit random suffix.- Return type:
- Returns:
A unique delimiter string.
- repomatic.github.actions.trim_to_budget(text, budget, measure)[source]¶
Keep the leading whole lines of text that fit in budget.
Cutting on line boundaries keeps the trimmed markdown rendering: a table missing rows still renders, one cut mid-row does not.
The one trimming loop behind both of GitHub’s size ceilings, which count in different units: measure prices a line in whatever unit the caller’s budget is denominated in (UTF-8 bytes for a step output, UTF-16 code units for a PR or issue body).
- repomatic.github.actions.trim_to_byte_budget(text, budget)[source]¶
Keep the leading whole lines of text that fit in budget UTF-8 bytes.
trim_to_budget()in the step-output unit. Trimming whole lines also keeps the result valid UTF-8, which slicing a byte string cannot promise.
- repomatic.github.actions.format_multiline_output(name, value)[source]¶
Format a multiline value for GitHub Actions output.
Produces output in the heredoc format required by
$GITHUB_OUTPUT:name<<GHA_DELIMITER_NNNNNNNNN value line 1 value line 2 GHA_DELIMITER_NNNNNNNNN
Values over
MAX_STEP_OUTPUT_BYTESare trimmed to fit, so a step reading this output cannot be killed byE2BIGbefore it starts.
- repomatic.github.actions.write_output_file(name, value)[source]¶
Write a step output’s value to a file, and return that file’s path.
The file lands in
RUNNER_TEMPwhere the runner exports it, which is per-job and cleaned up by the runner itself, and in the system temporary directory otherwise. Either is shared by every step of a job, which is what makes the handoff work: producer and consumer are separate processes on one filesystem.
- repomatic.github.actions.format_file_output(name, value)[source]¶
Format a value as a file-backed output, for a consumer to read back.
Writes value out with
write_output_file()and names the step output<name>_file, so the consuming step receives a path instead of content:harvest_file=/home/runner/work/_temp/repomatic-harvest-8m1t0p.md
This is the escape hatch from
MAX_STEP_OUTPUT_BYTES. A report grows with the repository it describes and has no ceiling of its own: the release notes a dependency sweep collects reached 269 KiB on one downstream repo, twice what the environment can carry, and trimming to fit is a loss of content, not a fix. Handing over a path keeps the environment holding a hundred-odd bytes whatever the report weighs, and leaves any trimming torepomatic.github.pr_body.build_pr_body(), which is the layer that knows GitHub’s own body limit and marks the cut for the reader.
- repomatic.github.actions.emit_report(body, output, output_format, key='diff_table')[source]¶
Write a markdown report to
--output, optionally as a step output.The shared tail of every report-producing command: nothing is written when no output path is set or the body is empty; with
--output-format github-actionsthe body is spilled to a file and the step output named<key>_filecarries its path, for$GITHUB_OUTPUTconsumption.A report is the one value here with no ceiling of its own, so it is the one that must not travel inline: see
format_file_output().
- repomatic.github.actions.read_file_output(name)[source]¶
Read a value passed either as a file path or inline.
The consuming half of
format_file_output():<NAME>_FILEholds the path of a file whose content is the value, while<NAME>holds the value itself. The path wins where both are set, the inline variable remaining for a caller that has not moved over, and for a workflow pinned to a release older than the CLI it invokes.
- repomatic.github.actions.emit_annotation(level, message)[source]¶
Emit a GitHub Actions workflow annotation.
Prints a workflow command that creates an annotation visible in the GitHub Actions UI and PR checks.
- Parameters:
level (
AnnotationLevel) – The annotation level.message (
str) – The annotation message.
- Return type:
- repomatic.github.actions.get_github_event() dict[str, Any][source]¶
Load the GitHub event payload from
GITHUB_EVENT_PATH.
- repomatic.github.actions.get_event_pull_request()[source]¶
Return the event payload’s
pull_requestnode, empty when absent.Truthiness, not key presence, is the test every reader below shares. A payload carrying
pull_requestas an empty object has no PR to act on, and it has to read that way tois_pull_request()as well as to the default lookups: testing"pull_request" in eventhere (as this code once did) let the two disagree, sois_pull_requestreported a pull request whileget_default_number()fell through to the issue branch.
- repomatic.github.actions.get_event_subject()[source]¶
Return the issue or pull request the current event is about.
Pull requests win: the two nodes are mutually exclusive on the events these readers handle, and preferring the PR keeps the lookups reading the same node
is_pull_request()reports on.
- repomatic.github.actions.get_default_author()[source]¶
Get the issue/PR author from the GitHub event payload.
- repomatic.github.actions.get_default_number()[source]¶
Get the issue/PR number from the GitHub event payload.
- repomatic.github.actions.is_pull_request()[source]¶
Check if the current event is a pull request.
- Return type:
- repomatic.github.actions.cancel_superseded_runs(branch, current_run_id)[source]¶
Cancel the in-progress and queued workflow runs of branch.
Backs the
cancel-runscommand, fired when a pull request closes: GitHub’sconcurrencymechanism only cancels a run when a new run enters the same group, and closing a PR fires no such run, so the branch’s live runs would otherwise burn CI minutes to completion.Every listed run is cancelled except two: current_run_id (the cancelling run itself), and any run whose head commit carries
RELEASE_COMMIT_PREFIX. A run that fails to cancel (already finished, insufficient token scope) is logged and skipped so one straggler never aborts the sweep. The repository is resolved by theghCLI fromGH_REPOor the checkout, matching every othergh apicall.Caution
The release guard is what makes this safe to point at a default branch. Every workflow’s
cancel-in-progressgate already spares a release run from automatic supersession, but a sweep like this one enters no concurrency group, so nothing else would stop it from killing the matrix that publishes a release. Cancelling a release run mid-flight costs the version its binaries permanently, since publishing locks the asset list (claude.md§ A published release freezes what is missing from it).
repomatic.github.dev_release module¶
Sync a rolling dev pre-release on GitHub.
Maintains a single draft pre-release that mirrors the unreleased
changelog section and always carries the latest successful dev binaries
and Python package. The dev tag (e.g. v6.1.1.dev0) is force-updated
to point to the latest main commit — no tag proliferation.
When the current version’s dev release already exists, it is edited
(not deleted and recreated) so that previously uploaded assets — especially
compiled binaries — survive pushes that skip binary compilation (e.g.
documentation-only changes). The upload_release_assets() function deletes
all existing assets before uploading new ones, preventing stale files from
accumulating when the naming scheme changes. Stale dev releases from previous
versions are always deleted.
Note
Dev releases are created as drafts so they remain mutable even
when GitHub’s immutable releases setting is enabled. Immutability
only blocks asset uploads on published releases — deletion still
works. But because the workflow needs to upload binaries after
creation, the release must stay as a draft throughout its lifetime
to allow asset uploads. See CLAUDE.md § Immutable releases.
- repomatic.github.dev_release.DEV_ASSET_PATTERNS = ('*.bin', '*.exe', '*.tar.gz', '*.whl')¶
Glob patterns for dev release assets.
Both halves are spelled as globs from the sets that define them:
BINARY_ASSET_SUFFIXESfor the compiled binaries, so a dev pre-release carries exactly the artifacts the release workflow downloads andscan-virustotalsubmits, andPYTHON_DIST_SUFFIXESfor what a dev pre-release adds on top. Derived rather than re-listed, because a dev release advertising a different set of assets than the real one is the bug this pairing exists to prevent.Note
Bare extensions (no
repomatic-prefix) keep patterns generic so downstream repositories can reuse the same logic regardless of their package name.
- repomatic.github.dev_release.sync_dev_release(changelog_path, version, repository, dry_run=True, asset_dir=None)[source]¶
Create or update the dev pre-release on GitHub.
Reads the changelog, renders the release body for the given version via
build_expected_body(), then either edits the existing dev release or creates a new one. Stale dev releases from previous versions are always cleaned up.Existing releases are edited (not deleted and recreated) to preserve assets like compiled binaries from previous successful builds. When
asset_diris provided, existing assets are deleted and new ones uploaded viaupload_release_assets().- Parameters:
changelog_path (
Path) – Path tochangelog.md.version (
str) – Current version string (e.g.6.1.1.dev0).repository (
str) – GitHub repository inowner/nameform.dry_run (
bool) – IfTrue, report without making changes.asset_dir (
Path|None) – Directory containing assets to upload. IfNone, no asset upload is performed.
- Return type:
- Returns:
Trueif the release was synced (or would be in dry-run),Falseif the changelog section is empty.
- repomatic.github.dev_release.upload_release_assets(tag, repository, asset_dir)[source]¶
Upload assets to a GitHub release.
Scans
asset_dirfor files matchingDEV_ASSET_PATTERNS. If no matching files are found, returns immediately without modifying the release — this preserves existing assets for documentation-only pushes. When files are found, all existing assets are deleted first to prevent stale files from accumulating when the naming scheme changes.
- repomatic.github.dev_release.cleanup_dev_releases(repository, *, keep_tag=None)[source]¶
Delete stale dev pre-releases from GitHub.
Lists all releases and deletes any whose tag ends with
.dev0, exceptkeep_tagwhich is preserved so its assets (e.g. compiled binaries) survive. This handles stale dev releases left behind after version bumps. Silently succeeds if no dev releases exist or if individual deletions fail.
- repomatic.github.dev_release.delete_release_by_tag(tag, repository)[source]¶
Delete a release and its tag from GitHub.
Silently succeeds if the release does not exist or cannot be deleted. The outcome is returned rather than announced: this helper deletes any release, so only the caller knows what kind of release it just removed and can name it accurately.
repomatic.github.ci_status module¶
Which CI jobs are red, and which of those actually gate a merge.
repomatic names the jobs it generates, prefixing each matrix cell with a
glyph that records whether the cell is allowed to fail: ✅ for a required
one, ⁉️ for a probe running under continue-on-error. Reading that back
was left to whoever was watching CI, and it is easy to get wrong in three
specific ways this module exists to settle:
The glyph, not the position. Job names differ in shape across workflows:
tests.yamlemits✅ ubuntu-26.04 / py3.10while the release engine emitsworkflow / ✅ ubuntu-26.04, abc1234 build. Anything that splits on" / "and reads a fixed field strips the glyph off one of the two and files a required red as a probe, which reads as green.Jobs, not the run. A run’s own
conclusionissuccesswhile acontinue-on-errorprobe inside it crashed, and itsstatusstill readsqueuedwhile a dozen of its jobs have already finished. Neither answers “is anything broken”.A run that failed around its jobs. A
failureconclusion with no failed job is a workflow-level error: an invalidstrategy.matrixexpression, malformed YAML, a missing secret. There is no job log to read, and treating it as benign is how a persistently red workflow gets written off as a known artifact.
A job carrying no stability glyph is required. That covers every non-matrix job
(1️⃣ Run-once tests, 📦 Package install, 🛡️ Lint types), where the absence
of a marker means the job was never optional rather than that its status is
unknown. Only the two stability glyphs count: a job name may carry any other
emoji and still be required, which is why the test is for ⁉️ specifically
rather than for a decorated name.
- repomatic.github.ci_status.STABLE_GLYPH = '✅'¶
Marks a matrix cell that must pass. See
UNSTABLE_GLYPH.
- repomatic.github.ci_status.UNSTABLE_GLYPH = '⁉️'¶
Marks a matrix cell running under
continue-on-error.A red one never gates a merge, which is exactly why it has to be told apart from a required cell rather than counted with it. A release still fixes what it can: see
claude.mdon the genuinely-green goal.
- repomatic.github.ci_status.TERMINAL_STATUSES = frozenset({'completed'})¶
Job statuses meaning the job will not change again.
- repomatic.github.ci_status.CI_STATUS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Workflow', 'workflow'), ('Commit', 'commit'), ('Run status', 'run-status'), ('Verdict', 'verdict'))¶
Column definitions for the
ci-statustable.
- class repomatic.github.ci_status.JobStatus(name, status, conclusion)[source]¶
Bases:
objectOne job of one workflow run.
- property required: bool¶
Whether a failure here gates a merge.
Looks for the glyph anywhere in the raw name rather than at its start. The two shapes disagree on where it sits:
tests.yamlleads with it (⁉️ ubuntu-26.04 / py3.15-dev) while the release engine prefixes the workflow first (release / ⁉️ windows-11-arm, abc1234 build). A leading-position test passes the first and silently files the second as required; splitting on" / "gets it wrong the other way round. Containment is the one form both satisfy, and the templates emit exactly one glyph per name.
- class repomatic.github.ci_status.RunStatus(workflow, run_id, head_sha, status, conclusion, jobs=())[source]¶
Bases:
objectThe latest run of one workflow on one branch.
- class repomatic.github.ci_status.CIStatus(branch, runs=<factory>)[source]¶
Bases:
objectEvery monitored workflow’s latest run on a branch.
- repomatic.github.ci_status.monitored_workflows(workflow_dir)[source]¶
Every workflow a push to the default branch can start.
Derived from the tree rather than listed by hand, so a workflow added later is watched without anyone remembering to add it here. A reusable workflow is excluded: it has no runs of its own, only the ones its callers create.
- repomatic.github.ci_status.latest_run(workflow, branch)[source]¶
Read a workflow’s most recent run on branch, jobs included.
- Parameters:
- Return type:
- Returns:
The run, or
Nonewhen the workflow has none. An empty listing is not proof the workflow was filtered out: GitHub can sit on a push event for hours before materializing a run.
repomatic.github.gh module¶
Generic wrapper for the gh CLI.
Note
Workflow steps must set GH_TOKEN explicitly: GITHUB_TOKEN is a
secret expression in GitHub Actions, not an automatic environment variable.
The standard pattern is GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }}
for steps that prefer a PAT, or GH_TOKEN: ${{ github.token }} otherwise.
As defense-in-depth, run_gh_command() promotes REPOMATIC_PAT to
GH_TOKEN when set, and promotes GITHUB_TOKEN to GH_TOKEN when
GH_TOKEN is absent. A Requires authentication 401 (a GitHub-side
auth incident or a fine-grained PAT scope quirk) is first retried with
the same token after a short back-off, catching transient flaps that
clear on their own. A Bad credentials 401 skips that wait: an expired
or revoked PAT never recovers on a retry. Either then falls back to
GITHUB_TOKEN if available and different. When every retry path is
exhausted, the raised RuntimeError is annotated with the current
githubstatus.com summary so operators
are not sent chasing PAT scopes during an upstream incident.
- repomatic.github.gh.gh_executable() str[source]¶
Resolve the
ghbinary every call in this package shells out to.Prefers the registry-pinned build over whatever
$PATHoffers, which is the ruleclaude.md§ “A cooldown is not a hash” states for any tool repomatic shells out to: the registry pin carries a version, a checksum and a cooldown, while$PATHcarries none of the three and hands each runner image (and each developer laptop) a differentgh.Caution
Falls back to bare
ghon$PATHwhen the registry build cannot be obtained. Unlike a formatter,ghis on the critical path of jobs that have already done real work (a release publish, an issue upsert), so a download failure must not strand them: a hosted runner ships a usablegh, and degrading to it beats failing the job. The fallback is logged, never silent.Memoized: the install-and-verify path runs once per process however many of the ~60 call sites fire.
- Return type:
- repomatic.github.gh.resolve_gh_token()[source]¶
Return the GitHub token from environment variables.
The canonical lookup order for every GitHub API access in the package:
REPOMATIC_PAT>GH_TOKEN>GITHUB_TOKEN. Empty string when no variable is set.- Return type:
- repomatic.github.gh.api_headers()[source]¶
Build GitHub API request headers, authenticated when a token is present.
The one place a direct HTTP call to the GitHub API gets its headers, so every such call agrees on the
Acceptmedia type and the authentication scheme.Beareris the scheme GitHub documents, and it carries both classic and fine-grained tokens.A token raises the rate limit from 60 to at least 1,000 requests/hour, which matters when iterating every tool and action in CI. Resolution follows the canonical
resolve_gh_token()order, so a repo carrying onlyREPOMATIC_PATgets authenticated reads here too, not just through theghCLI.
- repomatic.github.gh.run_gh_command(args)[source]¶
Run a
ghCLI command and return stdout.Token priority:
REPOMATIC_PAT>GH_TOKEN>GITHUB_TOKEN. TheghCLI does not recognizeREPOMATIC_PAT, so when set it is injected asGH_TOKEN. ARequires authentication401 from the primary token is first retried with the same token after a short bounded back-off (see_TRANSIENT_AUTH_BACKOFF_SECONDS), absorbing transient GitHub auth flaps that resolve on their own; a Bad credentials 401 skips straight past it, since a revoked or expired token cannot clear on a retry. A secondary rate-limit refusal gets the same treatment on its own, longer schedule (see_TRANSIENT_THROTTLE_BACKOFF_SECONDS), since it lifts on its own within the minute. If 401s persist, the command is then retried withGITHUB_TOKENif available and different, letting CI jobs degrade gracefully to the standard Actions token instead of failing outright on a stale PAT. When every retry path is exhausted, the raisedRuntimeErrorcarries a githubstatus.com annotation when an incident is active.- Parameters:
- Return type:
- Returns:
The stdout output from the command.
- Raises:
RuntimeError – If the command fails (after retries and fallback, if attempted).
- repomatic.github.gh.parse_create_output(output, kind)[source]¶
Read the number and URL of a thread out of
gh {kind} createoutput.gh issue createandgh pr createboth print the new thread’s URL last, in the formhttps://github.com/owner/repo/{issues,pull}/123. Read the last line rather than the whole output:ghprepends advisory lines of its own (a deprecation notice, a “Warning: N uncommitted changes” banner), and parsing the joined output turns one of those into an error that reads as a failed creation when the thread was in fact created.
- repomatic.github.gh.gh_api_json(args)[source]¶
Run a
ghcommand expected to emit JSON, and parse it.The two ways a JSON-producing
ghcall can fail are indistinguishable to a caller that just wants the payload: the command may not run at all (network, auth, a404on an endpoint the repository has not enabled) or it may return something that is not JSON. Both collapse toNonehere, so a caller reports one “could not read it” outcome instead of two it cannot act on differently.Reserved for calls whose failure is a tolerable outcome, which is what every
repomatic.lint_repocheck wants: a probe that cannot run reports itself as skipped rather than failing the lint. Callers that must distinguish the failure modes, or that treat a failure as fatal, should keep usingrun_gh_command()and handleRuntimeErrorthemselves.
- repomatic.github.gh.gh_graphql(query, **variables)[source]¶
Run a one-shot GraphQL query through
gh, and return itsdataenvelope.The paginated sibling of
iter_graphql_nodes(), for the queries that read a handful of fields off a single object rather than walking a connection. The query travels as a raw field, since--fieldreads a value looking like a number or a boolean as one, which would corrupt a query string that happens to start with a digit.- Parameters:
- Return type:
- Returns:
The response’s
dataobject, unwrapped.- Raises:
RuntimeError – When the
ghinvocation fails (seerun_gh_command()).
- repomatic.github.gh.iter_graphql_nodes(query, connection_path, variables=None, *, page_size_var='', page_size=0, max_nodes=None)[source]¶
Iterate a GraphQL connection’s nodes, following cursor pagination.
The shared
gh api graphqlpagination loop: run the query, walk the response to the connection object, yield each node, then followpageInfo.hasNextPage/endCursoruntil the connection is exhausted. The query must declare a$cursor: Stringvariable and pass it asafter: $cursor, and its connection must selectpageInfo { hasNextPage endCursor }.Null nodes (which GitHub’s
searchconnection can emit) are skipped.- Parameters:
query (
str) – The GraphQL query string.connection_path (
Sequence[str]) – Keys from the response’sdataobject down to the connection (like("search",)or (“user”, “sponsorshipsAsMaintainer”)).variables (
Mapping[str,str|int|bool] |None) – Query variables. Strings are passed with-f; ints and bools with-F, so they keep their GraphQL type.page_size_var (
str) – When set, inject the page size into this query variable on every request (the query then controlsfirst:with it). Leave empty for queries with a hard-coded page size.page_size (
int) – Nodes requested per page; only used with page_size_var. The last page shrinks to the max_nodes remainder so the budget is never over-fetched.max_nodes (
int|None) – Stop after yielding this many nodes.Nonemeans every node in the connection.
- Yields:
Each node dict, in API order.
- Raises:
RuntimeError – When a
ghinvocation fails (seerun_gh_command()).
repomatic.github.issue module¶
GitHub issue lifecycle management.
Generic primitives for listing, creating, updating, closing, triaging and
locking GitHub issues via the gh CLI, used by repomatic.broken_links
and other modules that manage bot-created issues. The pull-request counterpart
lives in pr.
Conversation locking covers both kinds rather than issues alone, because
GitHub gives issues and pull requests one number space and one lock endpoint:
lock_stale_threads() backs the lock-threads command and the autolock
workflow behind it.
The life-cycle of issues created in CI jobs is managed here by hand because the
create-issue-from-file action blindly creates issues ad-nauseam.
See: - https://github.com/peter-evans/create-issue-from-file/issues/298 - https://github.com/lycheeverse/lychee-action/issues/74#issuecomment-1587089689
- repomatic.github.issue.BOT_ISSUE_LABEL = '🤖 ci'¶
Label carried by every issue this module’s lifecycle helper maintains.
Applied by
manage_issue_lifecycle()on creation. Lives here rather than in a calling module because both callers reach the label through that helper, and neither should have to import the other to agree on it. The value is one of the labelsrepomatic/data/labels.tomldeclares, so renaming it there means renaming it here: an issue labelled with a name the registry does not carry is created unlabelled, silently.
- repomatic.github.issue.LOCKED_CONVERSATION_MARKER = 'is locked'¶
Substring GitHub returns when a write is refused on a locked conversation.
The full message is GraphQL: Unable to create comment because issue is locked (addComment). Matching the tail alone also covers the pull-request phrasing, which names the other kind in the same slot.
- repomatic.github.issue.LOCK_INACTIVE_DAYS = 90¶
Days a closed thread must sit untouched before
lock_stale_threads()locks it.Counted from the thread’s last update, not its closing date, so a closed issue someone is still commenting on keeps resetting the clock. That is the same measure
dessant/lock-threadsused, at the same 90-day value this repository configured it with, so replacing the action changed no thread’s fate.
- repomatic.github.issue.LOCK_ISSUE_COMMENT = 'This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.'¶
Comment posted on an issue just before locking it.
- repomatic.github.issue.LOCK_PR_COMMENT = 'This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.'¶
Comment posted on a pull request just before locking it.
- repomatic.github.issue.LOCK_REASON = 'resolved'¶
Reason attached to every automated lock.
One of the four values GitHub accepts (
off_topic,resolved,spam,too_heated), spelled the waygh issue lock --reasonwants it.resolvedis whatdessant/lock-threadsdefaulted to, and it is the only one of the four that describes a thread locked for age rather than for conduct.
- repomatic.github.issue.LOCK_THREADS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Kind', 'kind'), ('Thread', 'thread'), ('Title', 'title'), ('Outcome', 'outcome'))¶
Column definitions for the
repomatic lock-threadstable.Lives beside
lock_stale_threads(), whose rows it names, so the columns and the tuple they render cannot drift apart; the CLI derives its--sort-bychoices from it.
- repomatic.github.issue.LOCK_SEARCH_LIMIT = 200¶
Threads examined per
lock_stale_threads()run.The search API caps a query at 1,000 results and the job runs weekly, so a lower bound keeps one run’s blast radius small while still draining a backlog over a few weeks. A run that hits the cap says so, rather than reporting a clean sweep of a set it only partially saw.
- repomatic.github.issue.add_labels(repository, number, labels, *, is_pr=False)[source]¶
Add labels to an issue or pull request.
Additive only: labels already on the thread are left in place, and none is ever removed. Every automated labeller here pre-labels for the maintainer’s first pass, so it must never undo a classification made by hand.
- Parameters:
- Return type:
- Returns:
Whether the labels were applied.
Falseon an API failure, which is logged rather than raised: a labelling run is a convenience, and failing the job over it would be louder than the outcome deserves.
- repomatic.github.issue.search_stale_threads(repository, inactive_days=90, limit=200)[source]¶
Search closed, unlocked issues and pull requests left inactive too long.
Issues and pull requests share one number space and one search index, so a single
--include-prsquery covers both andisPullRequestsorts them afterwards. Halving the round-trips matters less than the ordering it buys: results come back newest-first across both kinds, so alimitthat truncates cuts the same slice from either.Note
The
is:unlockedhalf of the filter is what makes the whole operation idempotent, and it needs no state of its own: locking a thread removes it from this result set permanently. A second run minutes after the first therefore finds nothing, which is also why the search is authoritative enough to skip a per-threadlockedre-check before writing.- Parameters:
- Return type:
- Returns:
Search result dicts carrying
number,title,url,isPullRequest,labelsandupdatedAt, newest first.
- repomatic.github.issue.lock_thread(repository, number, *, is_pr, comment='', reason='resolved')[source]¶
Comment on a closed thread, then lock its conversation.
The comment goes first on purpose: posting it after the lock would need the lock lifted again, and a reader arriving at a locked thread with no explanation has no way to learn where to go instead.
- Parameters:
repository (
str) – GitHub repository inowner/nameform.number (
int) – The issue or pull request number to lock.is_pr (
bool) – Whether number names a pull request rather than an issue.comment (
str) – Comment to post before locking. Skipped when empty.reason (
str) – Lock reason, one of GitHub’s four accepted values. Omitted from the call when empty.
- Return type:
- repomatic.github.issue.lock_stale_threads(repository, inactive_days=90, issue_comment='This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.', pr_comment='This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.', exclude_labels=('🤖 ci',), limit=200, reason='resolved', *, dry_run=True)[source]¶
Lock every closed thread left inactive for inactive_days.
Caution
exclude_labelsdefaults toBOT_ISSUE_LABELbecause the issuesmanage_issue_lifecycle()maintains are designed to be reopened when their condition recurs, and GitHub refusesaddCommenton a locked conversation. Locking one turns the next reopen into a failed job, which is the holerun_unlocking()exists to patch after the fact. Excluding the label stops the collision at the source; the recovery path stays in place for locks applied by hand.Label exclusion is applied here rather than folded into the search query. GitHub’s
-label:qualifier would work, but it starts with a hyphen, whichgh searchparses as a flag and needs shell-level escaping to survive: a client-side filter over a field the search already returns costs one comparison and no quoting.- Parameters:
repository (
str) – GitHub repository inowner/nameform.inactive_days (
int) – Days without an update before a closed thread qualifies.issue_comment (
str) – Comment posted on an issue before locking it.pr_comment (
str) – Comment posted on a pull request before locking it.exclude_labels (
Sequence[str]) – Threads carrying any of these labels are left alone.limit (
int) – Maximum number of threads to examine in one run.reason (
str) – Lock reason passed togh {issue,pr} lock --reason.dry_run (
bool) – Report what would be locked without writing anything.
- Return type:
- Returns:
One
(kind, number, title, outcome)row per examined thread.
- repomatic.github.issue.list_issues(title='')[source]¶
List all issues (open and closed), optionally filtered by title.
Note
No
--authorfilter is applied. WhenREPOMATIC_PATis configured,ghauthenticates as the token owner (notgithub-actions[bot]), so issues may be authored by either identity. Filtering by author would miss issues created under the other identity, breaking deduplication. The caller (triage_issues()) already matches by exact title, so author-agnostic listing is safe.
- repomatic.github.issue.unlock_thread(number, *, is_pr=False)[source]¶
Unlock an issue or pull request’s conversation.
- repomatic.github.issue.run_unlocking(args, number, *, is_pr=False)[source]¶
Run a commenting
ghcommand, clearing a conversation lock if it blocks.GitHub refuses
addCommenton a locked conversation, which is how a lock breaks the recurring issues this module manages: the next run that needs to reopen one (because the condition recurred) has its reopen comment rejected. The same refusal breaksgh pr close --commenton a locked pull request, which isclose_pr()’s whole retire path. Nothing downstream distinguishes either from a real failure, so the job dies and the report is never filed.lock_stale_threads()no longer causes that, since it skips anything carryingBOT_ISSUE_LABEL. This path remains for the locks it does not own: one applied by hand, or one left behind by thedessant/lock-threadsaction this command replaced, which had no such exclusion configured.Unlocking is deliberate rather than incidental. A conversation that repomatic is reopening is one it is about to comment on again, so the lock has outlived its purpose; autolock re-applies it 90 days after the thread next closes.
Note
The lock is cleared only after a write actually fails, never speculatively. An unlocked conversation therefore costs no extra API call, and a lock set by hand on a thread repomatic never writes to is left alone.
gh issue list --jsonandgh issue view --jsonboth omit thelockedfield, so a pre-flight check would need a REST round-trip on every run to buy nothing.- Parameters:
- Return type:
- Returns:
The command’s standard output.
- Raises:
RuntimeError – When the command fails for any reason other than a conversation lock, or when it still fails after unlocking.
- repomatic.github.issue.reopen_issue(number, comment='')[source]¶
Reopen a previously closed issue.
A closed issue old enough to reopen is old enough to have been autolocked, so the write goes through
run_unlocking().
- repomatic.github.issue.create_issue(body_file, labels, title)[source]¶
Create a new issue.
- Parameters:
body_file (
Path) – Path to the file containing the issue body, already trimmed to GitHub’s size limit (seefit_github_body()).title (
str) – Issue title.
- Return type:
- Returns:
The created issue number.
- Raises:
RuntimeError – When the output carries no parsable issue URL.
- repomatic.github.issue.update_issue(number, body_file)[source]¶
Update an existing issue body.
- Parameters:
number (
int) – The issue number to update.body_file (
Path) – Path to the file containing the new issue body, already trimmed to GitHub’s size limit (seefit_github_body()).
- Return type:
- repomatic.github.issue.triage_issues(issues, title, needed)[source]¶
Triage issues matching a title for deduplication.
- Parameters:
- Return type:
- Returns:
A tuple of (issue_needed, issue_to_update, issue_state, issues_to_close).
If
neededisTrue, the most recent matching issue is kept asissue_to_update(with itsissue_state) and all older matching issues are collected inissues_to_close. IfneededisFalse, all open matching issues are placed inissues_to_close(already-closed issues are skipped).
- repomatic.github.issue.manage_issue_lifecycle(has_issues, body, labels, title, no_issues_comment='No more issues.')[source]¶
Manage the full issue lifecycle: list, triage, close, create/update.
This function handles:
Listing all issues (open and closed) via
gh issue list.Triaging matching issues (keep newest if needed, close duplicates).
Closing duplicate open issues via
gh issue close.Creating, updating, or reopening the main issue via
gh issue create,gh issue edit, orgh issue reopen.
When
has_issuesisTrueand the most recent matching issue is closed, it is reopened and updated rather than creating a duplicate. A conversation lock standing in the way of that reopen is cleared first, so a recurring issue survives the autolock workflow that closes over it; seerun_unlocking().- Parameters:
has_issues (
bool) – Whether issues were found that warrant an open issue.body (
str) – The rendered markdown issue body. Written to a temporary file only when a create or update actually happens, since a run that just closes issues never needs one.labels (
list[str]) – Labels to apply when creating a new issue.title (
str) – Issue title to match and create.no_issues_comment (
str) – Comment to add when closing issues because the condition no longer applies.
- Return type:
repomatic.github.job_timings module¶
Measure how long each runner image actually takes, from finished runs.
Runner selection is supposed to rest on measurement rather than on architecture
folklore, and this repository has already paid for the alternative: the lean
ubuntu-slim image held every mechanical job for years on a benchmark that
timed only the tool pass, where it looked near-parity. Timed end to end it was
20-56% slower, because most of the difference sat in checkout and install,
which that measurement could not see.
The counter-measure was a prose warning plus a two-command gh recipe and a
median taken by hand. This module is that recipe, which makes the rule
mechanical rather than advisory: the jobs API reports startedAt and
completedAt, so a duration read from it is whole-job by construction and
the mistake above is not expressible.
Note
Why the job name, and not a second API call
Attributing a duration to an image needs no extra request, because the matrix
job names already carry it (⁉️ ubuntu-26.04 / py3.15-dev). Matching them
against KNOWN_RUNNERS is the same technique
literal_runners() uses on runs-on: values, and it
degrades honestly: a job whose name carries no known image is reported under
UNATTRIBUTED rather than guessed at.
Caution
These numbers are a sample of a shared, noisy fleet, not a benchmark. A cold image, a queue stall or a flaky network inflates a single cell, which is why the report is a median across several runs rather than a mean of one. Read a gap of a few percent as noise and act only on the systematic ones.
- repomatic.github.job_timings.UNATTRIBUTED = '(no image in job name)'¶
Bucket for a job whose name matches no known runner image.
Non-matrix jobs land here by design: they carry no image in their name because they never had a choice of one. Keeping them visible rather than dropping them is what stops the report reading as though it covered the whole workflow.
- repomatic.github.job_timings.JOB_TIMINGS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Runner', 'runner'), ('Jobs', 'jobs'), ('Median', 'median'), ('Slowest job', 'slowest-job'), ('Slowest', 'slowest'))¶
Column definitions for the
job-timingstable.
- class repomatic.github.job_timings.JobTiming(name, runner, seconds)[source]¶
Bases:
objectOne finished job, and how long it occupied a runner.
- runner: str¶
Runner image matched out of
name, orUNATTRIBUTED.
- repomatic.github.job_timings.match_runner(job_name)[source]¶
Attribute a job to the runner image named in it.
- Parameters:
job_name (
str) – Job name as GitHub reports it.- Return type:
- Returns:
The matched image, or
UNATTRIBUTED.
- repomatic.github.job_timings.fetch_job_timings(workflow, branch='main', limit=5)[source]¶
Read finished job durations from the most recent successful runs.
Only successful runs are sampled. A failed run’s jobs stop early, so their durations measure where the failure landed rather than what the image costs, and a cancelled matrix reports whatever fraction ran before the cancellation swept it.
- Parameters:
- Return type:
- Returns:
One
JobTimingper finished job across the sampled runs.
- class repomatic.github.job_timings.RunnerReport(runner, job_count, median_seconds, slowest_job, slowest_seconds)[source]¶
Bases:
objectAggregated timings for one runner image.
- repomatic.github.job_timings.summarize(timings)[source]¶
Aggregate per-job timings into one row per runner image.
Sorted slowest-median first: the question this answers is which image is holding the matrix up, and that one belongs at the top rather than alphabetically buried.
- Parameters:
timings (
Iterable[JobTiming]) – Job timings, typically fromfetch_job_timings().- Return type:
- Returns:
One
RunnerReportper image seen.
- repomatic.github.job_timings.format_duration(seconds)[source]¶
Render a duration zero-padded to a fixed width.
The padding is not cosmetic.
--sort-byorders the rendered table lexicographically, so an unpadded21ssorts between1m37sand2m03sand the default view reads as though a 21-second job were slower than a 97-second one. Fixed-width00m21smakes the string order the chronological one, for every column and both directions, without the table layer needing to know these cells are durations.- Return type:
- repomatic.github.job_timings.render_markdown(reports, workflow, runs)[source]¶
Render a report as a Markdown table, for pasting into documentation.
Emitted on request rather than written by a sync job. Timings move on every run, so a job regenerating a checked-in table would open a pull request forever and never converge: this is a measurement to take when a decision needs one, not a file to keep in sync.
- Parameters:
reports (
Sequence[RunnerReport]) – Aggregated rows fromsummarize().workflow (
str) – Workflow the sample came from.runs (
int) – How many runs were sampled.
- Return type:
- Returns:
A Markdown table, newline-terminated.
repomatic.github.matrix module¶
GitHub Actions job-matrix model: variations, includes, excludes, and their
expansion into the JSON payload workflow strategy.matrix keys consume.
- repomatic.github.matrix.RESERVED_MATRIX_KEYWORDS = ('include', 'exclude')¶
Keys GitHub reserves inside a
strategy.matrixblock.Neither can name a variation axis, since both already mean something to the matrix expander.
Matrix._check_ids()rejects them.
- repomatic.github.matrix.stale_axis_values(entry, axes)[source]¶
Return the
entrykey/value pairs absent from the matrixaxes.A non-empty result means an
excludedirective can never match a combination: one of its keys is not a live axis, or its value is absent from that axis.Matrix.prune()drops such a directive silently, since GitHub rejects a matrix whose excludes name unknown keys; this is the predicate behind that decision, exposed so callers can also report the drift instead of only absorbing it (seerepomatic.metadata.Metadata.stale_test_matrix_excludes).
- class repomatic.github.matrix.Matrix[source]¶
Bases:
objectA matrix as defined by GitHub’s actions workflows.
See GitHub official documentation on how-to implement variations of jobs in a workflow.
Note
Why matrices are pre-computed in the
metadatajobGitHub Actions matrix outputs are not cumulative — the last job in a matrix wins (community discussion). This makes a matrix-based job terminal in a dependency graph: no downstream job can depend on its aggregated outputs.
The workaround is a single preliminary
metadatajob that computes all matrices upfront. Downstream jobs depend on that job and consume the pre-built matrices, rather than computing them themselves.A matrix starts empty and is populated through its own methods, never through the constructor:
matrix()renders the result as an immutableFrozenDictfor serialization, and__getitem__()reads a single axis, but the object itself is not a mapping: it holds axes, includes and excludes as separate state.The implementation respects the order in which items were inserted. This provides a natural and visual sorting that should ease the inspection and debugging of large matrix.
- matrix(ignore_includes=False, ignore_excludes=False)[source]¶
Returns a copy of the matrix.
The special
includeandexcludesdirectives will be added by default. You can selectively ignore them by passing the corresponding boolean parameters.
- replace_variation_value(variation_id, old, new)[source]¶
Replace a single value within a variation axis.
The new value takes the position of the old value. If the new value already exists elsewhere in the axis, the duplicate is removed by
boltons.iterutils.unique().Silently skips if the axis does not exist or does not contain the old value, making the operation idempotent.
- Return type:
- remove_variation_value(variation_id, value)[source]¶
Remove a single value from a variation axis.
If the axis becomes empty after removal, it is deleted entirely.
Silently skips if the axis does not exist or does not contain the value, making the operation idempotent.
- Return type:
- add_includes(*new_includes)[source]¶
Add one or more
includespecial directives to the matrix.- Return type:
- add_excludes(*new_excludes)[source]¶
Add one or more
excludespecial directives to the matrix.- Return type:
- prune()[source]¶
Remove no-op exclude directives and log about them.
An exclude is a no-op when it references a key that is not a variation axis at all, or when the key exists but the value is not present in that axis. Either way the exclude can never match any combination produced by
product(), and GitHub Actions rejects excludes that reference non-existent matrix keys.- Return type:
- all_variations(with_matrix=True, with_includes=False, with_excludes=False)[source]¶
Collect all variations encountered in the matrix.
Extra variations mentioned in the special
includeandexcludedirectives will be ignored by default.You can selectively expand or restrict the resulting inventory of variations by passing the corresponding
with_matrix,with_includesandwith_excludesboolean filter parameters.
- product(with_includes=False, with_excludes=False)[source]¶
Only returns the combinations of the base matrix by default.
You can optionally add any variation referenced in the
includeandexcludespecial directives.Respects the order of variations and their values.
- solve(strict=False)[source]¶
Expand the matrix to explicit jobs, applying
excludetheninclude.Reproduces GitHub’s documented matrix algorithm:
Build the cross-product of the base variations.
Drop every combination matching an
excludedirective. A directive matches when all of its keys equal the combination’s, so a partial directive removes a whole slice.Process
includedirectives in order. Each is merged into every product combination it does not conflict with (it conflicts when it would overwrite an original axis value). A directive merging into no combination is appended as a new standalone job.
Note
includedirectives augment combinations from the base cross-product only, never jobs created by an earlierinclude. An excluded combination is resurrected solely when anincludefully re-specifies it, so it merges into nothing and is appended: a partialincludethat augments surviving jobs does not bring excluded slices back. GitHub remains the authoritative expander, but this follows its documented rules so downstreamfull-includejob lists (whichmatrix()serializes verbatim) match what GitHub would run.
- pivot(row_axis='python-version', col_axis='os', cell_key='state', missing='—')[source]¶
Pivot the solved matrix into a 2D grid keyed by two axes.
Expands the matrix with
solve(), then arranges the resulting jobs into a grid: one row per distinctrow_axisvalue, one column per distinctcol_axisvalue. Each cell holds the job’scell_keyvalue at that intersection (itsstate, by default), ormissingwhen no job occupies it (an excluded combination).- Parameters:
- Return type:
- Returns:
A
(col_values, rows)pair.col_valuesis the ordered tuple of column values (distinctcol_axisvalues). Each entry inrowsis(row_value, cell, …), with one cell percol_valuesentry.
Note
Axis values keep first-seen order in the solved job stream. That matches the declared axis order for a base cross-product matrix, and the emitted job order for a flattened
full-includematrix.When several jobs share one (row, col) intersection (a matrix carrying extra axes, such as a
click-versionvariation), their distinctcell_keyvalues are joined with,. A matrix with only theosandpython-versionaxes has exactly one job per cell.
repomatic.github.pr module¶
Pull-request lifecycle management for automated jobs.
upsert_pr() converges a bot branch and its pull request onto whatever the
working tree currently holds: it opens the PR when there is something to say,
refreshes it when the content moved, leaves it strictly alone when nothing
changed, and retires branch and PR together once the change evaporates. Every
sync-*, update-*, format-* and fix-* job funnels through it.
Note
Why this is not peter-evans/create-pull-request
That action did this job for years, and this module is a deliberate port of its
algorithm rather than a fresh design: see docs/security.md for the
third-party-action inventory it belongs to. Two properties made porting it
worth the code.
The first is that the action’s cleanup only fires when the action runs. Half
these jobs sit behind an if: gate, and a skipped step cannot delete its own
branch, which is why close_open_prs_on_branch() had to exist as a
separate hand-written reconciler. A command that always runs and decides
internally folds that back into one code path.
The second is that the pieces were already here: repomatic.git_ops owns
the git side, gh the authenticated gh side, and
pr_body renders title, body and commit message. Only
the decision between them lived in YAML.
Note
What a job may hand this
Any mix of the two ways a job produces output: uncommitted working-tree edits
(a formatter) and commits it made itself (a release freeze). Both survive, the
second as separate commits. The checkout may sit on the base branch or be
detached at a commit, as long as base names the branch to open against, and
the base may have moved on since — the carried commits are replayed onto its
fresh tip. Nothing here is left for peter-evans/create-pull-request, and
tests/test_workflows.py fails if a workflow reaches for it again.
The one input that needs restating rather than inferring is the action’s
add-paths, ported as add_paths. Every job upstream writes only what it
means to publish, so the whole-tree default is right for all of them; a
downstream job that provisions its own tooling into the checkout (an npm
install of a linter, a package manager rewriting a lock file on the way past)
has to name its output, or the provisioning rides along into the pull request.
- repomatic.github.pr.STALE_PR_COMMENT = 'Closing automatically: this branch no longer differs from its base, so the change it carried has either landed or become moot.'¶
Comment left on a pull request retired by
upsert_pr().Stands in for the silent close
peter-evans/create-pull-requestperformed by deleting the head branch out from under the PR, which left no trace of why on the conversation.
- repomatic.github.pr.TEMP_BRANCH_PREFIX = 'repomatic/pr-sync-'¶
Namespace for the throwaway local branch a sync builds its commit on.
Never pushed and deleted before the command returns. The candidate commit needs somewhere to live that is not the checked-out base branch, so that a base left untouched is what a failed run rolls back to.
- class repomatic.github.pr.PrOperation(*values)[source]¶
Bases:
StrEnumWhat a
upsert_pr()call did, mirroring the action’s own vocabulary.- CREATED = 'created'¶
The branch was pushed and a new pull request opened.
- UPDATED = 'updated'¶
The branch was force-pushed and the existing pull request refreshed.
- CLOSED = 'closed'¶
The change evaporated: branch deleted and any open pull request closed.
- NONE = 'none'¶
Nothing to do: the branch already carried exactly this change.
- class repomatic.github.pr.PrSyncResult(operation: PrOperation, branch: str, number: int | None = None, url: str = '')[source]¶
Bases:
NamedTupleOutcome of a
upsert_pr()call.Create new instance of PrSyncResult(operation, branch, number, url)
- operation: PrOperation¶
Which of the four branches of the algorithm ran.
- repomatic.github.pr.list_open_prs_by_branch(branch)[source]¶
List open pull requests whose head branch matches
branch.
- repomatic.github.pr.list_changed_files(number, repository='')[source]¶
List the repository-relative paths a pull request changes.
Reads the REST files endpoint with
--paginaterather than the diff, since a diff has to be transferred and parsed to recover names the API already hands over one field at a time.Caution
GitHub caps this endpoint at 3,000 files, silently. A pull request past that returns a truncated list, so a file-glob rule keyed on the tail of a very large diff can miss. Nothing here can lift the cap, and the labeller this feeds is a first-pass convenience, so the truncation is tolerated rather than reported.
- repomatic.github.pr.close_pr(number, comment, delete_branch=True)[source]¶
Close a pull request with a comment.
The close comment is refused on a locked conversation, so the write goes through
run_unlocking(): a hand-locked pull request would otherwise wedgeupsert_pr()’s whole retire path.
- repomatic.github.pr.close_open_prs_on_branch(branch, comment)[source]¶
Close every open PR whose head branch matches
branch.Idempotent: a no-op when no open PR exists on the branch.
- repomatic.github.pr.upsert_pr(branch, title, body, commit_message, base=None, labels=(), assignees=(), draft=False, add_paths=(), remote='origin')[source]¶
Converge branch and its pull request onto what the checkout now holds.
Idempotent by construction: a second call over an unchanged checkout performs no write at all and reports
PrOperation.NONE. The four outcomes are those ofPrOperation.The candidate branch is whatever this checkout has that the base does not: commits the job made itself are carried through as separate commits, and any uncommitted working-tree change becomes one more commit on top, so a job that commits (a release freeze) and a job that only edits files (a formatter) both work without saying which they are. The base commit is read once from the remote, so a detached
HEADis fine as long as base names the branch to open against. When the base has moved past this checkout, the carried commits are replayed onto its fresh tip.All of that happens on a throwaway local branch, and the original checkout is restored before returning. That matters to
autofix.yaml’ssync-depsjob, which opens four pull requests in sequence from one checkout and needs each to start from a clean tree.- Parameters:
branch (
str) – Head branch to create, update or retire.title (
str) – Pull-request title.body (
str) – Rendered markdown body, trimmed here if oversized.commit_message (
str) – Message for the commit capturing uncommitted changes. Unused when the job committed its own work.base (
str|None) – Base branch. Defaults to the checked-out branch, and is required whenHEADis detached.assignees (
Sequence[str]) – Assignees to attach, best-effort.draft (
bool) – Hold the pull request in draft, on every update and not just at creation. See_set_draft().add_paths (
Sequence[str]) – Git pathspecs limiting what the uncommitted-changes commit picks up. Empty commits the whole tree, which is right for a job whose only writes are the ones it means to publish. A job that also provisions its own tooling into the checkout needs the narrower form: anything outside the pathspec is left dirty and discarded with the throwaway branch.remote (
str) – Remote to publish to.
- Raises:
RuntimeError – When
HEADis detached and no base is given, or when base does not exist on remote.- Return type:
repomatic.github.pr_body module¶
Generate PR body with workflow metadata for auto-created pull requests.
Callers inject a Metadata instance for CI
context to produce a collapsible <details> block containing a metadata
table (the injection keeps this module import-cycle-free: Metadata pulls
in half the package). Template prefixes are loaded from markdown files in
repomatic/templates/, optionally with YAML frontmatter for templates that
require arguments.
Note
load_template() and the render_* helpers also accept a
Path to read a template from disk. Downstream repos can ship
project-specific templates and feed them through repomatic pr-body
–template-file path/to/template.md (paired with one or more
--template-arg KEY=VALUE entries to fill the placeholders) without forking
repomatic. External templates should set footer: false in their frontmatter
to avoid duplicating the attribution footer that already ships with the
metadata block.
Also provides two helpers for embedding externally-sourced markdown in PR or
issue bodies: sanitize_markdown_mentions() neutralizes @mentions,
#issue refs, and GitHub URLs, and demote_markdown_headings() pushes
the embedded content’s headings below the embedding document’s own sections.
- repomatic.github.pr_body.GITHUB_BODY_MAX_CHARS = 65536¶
GitHub’s maximum PR and issue body size, in UTF-16 code units.
GitHub’s API rejects longer bodies outright, so an oversized body has to be trimmed before it is posted. The obvious trim is the wrong one: cutting the end (what a plain
body[:65536]does, and what peter-evans/create-pull-request did beforerepomatic.github.prreplaced it) drops whatever sits last, which here is the refresh tip, the metadata block and the attribution footer: the navigational parts a reader needs most when a report is too long to read.build_pr_body()(PRs) andfit_github_body()(issues) therefore trim the leading content instead, so the tail always survives.
- repomatic.github.pr_body.sanitize_markdown_mentions(text)[source]¶
Neutralize
@mentions,#issuerefs, and GitHub URLs in markdown.Prevents GitHub from auto-linking mentions and issue references in externally-sourced markdown (upstream release notes, third-party tool output) that would cause notification spam or accidental issue closure.
Uses a placeholder extraction approach: fenced code blocks and inline code spans are temporarily replaced with unique placeholders before sanitization, then restored afterward. This avoids the fragile “sanitize then restore” pattern that caused bugs in both Dependabot (2019 code-fence regression, dependabot/dependabot-core#1421) and Renovate (ongoing restoration pass edge cases, renovatebot/renovate#8823, renovatebot/renovate#2554).
Inserts a Unicode zero-width space (U+200B) after
@and#to break GitHub’s mention and issue parsers without affecting visual rendering. Rewritesgithub.comURLs toredirect.github.comto prevent backlink cross-references on upstream issues.- Parameters:
text (
str) – Raw markdown text from an external source.- Return type:
- Returns:
Sanitized markdown safe for embedding in a GitHub PR or issue body.
Note
Only call this on externally-sourced content (upstream release notes, third-party tool output). Do not call on content authored by the repository owner where mentions are intentional.
- repomatic.github.pr_body.demote_markdown_headings(text, floor)[source]¶
Demote ATX headings so the shallowest one lands at level floor.
Externally-sourced markdown (upstream release notes) carries its own
#and##headings, which GitHub renders at full size even inside a<details>block, so they compete with the embedding document’s section hierarchy. All headings are shifted deeper by the uniform offset that puts the shallowest at floor, preserving the body’s internal structure; levels past######(h6, markdown’s deepest) are clamped. Headings already at or below floor are left alone: this function never promotes.Fenced code blocks are shielded with the same placeholder extraction as
sanitize_markdown_mentions(), so# commentsin shell samples survive. Only ATX headings are rewritten: setext headings (underlined with===or---), rare in release notes, pass through unchanged.
- repomatic.github.pr_body.load_template(name)[source]¶
Load a PR body template by name or filesystem path.
Dispatch is type-based:
str(e.g."bump-version"): looked up as a packaged resource underrepomatic.templates, cached for the process (see_load_bundled_template()). Tries{name}.md.noformatfirst, then{name}.md. The.md.noformatextension is used for templates whosestring.Templateplaceholders confuse mdformat (e.g.$rerun_entryprefixed to a list line is parsed as literal text, breaking the list structure). Seepr-metadata.md.noformatfor the canonical example.Path: read directly from the filesystem, never cached, so a downstream repo iterating on a project-specific template sees each edit.
- repomatic.github.pr_body.render_template(*names, **kwargs)[source]¶
Load and render one or more templates with variable substitution.
When multiple template names are given, each is rendered and joined with a blank line. The
generated-footerattribution is appended once at the end if any of the templates wants it (i.e. does not havefooter: falsein its frontmatter).Static templates (no
$variableplaceholders) are returned as-is. Dynamic templates usestring.Template($variablesyntax) to avoid conflicts with markdown braces like[tool.repomatic].Consecutive blank lines left by empty variables are collapsed to a single blank line.
Note
The footer’s version is
__version__as read from the working tree, which makes it cosmetically wrong on the version-machinery PRs. That is accepted rather than fixed. Upstream runs the CLI from its own checkout (LOCAL_CLI_INVOCATION), and both thebump-versionandprepare-releasejobs rewrite__version__withbump-my-versionbefore this renders, so each of those bodies advertises the version its own PR produces (theminorormajorbump target, or the post-release patch bump) instead of the identical code that rendered it. Both fixes cost more than the tag is worth: rendering the body before the bump means splittingpr-bodyback out ofpr-sync, and feeding the pre-bump version in means a CLI option that exists only to work around step ordering. Downstream never sees it, since a frozen workflow runsuvx 'repomatic==X.Y.Z'and takes its version from the installed distribution.
- repomatic.github.pr_body.render_title(name, **kwargs)[source]¶
Load and render a template’s PR title with variable substitution.
- repomatic.github.pr_body.render_commit_message(name, **kwargs)[source]¶
Load and render a template’s commit message with variable substitution.
Falls back to the
titleif nocommit_messageis defined, and to an empty string if neither is set (templates without a title or commit message render only their body).- Parameters:
- Return type:
- Returns:
The rendered commit message string, or an empty string when the template defines neither
commit_messagenortitle.
- repomatic.github.pr_body.template_args(name)[source]¶
Return the list of required arguments for a template.
- repomatic.github.pr_body.template_labels(name)[source]¶
Return the labels a template’s pull request should carry.
Read from the
labels:frontmatter key, accepting a YAML list or a single string. The template is the one place that knows which operation it fronts, so its labels live beside its title and commit message rather than being repeated in every workflow step that opens the PR.
- repomatic.github.pr_body.template_draft(name)[source]¶
Return whether a template’s pull request should be held in draft.
Read from the
draft:frontmatter key. Both the YAML boolean and its quoted spelling are honored, mirroring thefooter:key.
- repomatic.github.pr_body.template_docs_url(name)[source]¶
Return a template’s documentation deep link, if it declares one.
PR templates carry a
docs:frontmatter field pointing at their job’s section of the hosted workflows reference, surfaced as theDocumentationentry of the metadata block now that PR bodies have no description section.
- repomatic.github.pr_body.template_stem(filename)[source]¶
Return a template’s name, shorn of its
.mdor.md.noformatextension.The one place that knows how template filenames decompose:
.md.noformatfiles are renamed.mdfiles hidden from mdformat (seeload_template()), so both extensions strip down to the same name. Callers derive a PR branch or a documentation label from a--template-filepath with it, andget_template_names()names the bundled templates through it.
- repomatic.github.pr_body.get_template_names()[source]¶
Discover all available template names from the templates package.
- repomatic.github.pr_body.generate_pr_metadata_block(md, docs_url='', docs_name='')[source]¶
Generate a collapsible metadata block from CI context.
Reads the
GITHUB_*environment context from md and returns a markdown<details>block listing the workflow metadata fields.- Parameters:
md (
Metadata) – TheMetadatainstance to read CI context from.docs_url (
str) – Optional deep link to the job’s section of the hosted workflows reference, rendered as the leadingDocumentationentry. Comes from the PR template’sdocs:frontmatter field (seetemplate_docs_url()).docs_name (
str) – Label for the documentation link, usually the template (operation) name. Without it the raw URL renders as an autolink.
- Return type:
- Returns:
A markdown string with the metadata block.
- repomatic.github.pr_body.generate_refresh_tip(md)[source]¶
Generate a tip admonition inviting users to refresh the PR manually.
Reads the repository URL and
GITHUB_WORKFLOW_REFfrom md to build the workflow dispatch URL.
- repomatic.github.pr_body.build_release_review_steps(md, version)[source]¶
Build the two optional review steps of the prepare-release checklist.
The
How-to releaselist opens with two review steps that render only when their GitHub data is reachable, so the checklist degrades to the bare merge instructions offline (or when the dev pre-release is disabled):Dev pre-release review: links the rolling
v{version}.dev0draft through itshtml_url(drafts have no public tag URL). Omitted when no such draft is visible.Full-changes review: links the
v{previous}...maincomparison. Omitted when no prior release exists to compare against.
Both come from a single
dev_release_url_and_previous_version()lookup. Each returned string is a complete ordered-list line ending in a newline (or empty), written with a1.marker so the surrounding lazily numbered list renumbers correctly however many steps survive.
- repomatic.github.pr_body.build_pr_body(prefix, metadata_block, refresh_tip='')[source]¶
Concatenate prefix, refresh tip, and metadata block into a PR body.
The
metadata_blockalready includes the attribution footer (appended automatically byrender_template()); the refresh_tip comes pre-rendered fromgenerate_refresh_tip()(empty to omit it).Bodies over
GITHUB_BODY_MAX_CHARShave their prefix trimmed to fit, replacing the dropped lines with a caution admonition, so the refresh tip, metadata block, and attribution footer always survive. Left alone, GitHub-side truncation would chop the body from the end instead.- Parameters:
prefix (
str) – Content to prepend before the metadata block. Can be empty.metadata_block (
str) – The collapsible metadata block fromgenerate_pr_metadata_block(), with footer.refresh_tip (
str) – Pre-rendered refresh-tip admonition, or empty.
- Return type:
- Returns:
The complete PR body string.
- repomatic.github.pr_body.fit_github_body(body)[source]¶
Trim an oversized issue or pull-request body, keeping the footer.
The
build_pr_body()counterpart for bodies rendered straight from a footer-carrying template (broken-links report, setup guide), and the safety netupsert_pr()runs over an explicit--bodythat never went throughbuild_pr_body(). The GitHub API rejects oversized bodies outright (gh issue create,gh issue editandgh pr createall fail), so the content above the attribution footer is trimmed on line boundaries, with a caution admonition marking the cut.
- repomatic.github.pr_body.temp_body_file(body)[source]¶
Materialize a rendered body as a temporary file, then remove it.
The
ghCLI takes a body only through--body-file, so every write path against an issue or a pull request needs one on disk. Owning the temp file at this layer keeps callers working in the currency they actually produce (rendered markdown) instead of each repeating the same write /try/unlinkenvelope.
repomatic.github.release_sync module¶
Sync GitHub release notes from changelog.md.
Compares each GitHub release body against the corresponding
changelog.md section and updates any that have drifted.
changelog.md is the single source of truth.
- repomatic.github.release_sync.build_expected_body(changelog, version, *, admonition_override=None)[source]¶
Build the expected release body from the changelog.
Decomposes the changelog section into discrete elements and renders them through the
github-releasestemplate. This allows the GitHub release body to include a different subset of elements than therelease-notestemplate used forchangelog.mdentries.Lives here, with the release publishers, rather than in
repomatic.changelog: every caller is a GitHub-release writer (this sync, the dev pre-release, the release-notes metadata keys), and the changelog module renders changelog entries, not release bodies.- Parameters:
- Return type:
- Returns:
The rendered release body, or empty string if the version has no changelog section.
- class repomatic.github.release_sync.SyncRow(action, version, release_url)[source]¶
Bases:
objectPer-release detail for the markdown report table.
- action: ReportAction¶
- class repomatic.github.release_sync.SyncResult(dry_run=True, rows=<factory>, total=0, in_sync=0, drifted=0, updated=0, failed=0, missing_changelog=0)[source]¶
Bases:
objectAccumulated results from a release-notes sync run.
- repomatic.github.release_sync.sync_github_releases(repo_url, changelog_path, dry_run=True)[source]¶
Sync GitHub release bodies from
changelog.md.For each released version in the changelog, compares the expected body (from
changelog.md) with the actual GitHub release body. In live mode, updates drifted releases viagh release edit.- Parameters:
- Return type:
- Returns:
Structured sync results.
- repomatic.github.release_sync.render_sync_report(result)[source]¶
Render a markdown report from sync results.
- Parameters:
result (
SyncResult) – Structured results from the sync run.- Return type:
- Returns:
Markdown report string.
repomatic.github.releases module¶
GitHub Releases API client.
The single home for reading GitHub Releases: raw cached API access (tags,
versions, single bodies), tag-to-version extraction, tag-to-SHA resolution, and
the range-to-release-notes fetch shared by the dependency updaters. The
repomatic.version_sync adapters and repomatic.dep_report
release-notes helper build on top of these reads.
One write helper lives here too: edit_release_notes(), the shared
gh release edit path behind the dev pre-release sync
(repomatic.github.dev_release) and the changelog-to-release-notes sync
(repomatic.github.release_sync), so both writers carry the same
arguments and failure contract.
- repomatic.github.releases.GITHUB_API_RELEASES_URL = 'https://api.github.com/repos/{owner}/{repo}/releases'¶
GitHub API URL for fetching all releases for a repository.
- repomatic.github.releases.GITHUB_API_TAG_REF_URL = 'https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}'¶
GitHub API URL for resolving a tag name to its git object.
- repomatic.github.releases.GITHUB_API_TAG_OBJECT_URL = 'https://api.github.com/repos/{owner}/{repo}/git/tags/{sha}'¶
GitHub API URL for dereferencing an annotated tag object to its commit.
- repomatic.github.releases.GITHUB_API_RELEASE_BY_TAG_URL = 'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}'¶
GitHub API URL for fetching a single release by tag name.
- repomatic.github.releases.owner_repo(repo_url)[source]¶
Extract
(owner, repo)from a GitHub repository URL.
Bases:
RuntimeErrorRaised when the GitHub Releases API call could not complete cleanly.
Signals a transient failure (network error, timeout, JSON parse error, or pagination breaking mid-stream) where the result cannot safely be treated as “no releases.”
Callers that drive destructive operations (rewriting the changelog, deleting tags, etc.) must catch this and refuse to act, rather than silently rewriting state with a corrupted or empty view of release history.
- class repomatic.github.releases.GitHubRelease(date: str, body: str)[source]¶
Bases:
NamedTupleRelease metadata for a single version from GitHub.
Create new instance of GitHubRelease(date, body)
- class repomatic.github.releases.ReleaseAsset(name: str, size: int, sha256: str, download_url: str)[source]¶
Bases:
NamedTupleA single downloadable asset attached to a GitHub release.
Create new instance of ReleaseAsset(name, size, sha256, download_url)
- class repomatic.github.releases.ReleaseWithAssets(tag: str, date: str, draft: bool, prerelease: bool, assets: tuple[ReleaseAsset, ...], body: str = '', html_url: str = '')[source]¶
Bases:
NamedTupleFull release metadata including its assets and visibility flags.
Create new instance of ReleaseWithAssets(tag, date, draft, prerelease, assets, body, html_url)
- assets: tuple[ReleaseAsset, ...]¶
Assets attached to the release, in API order.
- body: str¶
Release notes body (markdown).
Carried so
sync-binaries --backfill-recordscan recover detection snapshots from the legacy VirusTotal tables that release notes held before the scan history file existed.
- html_url: str¶
Browser URL of the release page.
For a draft release this is the only resolvable link: drafts have no public
releases/tag/<tag>URL, so GitHub serves them at an unguessablereleases/tag/untagged-<hash>path exposed only in this field. The prepare-release PR body links the rolling dev pre-release through it (seedev_release_url_and_previous_version()).
- repomatic.github.releases.get_github_releases(repo_url, *, force_refresh=False)[source]¶
Get versions and dates for all GitHub releases.
Fetches all releases via the GitHub API with pagination. Extracts version numbers by stripping the
vprefix from tag names. Usespublished_at(falling back tocreated_at) for the date.- Parameters:
- Return type:
- Returns:
Dict mapping version strings to
GitHubReleasetuples. Empty dict only when the repository genuinely has no releases (the API returned an empty page) or whenrepo_urldoes not parse to anowner/repopair.- Raises:
GitHubReleasesUnavailable – When any page fetch fails or returns unparsable JSON. An empty return value from this function means “the repo has no releases”; a raised exception means “the answer is unknown.”
- repomatic.github.releases.get_release_tags(repo_url)[source]¶
Get all releases keyed by their raw, unstripped tag name.
get_github_releases()keeps onlyv-prefixed tags (and strips thev), which drops tools whose release tags use another scheme (lychee’slychee-v…, biome’s@biomejs/biome@…).sync-tool-versionsandsync-action-pinsneed every tag, so the version can be extracted with a per-tool pattern.- Parameters:
repo_url (
str) – Repository URL.- Return type:
- Returns:
Dict mapping raw tag names to
GitHubReleasetuples. Empty only when the repository has no releases orrepo_urldoes not parse to anowner/repopair.- Raises:
GitHubReleasesUnavailable – When any page fetch fails or returns unparsable JSON.
- repomatic.github.releases.get_releases_with_assets(repo_url)[source]¶
Get every release with its assets, visibility flags, and digests.
Deliberately uncached, unlike
get_github_releases(): the main consumer issync-binaries, which runs minutes after a release is published and must see the assets that were just uploaded. A cached view would regenerate the binaries page from a pre-release snapshot.- Parameters:
repo_url (
str) – Repository URL (e.g.https://github.com/user/repo).- Return type:
- Returns:
One
ReleaseWithAssetsper release (drafts and pre-releases included, for the caller to filter), in API order (newest first). Empty when the repository has no releases or repo_url does not parse to anowner/repopair.- Raises:
GitHubReleasesUnavailable – When any page fetch fails or returns unparsable JSON.
- repomatic.github.releases.parse_release_version(tag)[source]¶
Parse a release tag as a version, or
Nonefor foreign tag schemes.- Return type:
Version|None
- repomatic.github.releases.dev_release_url_and_previous_version(repo_url, version)[source]¶
Look up the two release references the prepare-release PR body links to.
A single
get_releases_with_assets()fetch yields both:Dev pre-release URL: the
html_urlof the draft pre-release whose version shares version’s release segment (the rollingv{version}.dev0draft). Drafts are visible only to authenticated maintainers, so an unauthenticated or token-less caller getsNonehere even when the draft exists.Previous version: the highest final release (draft, pre-release, and
.devtags excluded) already published. At prepare-release time the tag for version does not exist yet, so this is the release the new one supersedes, used for thev{previous}...maincomparison link.
- Parameters:
- Return type:
- Returns:
An
(dev_release_url, previous_version)pair. Either element isNonewhen its release cannot be found or the API is unavailable, so the caller degrades each list item independently.
- repomatic.github.releases.resolve_tag_to_sha(repo_url, tag)[source]¶
Resolve a release tag to its 40-character commit SHA.
Reads the tag’s git reference. An annotated tag points at an intermediate tag object, dereferenced one hop to the commit it targets; a lightweight tag points straight at the commit.
- repomatic.github.releases.extract_version(tag, tag_pattern)[source]¶
Extract a version from a GitHub release tag.
- repomatic.github.releases.edit_release_notes(tag, repository, body, *, title='')[source]¶
Edit a release’s notes (and optionally its title) in place.
The one
gh release editpath shared by every release writer, so the dev pre-release sync and the changelog-to-release-notes sync carry the same arguments and failure contract. Assets are never touched.- Parameters:
- Return type:
- Returns:
Truewhen the edit landed,Falsewhen the release does not exist or the edit failed.
- repomatic.github.releases.get_github_release_body(repo_url, version)[source]¶
Fetch the release notes body for a specific version from GitHub.
Tries
v{version}first (most common for Python packages), then the bare{version}tag.
- repomatic.github.releases.fetch_github_release_notes(items)[source]¶
Fetch GitHub release notes for a batch of version bumps.
For each item, lists the repository’s releases (a cached call, already warm from a prior candidate sweep) and keeps those whose extracted version lands in the half-open range
(old, new], oldest first. Non-GitHub datasources (npm, PyPI workflow literals) contribute no item here and render no notes.- Parameters:
items (
list[tuple[str,str,str,str,str|None]]) – One(name, repo_url, old, new, tag_pattern)tuple per bumped pin, where old and new are bare versions and tag_pattern is the per-tool extraction regex (orNonefor thevX.Y.Zscheme).- Return type:
- Returns:
A dict mapping names to
(repo_url, versions)tuples, the same shaperepomatic.dep_report.fetch_release_notes()returns, sorepomatic.dep_report.format_release_notes()renders it unchanged. Only entries with at least one non-empty release body are included.
repomatic.github.sponsor module¶
Check if a GitHub user is a sponsor of another user or organization.
Uses the GitHub GraphQL API via the gh CLI to query sponsorship data.
Supports both user and organization owners, with pagination for accounts
that have more than 100 sponsors.
When run in GitHub Actions, the owner defaults to
Metadata’s view of the repository; the author
and issue/PR number come from the event-payload readers in
actions.
- repomatic.github.sponsor.get_default_owner()[source]¶
Get the repository owner from CI context.
Delegates to
Metadata.repo_owner.
- repomatic.github.sponsor.SPONSORS_QUERY_TEMPLATE = '\nquery($owner: String!, $cursor: String) {\n %s(login: $owner) {\n sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true) {\n pageInfo { hasNextPage endCursor }\n nodes { sponsorEntity { ... on User { login } ... on Organization { login } } }\n }\n }\n}\n'¶
GraphQL query for an account’s sponsors, parameterized on the account kind.
The user and organization queries are identical except for the node naming the account (
userororganization), which doubles as the response’s data path:_iter_sponsors()interpolates it into both places.
- repomatic.github.sponsor.get_sponsors(owner: str) frozenset[str][source]¶
Get all sponsors for a user or organization.
Tries the user query first, then falls back to organization query.
Results are cached to avoid redundant API calls within the same process.
repomatic.github.status module¶
Probe githubstatus.com on API failures.
When a gh or REST call fails with an opaque error, callers can ask this
module whether GitHub is reporting a live incident. The status page is the
source of truth for outages affecting authentication, the REST API, and
Actions, so surfacing it in error messages saves operators from chasing
PAT scopes that aren’t actually broken.
The HTTP probe is memoized for the lifetime of the process: a single CLI
invocation that fails ten gh calls in a row hits the status endpoint
once. Failures (DNS, timeout, JSON parse) collapse to None so the
probe itself never masks the original error.
- repomatic.github.status.GITHUB_STATUS_SUMMARY_URL = 'https://www.githubstatus.com/api/v2/status.json'¶
Status summary endpoint exposed by Statuspage.
Returns a JSON document with a top-level
statusobject containing anindicator(none,minor,major,critical,maintenance) and a human-readabledescription.
- class repomatic.github.status.GitHubStatus(indicator, description)[source]¶
Bases:
objectSnapshot of the githubstatus.com summary.
- Parameters:
- repomatic.github.status.get_github_status() GitHubStatus | None[source]¶
Fetch the current githubstatus.com summary.
Memoized for the lifetime of the process: only the first call hits the network. Returns
Nonewhen the probe cannot complete cleanly (network error, timeout, malformed JSON, missing fields), so callers can treat the probe as best-effort and never let it mask the underlying error they were trying to annotate.- Return type:
- repomatic.github.status.status_annotation()[source]¶
Return a one-line incident annotation, or empty string when healthy.
Convenience wrapper around
get_github_status()for the common case where callers want to append a string to an error message without branching onNone.- Return type:
repomatic.github.token module¶
GitHub token validation utilities.
Provides early validation for CLI commands that depend on the GitHub API, so users get clear error messages at startup rather than opaque failures mid-execution.
Note
Why REPOMATIC_PAT is needed
GitHub’s GITHUB_TOKEN cannot modify workflow files in .github/.
Neither contents: write, actions: write, nor permissions:
write-all grant this ability. The only way to push changes to workflow
YAML files is via a fine-grained Personal Access Token with the
Workflows permission. Without it, pushes are rejected with:
! [remote rejected] branch_xxx -> branch_xxx (refusing to allow a
GitHub App to create or update workflow
``.github/workflows/my_workflow.yaml`` without ``workflows`` permission)
Additionally, events triggered by GITHUB_TOKEN do not start new
workflow runs (see GitHub docs),
so tag pushes also need the PAT to trigger downstream workflows.
The Settings → Actions → General → Workflow permissions setting has no effect on this limitation — it’s a hard security boundary enforced by GitHub regardless of repository-level settings.
The permission has to reach the actual git push, not just the gh CLI:
setting GH_TOKEN in a step’s env only authenticates gh/repomatic API
calls made by that process. A bare git push (git_ops.force_push_branch,
which every pr-sync template goes through) instead authenticates with
whatever credentials actions/checkout configured for the job, which
defaults to github.token regardless of GH_TOKEN. A job whose diff can
touch .github/workflows/ needs token: ${{ secrets.REPOMATIC_PAT ||
github.token }} on its own checkout step too, or the push is rejected
exactly as if the PAT had never been set.
Jobs that use REPOMATIC_PAT:
autofix.yaml: fix-typos, sync-repomatic, sync-action-pins, sync-workflow-pins (PRs touching.github/workflows/files), sync-tool-versions (upstream-only dependency PRs), fix-vulnerable-deps (reads the GitHub Advisory Database).changelog.yaml: prepare-release (freezes versions in workflow files).release.yaml: create-tag (push triggerson.push.tags), create-release (triggers downstream workflows).
All jobs fall back to GITHUB_TOKEN when the PAT is unavailable
(secrets.REPOMATIC_PAT || github.token), but
operations requiring the workflows permission or workflow triggering
will silently fail.
Token permission mapping:
Workflows — PRs that touch
.github/workflows/files.Contents — Tag pushes, release publishing, PR branch creation.
Pull requests — All PR-creating jobs.
Dependabot alerts — fix-vulnerable-deps reads vulnerability alerts.
Issues — Setup guide issue.
Administration — Reads the Actions settings the setup guide verifies: SHA pinning required, and the fork-PR contributor-approval policy. Read-only: repomatic never writes a repository setting.
Metadata — Required for all fine-grained token API operations.
- repomatic.github.token.require_token(module, attr)[source]¶
Decorator that runs a token validator before the Click command body.
Uses late-bound
getattr(module, attr)so thatunittest.mock.patchcan replace the module attribute after import and the decorator sees the mock at call time.
- class repomatic.github.token.PatProbe(field: str, permission: str, endpoint: str, success: str, not_found: str = '')[source]¶
Bases:
NamedTupleOne fine-grained PAT permission probe.
A read-only API call whose
HTTP 403unambiguously identifies the missing fine-grained permission. Rows live inPAT_PERMISSION_PROBES.Create new instance of PatProbe(field, permission, endpoint, success, not_found)
- field: str¶
The
PatPermissionResultsfield receiving this probe’s result.
- repomatic.github.token.PAT_PERMISSION_PROBES: tuple[PatProbe, ...] = (('administration', 'Administration: Read-only', 'repos/{repo}/actions/permissions', 'Administration: token has access', ''), ('contents', 'Contents: Read and Write', 'repos/{repo}/contents/.github', 'Contents: token has access', ''), ('issues', 'Issues: Read and Write', 'repos/{repo}/issues?per_page=1&state=all', 'Issues: token has access', ''), ('pull_requests', 'Pull requests: Read and Write', 'repos/{repo}/pulls?per_page=1&state=all', 'Pull requests: token has access', ''), ('vulnerability_alerts', 'Dependabot alerts: Read-only', 'repos/{repo}/dependabot/alerts?per_page=1', 'Dependabot alerts: token has access, alerts enabled', 'Vulnerability alerts are not enabled on the repository. Enable them: gh api repos/{repo}/vulnerability-alerts --method PUT'), ('workflows', 'Workflows: Read and Write', 'repos/{repo}/actions/workflows?per_page=1', 'Workflows: token has access', ''))¶
The PAT permission probes, one per
PatPermissionResultsfield.
- repomatic.github.token.probe_pat_permission(repo, probe)[source]¶
Run one PAT permission probe against repo.
- class repomatic.github.token.PatPermissionResults(administration, contents, issues, pull_requests, vulnerability_alerts, workflows)[source]¶
Bases:
objectResults of all PAT permission checks.
Each field holds a
(passed, message)tuple from the correspondingPAT_PERMISSION_PROBESrow.- administration: tuple[bool, str]¶
Result of the
administrationPAT_PERMISSION_PROBESrow.
- contents: tuple[bool, str]¶
Result of the
contentsPAT_PERMISSION_PROBESrow.
- issues: tuple[bool, str]¶
Result of the
issuesPAT_PERMISSION_PROBESrow.
- pull_requests: tuple[bool, str]¶
Result of the
pull_requestsPAT_PERMISSION_PROBESrow.
- vulnerability_alerts: tuple[bool, str]¶
Result of the
vulnerability_alertsPAT_PERMISSION_PROBESrow.
- workflows: tuple[bool, str]¶
Result of the
workflowsPAT_PERMISSION_PROBESrow.
- repomatic.github.token.check_all_pat_permissions(repo)[source]¶
Run all PAT permission checks and return structured results.
This is the single entry point for PAT permission validation. Both
lint-repoandsetup-guidecall this function so that adding a new permission check benefits all consumers automatically.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- Returns:
PatPermissionResultswith all check outcomes.
- repomatic.github.token.validate_gh_token_env()[source]¶
Check that a GitHub token environment variable is set.
Lookup order:
REPOMATIC_PAT>GH_TOKEN>GITHUB_TOKEN, matchingrun_gh_command.- Raises:
RuntimeError – If no variable is set.
- Return type:
- repomatic.github.token.validate_gh_api_access()[source]¶
Smoke-test the GitHub API and return parsed response.
Calls
GET https://api.github.com/rate_limitwith the token from environment variables.Does not go through
get_json(), which returns only the parsed body: this is the one caller that needs the response headers, sinceX-OAuth-Scopesis what tells a classic PAT apart from a fine-grained one. It still borrows that module’sDEFAULT_TIMEOUT, so a stalled connection fails the check instead of hanging the job that runs it.
- repomatic.github.token.validate_classic_pat_scope(required_scope)[source]¶
Validate that the GitHub token is a classic PAT with the required scope.
Checks:
A GitHub token environment variable is set.
GitHub API is reachable (smoke-test GET).
Token is a classic PAT (has
X-OAuth-Scopesheader).Token has the required scope.
- Parameters:
required_scope (
str) – The OAuth scope to require (e.g."notifications").- Return type:
- Returns:
The full list of scopes on the token.
- Raises:
RuntimeError – If any check fails.
repomatic.github.unsubscribe module¶
Unsubscribe from closed, inactive GitHub notification threads.
Processes notification threads in two phases:
REST notification threads — Fetches all Issue/PullRequest notification threads via
/notifications, inspects each for closed + stale status, and unsubscribes viaDELETE+PATCH.GraphQL threadless subscriptions — Searches for closed issues/PRs the user is involved in but that lack notification threads, and unsubscribes via the
updateSubscriptionmutation.
Requires the gh CLI to be installed and authenticated with a token that
has the notifications scope (classic PAT) or equivalent fine-grained
permissions.
- repomatic.github.unsubscribe.GRAPHQL_PAGE_SIZE = 25¶
Per-page count for GraphQL search results.
- repomatic.github.unsubscribe.NOTIFICATION_PAGE_SIZE = 50¶
Per-page count for REST
/notificationsresults.
- repomatic.github.unsubscribe.NOTIFICATION_SUBJECT_TYPES = frozenset({'Issue', 'PullRequest'})¶
Notification subject types to process.
- class repomatic.github.unsubscribe.DetailRow(action, html_url, number, repo, title, updated_at)[source]¶
Bases:
objectPer-item detail for the markdown report table.
- action: ReportAction¶
- class repomatic.github.unsubscribe.Phase1Result(batch_size=0, cutoff=None, newest_updated=None, oldest_updated=None, rows=<factory>, threads_failed=0, threads_inspected=0, threads_skipped_open=0, threads_skipped_recent=0, threads_skipped_unknown=0, threads_total=0, threads_unsubscribed=0)[source]¶
Bases:
objectAccumulated counts and details from REST notification phase.
- class repomatic.github.unsubscribe.Phase2Result(batch_size=0, cutoff=None, graphql_failed=0, graphql_not_subscribed=0, graphql_skipped_recent=0, graphql_total=0, graphql_unsubscribed=0, rows=<factory>, search_query='', skipped=False, skip_reason='')[source]¶
Bases:
objectAccumulated counts and details from GraphQL threadless phase.
- class repomatic.github.unsubscribe.UnsubscribeResult(dry_run=False, months=3, phase1=<factory>, phase2=<factory>)[source]¶
Bases:
objectAccumulated results from both unsubscribe phases.
- phase1: Phase1Result¶
- phase2: Phase2Result¶
- repomatic.github.unsubscribe.render_report(result)[source]¶
Render a markdown report from unsubscribe results.
Pure function that produces the same markdown structure as the downstream
unsubscribe.yamlworkflow’s$GITHUB_STEP_SUMMARY.- Parameters:
result (
UnsubscribeResult) – Structured results from both phases.- Return type:
- Returns:
Markdown report string.
- repomatic.github.unsubscribe.unsubscribe_threads(months, batch_size, dry_run)[source]¶
Unsubscribe from closed, inactive notification threads.
Runs two phases:
REST notification threads — Fetches notification threads, inspects each subject for closed + stale status, and unsubscribes.
GraphQL threadless subscriptions — Searches for closed issues/PRs the user is involved in and unsubscribes via mutation.
- Parameters:
- Return type:
- Returns:
Structured results from both phases.
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_agerather 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 bytests/test_workflows.py, and emitted into the downstreamrelease.yamlcaller 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 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. Seeclaude.md§ Cooldown on every install.- Return type:
- Returns:
The comment and
env:mapping, newline-terminated, ready to splice above a workflow’sjobs: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 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.
- class repomatic.github.workflow_sync.LintResult(message, is_issue, level=AnnotationLevel.WARNING)[source]¶
Bases:
objectResult of a single lint check.
- 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
onkey parses as the booleanTruewhile 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.
- 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.
Parses the workflow YAML and separates
workflow_callconfiguration from other triggers.- 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=None, 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.- Parameters:
source_paths (
list[str] |None) – Substituted in for the canonicalrepomatic/**glob in every workflow that references it.Nonedrops the glob without substitution.extra_paths (
list[str]) – Appended to every workflow’spaths:list (after source substitution andignore_pathsfiltering, before render). Skipped for workflows listed in workflow_paths.ignore_paths (
list[str]) – Removed from every workflow’spaths: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 completepaths:list for that workflow; the other knobs do not apply.
- 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_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 (
str|None) – Current content of the downstream file, when it already exists. Onlyrelease.yamlreads it, to carry over the extraneeds:edges of itsreleaselane; every other caller regenerates whole.
- 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_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, 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)[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.
- 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.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.
- 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.
- 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.check_has_workflow_dispatch(workflow_path)[source]¶
Check that a workflow has a
workflow_dispatchtrigger.- Parameters:
workflow_path (
Path) – Path to the workflow file.- Return type:
- 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:
- Return type:
- 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_calltriggers defined in the canonical workflow.- Parameters:
- Return type:
- 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 viasecrets: inherit.- Parameters:
- Return type:
- 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,ontriggers,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 (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.- Parameters:
- Return type:
- Returns:
Raw header text.
- Raises:
FileNotFoundError – If the workflow file is not bundled.
ValueError – If no
jobs:line is found.
- 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, runscheck_has_workflow_dispatch()to flag missing manual triggers.Thin callers are exempt from
check_has_workflow_dispatch()becausecheck_triggers_match()is authoritative: a thin caller mirrors its canonical workflow exactly, and some canonical workflows (e.g.,cancel-runs.yaml) intentionally lackworkflow_dispatch.