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.

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.

ERROR = 'error'
WARNING = 'warning'
NOTICE = 'notice'
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.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
Parameters:
  • name (str) – The output variable name.

  • value (str) – The multiline value.

Return type:

str

Returns:

Formatted string for $GITHUB_OUTPUT.

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 | Literal['error', 'warning', 'notice']) – The annotation level (error, warning, or notice).

  • 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.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 except current_run_id (the cancelling run itself) is cancelled. 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.

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', '*.whl', '*.tar.gz')

Glob patterns for dev release assets.

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

  • nwo (str) – Repository name-with-owner (e.g. user/repo).

  • 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, nwo, 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).

  • nwo (str) – Repository name-with-owner (e.g. user/repo).

  • 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(nwo, *, 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:
  • nwo (str) – Repository name-with-owner (e.g. user/repo).

  • 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_dev_release(version, nwo)[source]

Delete the dev pre-release and its tag from GitHub.

Silently succeeds if no dev release exists. This is used during real releases to clean up the dev pre-release for the version being released.

Parameters:
  • version (str) – Dev version string (e.g. 6.1.1.dev0).

  • nwo (str) – Repository name-with-owner (e.g. user/repo).

Return type:

None

repomatic.github.dev_release.delete_release_by_tag(tag, nwo)[source]

Delete a release and its tag from GitHub.

Silently succeeds if the release does not exist or cannot be deleted.

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

  • nwo (str) – Repository name-with-owner (e.g. user/repo).

Return type:

None

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. On a 401 from the primary token (either Bad credentials` from an expired or revoked PAT, or Requires authentication` from a GitHub-side auth incident or a fine-grained PAT scope quirk) it first retries with the **same** token after a short back-off (catching transient flaps that clear on their own), then with ``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.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.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. On a 401 from the primary token (Bad credentials` or Requires authentication`) the command 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. 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.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, and triaging GitHub issues via the gh CLI. Used by broken_links and potentially other modules that manage bot-created issues.

We need to manually manage the life-cycle of issues created in CI jobs 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.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.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.

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.

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

  • title (str) – Issue title.

Return type:

int

Returns:

The created issue number.

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.

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_file, 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.

Parameters:
  • has_issues (bool) – Whether issues were found that warrant an open issue.

  • body_file (Path) – Path to the file containing the issue body.

  • 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.matrix module

GitHub Actions job-matrix model: variations, includes, excludes, and their expansion into the JSON payload workflow strategy.matrix keys consume.

class repomatic.github.matrix.Matrix(*args, **kwargs)[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.

This Matrix behave like a dict and works everywhere a dict would. Only that it is immutable and based on FrozenDict. If you want to populate the matrix you have to use the following methods:

The implementation respects the order in which items were inserted. This provides a natural and visual sorting that should ease the inspection and debugging of large matrix.

matrix(ignore_includes=False, ignore_excludes=False)[source]

Returns a copy of the matrix.

The special 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

GitHub pull request lifecycle helpers.

Generic primitives for querying and closing pull requests via the gh wrapper. Used by workflows that need to reconcile automation-managed PRs (like the bump-version job) when their target state changes mid-flight.

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 title. Empty if no open PR exists on branch.

repomatic.github.pr.close_pr(number, comment, delete_branch=True)[source]

Close a pull request with a comment.

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_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, and peter-evans/create-pull-request pre-empts the rejection by hard-truncating the end of the body (main.ts: inputs.body.substring(0, 65536)), silently dropping whatever sits last: the refresh tip, the metadata block, and the attribution footer. build_pr_body() (PRs) and fit_issue_body() (issues) 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. 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. Lets downstream repos ship project-specific templates without forking repomatic.

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.

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_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.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.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.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_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_issue_body(body)[source]

Trim an oversized issue body, keeping the attribution footer.

The build_pr_body() counterpart for issue bodies rendered straight from a footer-carrying template (broken-links report, setup guide). The GitHub API rejects oversized issue bodies outright (gh issue create and gh issue edit 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 issue body, attribution footer included.

Return type:

str

Returns:

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

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.

class repomatic.github.release_sync.SyncAction(*values)[source]

Bases: Enum

Action taken (or to be taken) on a release body.

DRY_RUN = 'dry_run'
FAILED = 'failed'
SKIPPED = 'skipped'
UPDATED = 'updated'
class repomatic.github.release_sync.SyncRow(action, version, release_url)[source]

Bases: object

Per-release detail for the markdown report table.

action: SyncAction
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.uv release-notes helper build on top of these reads.

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

Bases: NamedTuple

Full release metadata including its assets and visibility flags.

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

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.

repomatic.github.releases.get_github_releases(repo_url)[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).

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 “we don’t know.”

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.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.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.uv.fetch_release_notes() returns, so repomatic.uv.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, defaults are read from Metadata for owner and repository, and from GITHUB_EVENT_PATH for the author and issue/PR number.

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

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

Return type:

str | None

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

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

Return type:

int | None

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

Check if the current event is a pull request.

Return type:

bool

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.sponsor.add_sponsor_label(repo, number, label, is_pr=False)[source]

Add a label to an issue or PR.

Parameters:
  • repo (str) – The repository in “owner/repo” format.

  • number (int) – The issue or PR number.

  • label (str) – The label to add.

  • is_pr (bool) – True if this is a PR, False for an issue.

Return type:

bool

Returns:

True if label was added successfully, 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.

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.

  • Metadata — Required for all fine-grained token API operations.

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, ...] = (('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(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 check_pat_* function.

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.

all_passed()[source]

Return True when every executed check passed.

Return type:

bool

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.

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.

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.ItemAction(*values)[source]

Bases: Enum

Action taken (or to be taken) on a notification item.

DRY_RUN = 'dry_run'
FAILED = 'failed'
UNSUBSCRIBED = 'unsubscribed'
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: ItemAction
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.

See WorkflowFormat for available output formats and their behavior.

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.

class repomatic.github.workflow_sync.WorkflowFormat(*values)[source]

Bases: StrEnum

Output format for generated workflow files.

FULL_COPY = 'full-copy'

Verbatim copy of the canonical workflow file.

Creates or overwrites the target with the full upstream content. Useful for workflows that need no downstream customization.

HEADER_ONLY = 'header-only'

Sync only the header (name, on, concurrency) from upstream.

Replaces everything before the jobs: line in an existing downstream file with the canonical header. The downstream jobs: section is preserved. Requires the target file to already exist; does not create new files.

Create a symbolic link to the canonical workflow file.

Creates or overwrites the target as a symlink pointing to the upstream workflow in the bundled data directory.

THIN_CALLER = 'thin-caller'

Generate a minimal caller that delegates to the reusable upstream workflow.

Creates or overwrites the target with a lightweight workflow containing only name, on triggers, and a jobs: section that calls the upstream workflow via workflow_call. Only works for reusable workflows (those with a workflow_call trigger).

When the target file already exists and contains extra jobs beyond the managed caller job, those jobs are preserved and appended after the regenerated content.

default_names()[source]

The workflow set a bare create or sync targets in this format.

Thin callers cover every reusable workflow, header-only syncs cover the non-reusable ones, and the copy modes cover everything.

Return type:

tuple[str, ...]

write_workflow(filename, target, *, repo, version, spec, commit_sha)[source]

Write target in this format from the canonical filename.

Benign skips (a non-reusable workflow in thin-caller mode, a missing downstream file in header-only mode) log a warning and count as success; failures log an error.

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

  • target (Path) – Destination path to write.

  • repo (str) – Upstream repository for thin-caller uses: refs.

  • version (str) – Version reference for thin-caller uses: refs.

  • spec (PathsSpec) – Paths-adaptation spec for thin callers and headers.

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

Return type:

bool

Returns:

False when the file could not be written, True otherwise.

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.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)[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. :return: Complete YAML content for the thin caller workflow. :raises ValueError: If the workflow does not support workflow_call.

Return type:

str

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.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.check_concurrency_match(workflow_path, canonical_filename)[source]

Check that a thin caller’s concurrency block matches the canonical workflow.

Compares parsed concurrency dicts so formatting differences are ignored.

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

repomatic.github.workflow_sync.generate_workflows(names, output_format, version, repo, output_dir, overwrite, commit_sha=None, paths_spec=None)[source]

Generate workflow files in the specified format.

Shared logic for the create and sync subcommands.

Parameters:
  • names (tuple[str, ...]) – Workflow filenames to generate. Empty tuple means all.

  • output_format (WorkflowFormat) – See WorkflowFormat for available formats.

  • version (str) – Version reference for thin callers.

  • repo (str) – Upstream repository.

  • output_dir (Path) – Directory to write files to.

  • overwrite (bool) – Whether to overwrite existing files.

  • commit_sha (str | None) – Full 40-character commit SHA for SHA-pinned uses: references. Passed through to generate_thin_caller().

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

Return type:

int

Returns:

Exit code (0 for success, 1 for errors).