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.