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 before SHA 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_OUTPUT value, 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 a steps.*.outputs.* expression, and action inputs, which the runner exports as INPUT_*. Linux caps a single argv/envp string at MAX_ARG_STRLEN, 32 pages, so a value past that makes the runner’s execve() of /usr/bin/bash fail with E2BIG before 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 by repomatic.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: StrEnum

Workflow events that cause a workflow to run.

List of events.

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: Enum

Annotation 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: Enum

What 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.

Parameters:

workflow_ref (str | None) – The full workflow reference, e.g. owner/repo/.github/workflows/name.yaml@refs/heads/branch.

Return type:

str

Returns:

The workflow filename (e.g. name.yaml), or an empty string if the reference is empty or malformed.

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_NNNNNNNNN where N is a digit, producing a 9-digit random suffix.

Return type:

str

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

Parameters:
  • text (str) – Content to trim.

  • budget (int) – Available room, in measure’s unit.

  • measure (Callable[[str], int]) – Returns the size of one line in that unit.

Return type:

str

Returns:

The kept lines, right-stripped; empty when nothing fits.

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.

Parameters:
  • text (str) – Content to trim.

  • budget (int) – Available room, in UTF-8 bytes.

Return type:

str

Returns:

The kept lines, right-stripped; empty when nothing fits.

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_BYTES are trimmed to fit, so a step reading this output cannot be killed by E2BIG before it starts.

Parameters:
  • name (str) – The output variable name.

  • value (str) – The multiline value.

Return type:

str

Returns:

Formatted string for $GITHUB_OUTPUT.

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_TEMP where 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.

Parameters:
  • name (str) – The step output name, which the filename carries so a spilled report is recognisable in a temporary directory.

  • value (str) – The content to write.

Return type:

Path

Returns:

Path of the file holding value.

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 to repomatic.github.pr_body.build_pr_body(), which is the layer that knows GitHub’s own body limit and marks the cut for the reader.

Parameters:
  • name (str) – The output variable name, before the _file suffix.

  • value (str) – The content to hand over.

Return type:

str

Returns:

Formatted string for $GITHUB_OUTPUT.

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-actions the body is spilled to a file and the step output named <key>_file carries its path, for $GITHUB_OUTPUT consumption.

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

Parameters:
  • body (str) – The markdown report.

  • output (Path | None) – The --output path (None to skip, - for stdout).

  • output_format (str) – markdown or github-actions.

  • key (str) – The step output variable name, before the _file suffix, for the github-actions format.

Return type:

None

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>_FILE holds 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.

Parameters:

name (str) – The environment variable name, before the _FILE suffix.

Return type:

str

Returns:

The value, empty when neither variable is set.

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:

None

repomatic.github.actions.get_github_event() dict[str, Any][source]

Load the GitHub event payload from GITHUB_EVENT_PATH.

Return type:

dict[str, Any]

Returns:

The parsed event payload, or empty dict if not available.

repomatic.github.actions.get_event_pull_request()[source]

Return the event payload’s pull_request node, empty when absent.

Truthiness, not key presence, is the test every reader below shares. A payload carrying pull_request as an empty object has no PR to act on, and it has to read that way to is_pull_request() as well as to the default lookups: testing "pull_request" in event here (as this code once did) let the two disagree, so is_pull_request reported a pull request while get_default_number() fell through to the issue branch.

Return type:

dict[str, Any]

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.

Return type:

dict[str, Any]

Returns:

The subject node, or an empty dict when the event carries neither.

repomatic.github.actions.get_default_author()[source]

Get the issue/PR author from the GitHub event payload.

Return type:

str | None

repomatic.github.actions.get_default_number()[source]

Get the issue/PR number from the GitHub event payload.

Return type:

int | None

repomatic.github.actions.is_pull_request()[source]

Check if the current event is a pull request.

Return type:

bool

repomatic.github.actions.cancel_superseded_runs(branch, current_run_id)[source]

Cancel the in-progress and queued workflow runs of branch.

Backs the cancel-runs command, fired when a pull request closes: GitHub’s concurrency mechanism 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 the gh CLI from GH_REPO or the checkout, matching every other gh api call.

Caution

The release guard is what makes this safe to point at a default branch. Every workflow’s cancel-in-progress gate 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).

Parameters:
  • branch (str) – Head branch whose runs to cancel.

  • current_run_id (str) – Run ID to spare (the caller’s own run).

Return type:

int

Returns:

Number of runs cancelled.

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_SUFFIXES for the compiled binaries, so a dev pre-release carries exactly the artifacts the release workflow downloads and scan-virustotal submits, and PYTHON_DIST_SUFFIXES for 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_dir is provided, existing assets are deleted and new ones uploaded via upload_release_assets().

Parameters:
  • changelog_path (Path) – Path to changelog.md.

  • version (str) – Current version string (e.g. 6.1.1.dev0).

  • repository (str) – GitHub repository in owner/name form.

  • dry_run (bool) – If True, report without making changes.

  • asset_dir (Path | None) – Directory containing assets to upload. If None, no asset upload is performed.

Return type:

bool

Returns:

True if the release was synced (or would be in dry-run), False if 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_dir for files matching DEV_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.

Parameters:
  • tag (str) – Git tag name (e.g. v6.1.1.dev0).

  • repository (str) – GitHub repository in owner/name form.

  • asset_dir (Path) – Directory containing assets to upload.

Return type:

list[Path]

Returns:

List of uploaded file paths.

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, except keep_tag which 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.

Parameters:
  • repository (str) – GitHub repository in owner/name form.

  • keep_tag (str | None) – Tag to preserve (e.g. v6.2.0.dev0). If None, all dev releases are deleted.

Return type:

None

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.

Parameters:
  • tag (str) – Git tag name (e.g. v6.1.1.dev0).

  • repository (str) – GitHub repository in owner/name form.

Return type:

bool

Returns:

True when the release was deleted, False when it did not exist or could not be removed.

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.yaml emits ubuntu-26.04 / py3.10 while the release engine emits workflow / 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 conclusion is success while a continue-on-error probe inside it crashed, and its status still reads queued while a dozen of its jobs have already finished. Neither answers “is anything broken”.

  • A run that failed around its jobs. A failure conclusion with no failed job is a workflow-level error: an invalid strategy.matrix expression, 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.md on 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-status table.

class repomatic.github.ci_status.JobStatus(name, status, conclusion)[source]

Bases: object

One job of one workflow run.

name: str

The job’s name, glyph included.

status: str

queued, in_progress or completed.

conclusion: str

success, failure, cancelled, skipped, or empty while running.

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.yaml leads 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.

property failed: bool

Whether this job reached a failing conclusion.

property running: bool

Whether this job has yet to reach a terminal state.

class repomatic.github.ci_status.RunStatus(workflow, run_id, head_sha, status, conclusion, jobs=())[source]

Bases: object

The latest run of one workflow on one branch.

workflow: str

Workflow name, as GitHub reports it.

run_id: int

Numeric run ID, for gh run view.

head_sha: str

Commit the run was created for.

status: str

The run’s own status. Lags its jobs, so it never gates anything here.

conclusion: str

The run’s own conclusion, empty while it is still going.

jobs: tuple[JobStatus, ...] = ()

Every job of the run.

property failed_required: tuple[JobStatus, ...]

Failing jobs that gate a merge.

property failed_probes: tuple[JobStatus, ...]

Failing jobs allowed to fail.

property running_jobs: tuple[JobStatus, ...]

Jobs that have not settled yet.

property workflow_level_failure: bool

Whether the run failed around its jobs rather than inside one.

No job log explains this one: read the run’s error annotations and fix the workflow itself.

property blocking: bool

Whether this run holds up a merge.

property verdict: str

One-phrase outcome, for the table’s last column.

class repomatic.github.ci_status.CIStatus(branch, runs=<factory>)[source]

Bases: object

Every monitored workflow’s latest run on a branch.

branch: str

Branch the runs were read from.

runs: list[RunStatus]

One entry per workflow that has a run, newest first.

property blocking: list[RunStatus]

Runs holding up a merge.

property settled: bool

Whether every run reached a terminal state.

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.

Parameters:

workflow_dir (Path) – Directory holding the workflow files.

Return type:

list[str]

Returns:

Workflow filenames, sorted.

repomatic.github.ci_status.latest_run(workflow, branch)[source]

Read a workflow’s most recent run on branch, jobs included.

Parameters:
  • workflow (str) – Workflow filename, like tests.yaml.

  • branch (str) – Branch to read runs from.

Return type:

RunStatus | None

Returns:

The run, or None when 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.ci_status.read_ci_status(workflows, branch)[source]

Read the latest run of each workflow on branch.

Parameters:
  • workflows (Iterable[str]) – Workflow filenames to read.

  • branch (str) – Branch to read runs from.

Return type:

CIStatus

Returns:

The collected status.

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 gh binary every call in this package shells out to.

Prefers the registry-pinned build over whatever $PATH offers, which is the rule claude.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 $PATH carries none of the three and hands each runner image (and each developer laptop) a different gh.

Caution

Falls back to bare gh on $PATH when the registry build cannot be obtained. Unlike a formatter, gh is 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 usable gh, 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:

str

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:

str

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 Accept media type and the authentication scheme. Bearer is 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 only REPOMATIC_PAT gets authenticated reads here too, not just through the gh CLI.

Return type:

dict[str, str]

Returns:

Request headers, with Authorization present only when a token is set.

repomatic.github.gh.run_gh_command(args)[source]

Run a gh CLI command and return stdout.

Token priority: REPOMATIC_PAT > GH_TOKEN > GITHUB_TOKEN. The gh CLI does not recognize REPOMATIC_PAT, so when set it is injected as GH_TOKEN. A Requires authentication 401 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 with GITHUB_TOKEN if 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 raised RuntimeError carries a githubstatus.com annotation when an incident is active.

Parameters:

args (list[str]) – Command arguments to pass to gh.

Return type:

str

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} create output.

gh issue create and gh pr create both print the new thread’s URL last, in the form https://github.com/owner/repo/{issues,pull}/123. Read the last line rather than the whole output: gh prepends 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.

Parameters:
  • output (str) – The raw gh ... create standard output.

  • kind (str) – The thread kind for the error message, issue or pr.

Return type:

tuple[int, str]

Returns:

The (number, url) pair of the created thread.

Raises:

RuntimeError – When the output carries no parsable thread URL.

repomatic.github.gh.gh_api_json(args)[source]

Run a gh command expected to emit JSON, and parse it.

The two ways a JSON-producing gh call can fail are indistinguishable to a caller that just wants the payload: the command may not run at all (network, auth, a 404 on an endpoint the repository has not enabled) or it may return something that is not JSON. Both collapse to None here, 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_repo check 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 using run_gh_command() and handle RuntimeError themselves.

Parameters:

args (Sequence[str]) – Command arguments to pass to gh.

Return type:

Any | None

Returns:

The parsed JSON payload, or None when the command failed or its output did not parse.

repomatic.github.gh.gh_graphql(query, **variables)[source]

Run a one-shot GraphQL query through gh, and return its data envelope.

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 --field reads 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:
  • query (str) – The GraphQL query string.

  • variables (str) – Query variables, all passed as strings.

Return type:

Any

Returns:

The response’s data object, unwrapped.

Raises:

RuntimeError – When the gh invocation fails (see run_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 graphql pagination loop: run the query, walk the response to the connection object, yield each node, then follow pageInfo.hasNextPage/endCursor until the connection is exhausted. The query must declare a $cursor: String variable and pass it as after: $cursor, and its connection must select pageInfo { hasNextPage endCursor }.

Null nodes (which GitHub’s search connection can emit) are skipped.

Parameters:
  • query (str) – The GraphQL query string.

  • connection_path (Sequence[str]) – Keys from the response’s data object 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 controls first: 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. None means every node in the connection.

Yields:

Each node dict, in API order.

Raises:

RuntimeError – When a gh invocation fails (see run_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 labels repomatic/data/labels.toml declares, 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-threads used, 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 way gh issue lock --reason wants it. resolved is what dessant/lock-threads defaulted 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-threads table.

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-by choices 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:
  • repository (str) – GitHub repository in owner/name form.

  • number (int) – The issue or pull request number.

  • labels (Sequence[str]) – Labels to add. A no-op when empty.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

bool

Returns:

Whether the labels were applied. False on 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-prs query covers both and isPullRequest sorts them afterwards. Halving the round-trips matters less than the ordering it buys: results come back newest-first across both kinds, so a limit that truncates cuts the same slice from either.

Note

The is:unlocked half 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-thread locked re-check before writing.

Parameters:
  • repository (str) – GitHub repository in owner/name form.

  • inactive_days (int) – Days without an update before a closed thread qualifies.

  • limit (int) – Maximum number of threads to return.

Return type:

list[dict[str, Any]]

Returns:

Search result dicts carrying number, title, url, isPullRequest, labels and updatedAt, 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 in owner/name form.

  • 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:

None

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_labels defaults to BOT_ISSUE_LABEL because the issues manage_issue_lifecycle() maintains are designed to be reopened when their condition recurs, and GitHub refuses addComment on a locked conversation. Locking one turns the next reopen into a failed job, which is the hole run_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, which gh search parses 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 in owner/name form.

  • 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 to gh {issue,pr} lock --reason.

  • dry_run (bool) – Report what would be locked without writing anything.

Return type:

list[tuple[str, str, str, str]]

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 --author filter is applied. When REPOMATIC_PAT is configured, gh authenticates as the token owner (not github-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.

Parameters:

title (str) – If provided, only return issues whose title matches exactly.

Return type:

list[dict[str, Any]]

Returns:

List of issue dicts with number, title, createdAt, and state.

repomatic.github.issue.unlock_thread(number, *, is_pr=False)[source]

Unlock an issue or pull request’s conversation.

Parameters:
  • number (int) – The issue or pull request number to unlock.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

None

repomatic.github.issue.run_unlocking(args, number, *, is_pr=False)[source]

Run a commenting gh command, clearing a conversation lock if it blocks.

GitHub refuses addComment on 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 breaks gh pr close --comment on a locked pull request, which is close_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 carrying BOT_ISSUE_LABEL. This path remains for the locks it does not own: one applied by hand, or one left behind by the dessant/lock-threads action 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 --json and gh issue view --json both omit the locked field, so a pre-flight check would need a REST round-trip on every run to buy nothing.

Parameters:
  • args (Sequence[str]) – The gh command arguments to run.

  • number (int) – The thread number the command targets, used to unlock.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

str

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.close_issue(number, comment)[source]

Close an issue with a comment.

Parameters:
  • number (int) – The issue number to close.

  • comment (str) – The comment to add when closing.

Return type:

None

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

Parameters:
  • number (int) – The issue number to reopen.

  • comment (str) – Optional comment to add when reopening.

Return type:

None

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 (see fit_github_body()).

  • labels (list[str]) – List of labels to apply.

  • title (str) – Issue title.

Return type:

int

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 (see fit_github_body()).

Return type:

None

repomatic.github.issue.triage_issues(issues, title, needed)[source]

Triage issues matching a title for deduplication.

Parameters:
  • issues (list[dict]) – List of issue dicts from gh issue list –json number,title,createdAt,state`. The``state` field is optional for backward compatibility; when absent it defaults to "OPEN".

  • title (str) – Issue title to match against.

  • needed (bool) – Whether an issue with this title should exist.

Return type:

tuple[bool, int | None, str | None, set[int]]

Returns:

A tuple of (issue_needed, issue_to_update, issue_state, issues_to_close).

If needed is True, the most recent matching issue is kept as issue_to_update (with its issue_state) and all older matching issues are collected in issues_to_close. If needed is False, all open matching issues are placed in issues_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:

  1. Listing all issues (open and closed) via gh issue list.

  2. Triaging matching issues (keep newest if needed, close duplicates).

  3. Closing duplicate open issues via gh issue close.

  4. Creating, updating, or reopening the main issue via gh issue create, gh issue edit, or gh issue reopen.

When has_issues is True and 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; see run_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:

None

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-timings table.

class repomatic.github.job_timings.JobTiming(name, runner, seconds)[source]

Bases: object

One finished job, and how long it occupied a runner.

name: str

The job’s name, glyph and matrix cell included.

runner: str

Runner image matched out of name, or UNATTRIBUTED.

seconds: float

Whole-job wall-clock: the completedAt minus startedAt delta.

Deliberately not the compute time. This is what the run costs in billed minutes and in wall-clock waiting, and it is the figure that settled the ubuntu-slim question when the tool-pass figure could not.

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:

str

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:
  • workflow (str) – Workflow filename, like tests.yaml.

  • branch (str) – Branch whose runs to sample.

  • limit (int) – How many successful runs to sample. A median over several is what smooths a queue stall into noise.

Return type:

list[JobTiming]

Returns:

One JobTiming per finished job across the sampled runs.

class repomatic.github.job_timings.RunnerReport(runner, job_count, median_seconds, slowest_job, slowest_seconds)[source]

Bases: object

Aggregated timings for one runner image.

runner: str
job_count: int
median_seconds: float
slowest_job: str
slowest_seconds: float
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 from fetch_job_timings().

Return type:

list[RunnerReport]

Returns:

One RunnerReport per 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-by orders the rendered table lexicographically, so an unpadded 21s sorts between 1m37s and 2m03s and the default view reads as though a 21-second job were slower than a 97-second one. Fixed-width 00m21s makes 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:

str

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:
Return type:

str

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.matrix block.

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 entry key/value pairs absent from the matrix axes.

A non-empty result means an exclude directive 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 (see repomatic.metadata.Metadata.stale_test_matrix_excludes).

Return type:

dict[str, str]

class repomatic.github.matrix.Matrix[source]

Bases: object

A 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 metadata job

GitHub 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 metadata job 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 immutable FrozenDict for 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.

variations: dict[str, tuple[str, ...]]
include: tuple[dict[str, str], ...]
exclude: tuple[dict[str, str], ...]
matrix(ignore_includes=False, ignore_excludes=False)[source]

Returns a copy of the matrix.

The special include and excludes directives will be added by default. You can selectively ignore them by passing the corresponding boolean parameters.

Return type:

FrozenDict[str, tuple[str, ...] | tuple[dict[str, str], ...]]

add_variation(variation_id, values)[source]
Return type:

None

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:

None

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:

None

add_includes(*new_includes)[source]

Add one or more include special directives to the matrix.

Return type:

None

add_excludes(*new_excludes)[source]

Add one or more exclude special directives to the matrix.

Return type:

None

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:

None

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 include and exclude directives will be ignored by default.

You can selectively expand or restrict the resulting inventory of variations by passing the corresponding with_matrix, with_includes and with_excludes boolean filter parameters.

Return type:

dict[str, tuple[str, ...]]

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 include and exclude special directives.

Respects the order of variations and their values.

Return type:

Iterator[dict[str, str]]

solve(strict=False)[source]

Expand the matrix to explicit jobs, applying exclude then include.

Reproduces GitHub’s documented matrix algorithm:

  1. Build the cross-product of the base variations.

  2. Drop every combination matching an exclude directive. A directive matches when all of its keys equal the combination’s, so a partial directive removes a whole slice.

  3. Process include directives 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

include directives augment combinations from the base cross-product only, never jobs created by an earlier include. An excluded combination is resurrected solely when an include fully re-specifies it, so it merges into nothing and is appended: a partial include that augments surviving jobs does not bring excluded slices back. GitHub remains the authoritative expander, but this follows its documented rules so downstream full-include job lists (which matrix() serializes verbatim) match what GitHub would run.

Return type:

Iterator[dict[str, str]]

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 distinct row_axis value, one column per distinct col_axis value. Each cell holds the job’s cell_key value at that intersection (its state, by default), or missing when no job occupies it (an excluded combination).

Parameters:
  • row_axis (str) – Job key whose values become grid rows.

  • col_axis (str) – Job key whose values become grid columns.

  • cell_key (str) – Job key whose value fills each cell.

  • missing (str) – Placeholder for an empty (row, col) intersection.

Return type:

tuple[tuple[str, ...], tuple[tuple[str, ...], ...]]

Returns:

A (col_values, rows) pair. col_values is the ordered tuple of column values (distinct col_axis values). Each entry in rows is (row_value, cell, …), with one cell per col_values entry.

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-include matrix.

When several jobs share one (row, col) intersection (a matrix carrying extra axes, such as a click-version variation), their distinct cell_key values are joined with ,. A matrix with only the os and python-version axes 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-request performed 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: StrEnum

What 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: NamedTuple

Outcome 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.

branch: str

The head branch the call targeted.

number: int | None

Pull-request number, when one was created, updated or closed.

url: str

Pull-request URL, populated only on creation.

repomatic.github.pr.list_open_prs_by_branch(branch)[source]

List open pull requests whose head branch matches branch.

Parameters:

branch (str) – The head branch name to filter on.

Return type:

list[dict[str, Any]]

Returns:

List of PR dicts with number and isDraft. Empty if no open PR exists on 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 --paginate rather 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.

Parameters:
  • number (int) – The pull request number.

  • repository (str) – Repository in owner/name form. Left to gh’s own resolution when empty.

Return type:

list[str]

Returns:

Changed paths, in the order GitHub returns them.

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 wedge upsert_pr()’s whole retire path.

Parameters:
  • number (int) – The PR number to close.

  • comment (str) – The comment to add when closing.

  • delete_branch (bool) – When True, also delete the head branch.

Return type:

None

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.

Parameters:
  • branch (str) – The head branch name to match.

  • comment (str) – The comment to add when closing each PR.

Return type:

list[int]

Returns:

The list of PR numbers that were closed.

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 of PrOperation.

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 HEAD is 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’s sync-deps job, 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 when HEAD is detached.

  • labels (Sequence[str]) – Labels to attach, best-effort.

  • 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 HEAD is detached and no base is given, or when base does not exist on remote.

Return type:

PrSyncResult

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 before repomatic.github.pr replaced 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) and fit_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, #issue refs, 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. Rewrites github.com URLs to redirect.github.com to prevent backlink cross-references on upstream issues.

Parameters:

text (str) – Raw markdown text from an external source.

Return type:

str

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 # comments in shell samples survive. Only ATX headings are rewritten: setext headings (underlined with === or ---), rare in release notes, pass through unchanged.

Parameters:
  • text (str) – Raw markdown text from an external source.

  • floor (int) – Target level (1-6) for the shallowest heading.

Return type:

str

Returns:

Markdown with headings demoted.

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 under repomatic.templates, cached for the process (see _load_bundled_template()). Tries {name}.md.noformat first, then {name}.md. The .md.noformat extension is used for templates whose string.Template placeholders confuse mdformat (e.g. $rerun_entry prefixed to a list line is parsed as literal text, breaking the list structure). See pr-metadata.md.noformat for the canonical example.

  • Path: read directly from the filesystem, never cached, so a downstream repo iterating on a project-specific template sees each edit.

Parameters:

name (str | Path) – Template name without extension, or a Path pointing to a template file.

Return type:

tuple[dict[str, object], str]

Returns:

A tuple of (frontmatter metadata dict, template body string).

Raises:

FileNotFoundError – If neither resource nor file exists.

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-footer attribution is appended once at the end if any of the templates wants it (i.e. does not have footer: false in its frontmatter).

Static templates (no $variable placeholders) are returned as-is. Dynamic templates use string.Template ($variable syntax) 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 the bump-version and prepare-release jobs rewrite __version__ with bump-my-version before this renders, so each of those bodies advertises the version its own PR produces (the minor or major bump 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 splitting pr-body back out of pr-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 runs uvx 'repomatic==X.Y.Z' and takes its version from the installed distribution.

Parameters:
  • names (str | Path) – One or more template names (without .md extension) or Path objects pointing to template files.

  • kwargs (str | None) – Variables to substitute into all templates.

Return type:

str

Returns:

The rendered markdown string.

repomatic.github.pr_body.render_title(name, **kwargs)[source]

Load and render a template’s PR title with variable substitution.

Parameters:
  • name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

  • kwargs (str | None) – Variables to substitute into the title.

Return type:

str

Returns:

The rendered title string, or an empty string when the template has no title field in its frontmatter.

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 title if no commit_message is defined, and to an empty string if neither is set (templates without a title or commit message render only their body).

Parameters:
  • name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

  • kwargs (str | None) – Variables to substitute into the commit message.

Return type:

str

Returns:

The rendered commit message string, or an empty string when the template defines neither commit_message nor title.

repomatic.github.pr_body.template_args(name)[source]

Return the list of required arguments for a template.

Parameters:

name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

Return type:

list[str]

Returns:

List of argument names from the frontmatter args field.

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.

Parameters:

name (str | Path) – Template name without extension, or a template file path.

Return type:

list[str]

Returns:

The labels, empty when the frontmatter declares none.

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 the footer: key.

Parameters:

name (str | Path) – Template name without extension, or a template file path.

Return type:

bool

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 the Documentation entry of the metadata block now that PR bodies have no description section.

Parameters:

name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

Return type:

str

Returns:

The URL from the frontmatter docs field, or an empty string.

repomatic.github.pr_body.template_stem(filename)[source]

Return a template’s name, shorn of its .md or .md.noformat extension.

The one place that knows how template filenames decompose: .md.noformat files are renamed .md files hidden from mdformat (see load_template()), so both extensions strip down to the same name. Callers derive a PR branch or a documentation label from a --template-file path with it, and get_template_names() names the bundled templates through it.

Parameters:

filename (str) – A template file’s basename.

Return type:

str

Returns:

The name with neither extension.

repomatic.github.pr_body.get_template_names()[source]

Discover all available template names from the templates package.

Return type:

list[str]

Returns:

Sorted list of template names (without .md extension).

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) – The Metadata instance to read CI context from.

  • docs_url (str) – Optional deep link to the job’s section of the hosted workflows reference, rendered as the leading Documentation entry. Comes from the PR template’s docs: frontmatter field (see template_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:

str

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_REF from md to build the workflow dispatch URL.

Parameters:

md (Metadata) – The Metadata instance to read CI context from.

Return type:

str

Returns:

A GitHub-flavored markdown [!TIP] blockquote, or an empty string if the workflow reference is unavailable.

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 release list 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}.dev0 draft through its html_url (drafts have no public tag URL). Omitted when no such draft is visible.

  • Full-changes review: links the v{previous}...main comparison. 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 a 1. marker so the surrounding lazily numbered list renumbers correctly however many steps survive.

Parameters:
  • md (Metadata) – CI context, read for the repository URL.

  • version (str) – The release version being prepared (e.g. 1.2.3).

Return type:

tuple[str, str]

Returns:

A (dev_release_review, changes_review) pair of list-item lines.

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_block already includes the attribution footer (appended automatically by render_template()); the refresh_tip comes pre-rendered from generate_refresh_tip() (empty to omit it).

Bodies over GITHUB_BODY_MAX_CHARS have 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 from generate_pr_metadata_block(), with footer.

  • refresh_tip (str) – Pre-rendered refresh-tip admonition, or empty.

Return type:

str

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 net upsert_pr() runs over an explicit --body that never went through build_pr_body(). The GitHub API rejects oversized bodies outright (gh issue create, gh issue edit and gh pr create all fail), so the content above the attribution footer is trimmed on line boundaries, with a caution admonition marking the cut.

Parameters:

body (str) – The rendered body, attribution footer included.

Return type:

str

Returns:

The body unchanged when it fits, else trimmed to fit.

repomatic.github.pr_body.temp_body_file(body)[source]

Materialize a rendered body as a temporary file, then remove it.

The gh CLI 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 / unlink envelope.

Return type:

Iterator[Path]

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-releases template. This allows the GitHub release body to include a different subset of elements than the release-notes template used for changelog.md entries.

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:
  • changelog (Changelog) – Parsed changelog instance.

  • version (str) – Version string (e.g. 1.2.3).

  • admonition_override (str | None) – If provided, replaces the availability_admonition from the changelog. Used by release_notes_with_admonition to inject a pre-computed admonition at release time.

Return type:

str

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: object

Per-release detail for the markdown report table.

action: ReportAction
version: str
release_url: str
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: object

Accumulated results from a release-notes sync run.

dry_run: bool = True
rows: list[SyncRow]
total: int = 0
in_sync: int = 0
drifted: int = 0
updated: int = 0
failed: int = 0
missing_changelog: int = 0
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 via gh release edit.

Parameters:
  • repo_url (str) – Repository URL (e.g. https://github.com/user/repo).

  • changelog_path (Path) – Path to changelog.md.

  • dry_run (bool) – If True, report without making changes.

Return type:

SyncResult

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:

str

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.

Parameters:

repo_url (str) – Repository URL (e.g. https://github.com/user/repo).

Return type:

tuple[str, str] | None

Returns:

An (owner, repo) pair, or None when the URL does not parse.

exception repomatic.github.releases.GitHubReleasesUnavailable[source]

Bases: RuntimeError

Raised 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: NamedTuple

Release metadata for a single version from GitHub.

Create new instance of GitHubRelease(date, body)

date: str

Publication date in YYYY-MM-DD format.

body: str

Release description body (markdown).

class repomatic.github.releases.ReleaseAsset(name: str, size: int, sha256: str, download_url: str)[source]

Bases: NamedTuple

A single downloadable asset attached to a GitHub release.

Create new instance of ReleaseAsset(name, size, sha256, download_url)

name: str

Asset filename.

size: int

Asset size in bytes.

sha256: str

SHA-256 hex digest from the API’s digest field.

Empty for assets uploaded before GitHub started recording digests (mid-2025), where the API returns digest: null.

download_url: str

Public browser 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: NamedTuple

Full release metadata including its assets and visibility flags.

Create new instance of ReleaseWithAssets(tag, date, draft, prerelease, assets, body, html_url)

tag: str

Raw tag name (e.g. v1.2.3).

date: str

Publication date in YYYY-MM-DD format.

draft: bool

True for draft releases, which are only visible to maintainers.

prerelease: bool

True for releases marked as pre-release.

assets: tuple[ReleaseAsset, ...]

Assets attached to the release, in API order.

body: str

Release notes body (markdown).

Carried so sync-binaries --backfill-records can 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 unguessable releases/tag/untagged-<hash> path exposed only in this field. The prepare-release PR body links the rolling dev pre-release through it (see dev_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 v prefix from tag names. Uses published_at (falling back to created_at) for the date.

Parameters:
  • repo_url (str) – Repository URL (e.g. https://github.com/user/repo).

  • force_refresh (bool) – Ignore any cached map and re-fetch. A cached map predating a release reports it as absent, so callers acting on an absence around release time should re-confirm live.

Return type:

dict[str, GitHubRelease]

Returns:

Dict mapping version strings to GitHubRelease tuples. Empty dict only when the repository genuinely has no releases (the API returned an empty page) or when repo_url does not parse to an owner/repo pair.

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 only v-prefixed tags (and strips the v), which drops tools whose release tags use another scheme (lychee’s lychee-v…, biome’s @biomejs/biome@…). sync-tool-versions and sync-action-pins need every tag, so the version can be extracted with a per-tool pattern.

Parameters:

repo_url (str) – Repository URL.

Return type:

dict[str, GitHubRelease]

Returns:

Dict mapping raw tag names to GitHubRelease tuples. Empty only when the repository has no releases or repo_url does not parse to an owner/repo pair.

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 is sync-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:

list[ReleaseWithAssets]

Returns:

One ReleaseWithAssets per 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 an owner/repo pair.

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 None for 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_url of the draft pre-release whose version shares version’s release segment (the rolling v{version}.dev0 draft). Drafts are visible only to authenticated maintainers, so an unauthenticated or token-less caller gets None here even when the draft exists.

  • Previous version: the highest final release (draft, pre-release, and .dev tags 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 the v{previous}...main comparison link.

Parameters:
  • repo_url (str) – Repository URL (e.g. https://github.com/user/repo).

  • version (str) – The release version being prepared (e.g. 1.2.3), with the .dev suffix already stripped.

Return type:

tuple[str | None, str | None]

Returns:

An (dev_release_url, previous_version) pair. Either element is None when 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.

Parameters:
  • repo_url (str) – Repository URL.

  • tag (str) – The tag name to resolve (e.g. v1.2.3).

Return type:

str | None

Returns:

The commit SHA, or None when the tag cannot be resolved (network error, missing tag, or unexpected payload).

repomatic.github.releases.extract_version(tag, tag_pattern)[source]

Extract a version from a GitHub release tag.

Parameters:
  • tag (str) – The raw tag name.

  • tag_pattern (str | None) – A regex with a version named group, or None to strip a leading v (the common vX.Y.Z scheme).

Return type:

str | None

Returns:

The version string, or None when tag_pattern does not match.

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 edit path 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:
  • tag (str) – Git tag name of the release (e.g. v1.2.3).

  • repository (str) – GitHub repository in owner/name form.

  • body (str) – The new release body text.

  • title (str) – When non-empty, also replace the release title.

Return type:

bool

Returns:

True when the edit landed, False when 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.

Parameters:
  • repo_url (str) – GitHub repository URL.

  • version (str) – The version string (e.g. 7.13.5).

Return type:

tuple[str, str]

Returns:

A tuple of (tag, body) where tag is the matched tag name and body is the release notes markdown. Both are empty strings if no release is found.

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 (or None for the vX.Y.Z scheme).

Return type:

dict[str, tuple[str, list[tuple[str, str]]]]

Returns:

A dict mapping names to (repo_url, versions) tuples, the same shape repomatic.dep_report.fetch_release_notes() returns, so repomatic.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.

Return type:

str | None

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 (user or organization), 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.

Parameters:

owner (str) – The GitHub username or organization name.

Return type:

frozenset[str]

Returns:

Frozenset of sponsor login names.

repomatic.github.sponsor.is_sponsor(owner, user)[source]

Check if a user is a sponsor of an owner.

Parameters:
  • owner (str) – The GitHub username or organization to check sponsorship for.

  • user (str) – The GitHub username to check if they are a sponsor.

Return type:

bool

Returns:

True if user is a sponsor of owner, False otherwise.

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 status object containing an indicator (none, minor, major, critical, maintenance) and a human-readable description.

class repomatic.github.status.GitHubStatus(indicator, description)[source]

Bases: object

Snapshot of the githubstatus.com summary.

Parameters:
  • indicator (str) – One of none, minor, major, critical, maintenance.

  • description (str) – Human-readable summary (like All Systems Operational or Partial System Outage).

indicator: str
description: str
property is_incident: bool

Return True when Statuspage reports anything other than healthy.

annotation()[source]

Render a one-line annotation suitable for appending to an error.

Returns an empty string when no incident is active, so callers can concatenate unconditionally.

Return type:

str

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 None when 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:

GitHubStatus | None

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 on None.

Return type:

str

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 triggers on.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 that unittest.mock.patch can replace the module attribute after import and the decorator sees the mock at call time.

Return type:

Callable[[Callable[..., Any]], Callable[..., Any]]

class repomatic.github.token.PatProbe(field: str, permission: str, endpoint: str, success: str, not_found: str = '')[source]

Bases: NamedTuple

One fine-grained PAT permission probe.

A read-only API call whose HTTP 403 unambiguously identifies the missing fine-grained permission. Rows live in PAT_PERMISSION_PROBES.

Create new instance of PatProbe(field, permission, endpoint, success, not_found)

field: str

The PatPermissionResults field receiving this probe’s result.

permission: str

Fine-grained permission label, as GitHub’s PAT form spells it.

endpoint: str

Read-only probe endpoint, with a {repo} placeholder.

success: str

Message reported when the probe returns a 2xx.

not_found: str

Message template (with {repo}) when the probe 404s.

Empty for probes whose 404 carries no special meaning: those fall through to the generic failure classification.

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 PatPermissionResults field.

repomatic.github.token.probe_pat_permission(repo, probe)[source]

Run one PAT permission probe against repo.

Parameters:
  • repo (str) – Repository in ‘owner/repo’ format.

  • probe (PatProbe) – The PatProbe row to execute.

Return type:

tuple[bool, str]

Returns:

Tuple of (passed, message).

class repomatic.github.token.PatPermissionResults(administration, contents, issues, pull_requests, vulnerability_alerts, workflows)[source]

Bases: object

Results of all PAT permission checks.

Each field holds a (passed, message) tuple from the corresponding PAT_PERMISSION_PROBES row.

administration: tuple[bool, str]

Result of the administration PAT_PERMISSION_PROBES row.

contents: tuple[bool, str]

Result of the contents PAT_PERMISSION_PROBES row.

issues: tuple[bool, str]

Result of the issues PAT_PERMISSION_PROBES row.

pull_requests: tuple[bool, str]

Result of the pull_requests PAT_PERMISSION_PROBES row.

vulnerability_alerts: tuple[bool, str]

Result of the vulnerability_alerts PAT_PERMISSION_PROBES row.

workflows: tuple[bool, str]

Result of the workflows PAT_PERMISSION_PROBES row.

failed()[source]

Return (field_name, message) pairs for each failed check.

Return type:

list[tuple[str, str]]

iter_results()[source]

Yield all non-None (passed, message) tuples.

Return type:

list[tuple[bool, str]]

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-repo and setup-guide call this function so that adding a new permission check benefits all consumers automatically.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

PatPermissionResults

Returns:

PatPermissionResults with 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, matching run_gh_command.

Raises:

RuntimeError – If no variable is set.

Return type:

None

repomatic.github.token.validate_gh_api_access()[source]

Smoke-test the GitHub API and return parsed response.

Calls GET https://api.github.com/rate_limit with 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, since X-OAuth-Scopes is what tells a classic PAT apart from a fine-grained one. It still borrows that module’s DEFAULT_TIMEOUT, so a stalled connection fails the check instead of hanging the job that runs it.

Return type:

tuple[int, dict[str, str], str]

Returns:

Tuple of (status_code, headers, body).

Raises:

RuntimeError – If the API returns a 4xx/5xx status, or cannot be reached at all (network error, timeout).

repomatic.github.token.validate_classic_pat_scope(required_scope)[source]

Validate that the GitHub token is a classic PAT with the required scope.

Checks:

  1. A GitHub token environment variable is set.

  2. GitHub API is reachable (smoke-test GET).

  3. Token is a classic PAT (has X-OAuth-Scopes header).

  4. Token has the required scope.

Parameters:

required_scope (str) – The OAuth scope to require (e.g. "notifications").

Return type:

list[str]

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:

  1. REST notification threads — Fetches all Issue/PullRequest notification threads via /notifications, inspects each for closed + stale status, and unsubscribes via DELETE + PATCH.

  2. GraphQL threadless subscriptions — Searches for closed issues/PRs the user is involved in but that lack notification threads, and unsubscribes via the updateSubscription mutation.

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 /notifications results.

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: object

Per-item detail for the markdown report table.

action: ReportAction
html_url: str
number: int | None
repo: str
title: str
updated_at: datetime | None
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: object

Accumulated counts and details from REST notification phase.

batch_size: int = 0
cutoff: datetime | None = None
newest_updated: datetime | None = None
oldest_updated: datetime | None = None
rows: list[DetailRow]
threads_failed: int = 0
threads_inspected: int = 0
threads_skipped_open: int = 0
threads_skipped_recent: int = 0
threads_skipped_unknown: int = 0
threads_total: int = 0
threads_unsubscribed: int = 0
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: object

Accumulated counts and details from GraphQL threadless phase.

batch_size: int = 0
cutoff: datetime | None = None
graphql_failed: int = 0
graphql_not_subscribed: int = 0
graphql_skipped_recent: int = 0
graphql_total: int = 0
graphql_unsubscribed: int = 0
rows: list[DetailRow]
search_query: str = ''
skipped: bool = False
skip_reason: str = ''
class repomatic.github.unsubscribe.UnsubscribeResult(dry_run=False, months=3, phase1=<factory>, phase2=<factory>)[source]

Bases: object

Accumulated results from both unsubscribe phases.

dry_run: bool = False
months: int = 3
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.yaml workflow’s $GITHUB_STEP_SUMMARY.

Parameters:

result (UnsubscribeResult) – Structured results from both phases.

Return type:

str

Returns:

Markdown report string.

repomatic.github.unsubscribe.unsubscribe_threads(months, batch_size, dry_run)[source]

Unsubscribe from closed, inactive notification threads.

Runs two phases:

  1. REST notification threads — Fetches notification threads, inspects each subject for closed + stale status, and unsubscribes.

  2. GraphQL threadless subscriptions — Searches for closed issues/PRs the user is involved in and unsubscribes via mutation.

Parameters:
  • months (int) – Inactivity threshold in months.

  • batch_size (int) – Maximum threads/items to process per phase.

  • dry_run (bool) – If True, report what would be done without acting.

Return type:

UnsubscribeResult

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_age rather than written by hand, so the literal in the YAML has exactly one source. The same text is asserted verbatim against every checked-in workflow by tests/test_workflows.py, and emitted into the downstream release.yaml caller by _generate_release_caller().

Caution

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

Note

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

Return type:

str

Returns:

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

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

Relative strength of the permissions: levels GitHub accepts.

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

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

Default version reference for upstream workflows.

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

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

Bases: object

Parsed trigger information from a canonical workflow.

name: str

Workflow display name from the name: field.

filename: str

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

non_call_triggers: dict[str, Any]

All triggers except workflow_call, preserving their configuration.

call_inputs: dict[str, Any]

Inputs defined under workflow_call.inputs.

call_secrets: dict[str, Any]

Secrets defined under workflow_call.secrets.

has_workflow_call: bool

Whether the workflow defines a workflow_call trigger.

concurrency: dict[str, Any] | None

Parsed concurrency configuration, or None if absent.

raw_concurrency: str | None

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

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

Bases: object

Result of a single lint check.

message: str

Human-readable description of the finding.

is_issue: bool

Whether this result represents a problem.

level: AnnotationLevel = 'warning'

Severity level for GitHub Actions annotations.

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

Extract a parsed workflow’s on: mapping.

Note

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

Parameters:

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

Return type:

dict[str, Any]

Returns:

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

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

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

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

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

Parameters:

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

Return type:

dict[str, str]

Returns:

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

Raises:

FileNotFoundError – If the workflow file is not bundled.

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

Extract trigger information from a bundled canonical workflow.

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

Parameters:

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

Return type:

WorkflowTriggerInfo

Returns:

Parsed trigger information.

Raises:

FileNotFoundError – If the workflow file is not bundled.

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

Bases: object

Bundle of downstream paths: adaptation knobs.

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

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

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

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

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

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

Generate a thin caller workflow for a reusable canonical workflow.

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

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

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

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

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

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

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

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

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

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

Return type:

str

Returns:

Complete YAML content for the thin caller workflow.

Raises:

ValueError – If the workflow does not support workflow_call.

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

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

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

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

Render the complete downstream content of target, extras included.

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

Caution

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

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

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

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

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

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

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

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

Return type:

tuple[str, str | None]

Returns:

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

Raises:

ValueError – If filename declares no workflow_call trigger.

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

Every job the generated downstream release.yaml defines.

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

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

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

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

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

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

Return type:

str | None

Returns:

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

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

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

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

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

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

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

Return type:

str

Returns:

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

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

Whether an extras fragment holds actual job definitions.

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

Return type:

bool

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

Check that a workflow has a workflow_dispatch trigger.

Parameters:

workflow_path (Path) – Path to the workflow file.

Return type:

LintResult

Returns:

Lint result.

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

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

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

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

Return type:

LintResult

Returns:

Lint result.

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

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

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

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

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

Return type:

LintResult

Returns:

Lint result.

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

Check that a thin caller passes all required secrets explicitly.

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

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

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

Return type:

LintResult

Returns:

Lint result.

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

Return the raw header of a canonical workflow.

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

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

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

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

Return type:

str

Returns:

Raw header text.

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

Lint all workflow files in a directory.

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

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

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

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

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

Return type:

int

Returns:

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