Security¶
Supply chain security¶
repomatic implements most of the practices described in Astral’s Open Source Security at Astral post, baked into a drop-in setup that any maintainer can inherit by pointing their workflows at the reusable callers.
Astral practice |
How |
|---|---|
Ban dangerous triggers ( |
The lint-workflow-security job runs |
Minimal workflow permissions |
|
Pinned actions |
All |
No force-pushes to |
|
Immutable release tags |
|
Dependency cooldowns |
Renovate stabilization windows ( |
Trusted Publishing |
PyPI uploads via OIDC with no long-lived token. The |
Cryptographic attestations |
Every binary and wheel is attested to the workflow run that built it via |
Checksums in installer scripts |
The |
Fork PR approval policy |
|
Warning
Known gap: multi-person release approval. Astral gates releases behind a dedicated GitHub deployment environment with required reviewers, so that a single compromised account cannot publish. repomatic does not enforce this, but if the repository has multiple maintainers, I recommend adding an environment: release key to the caller-side publish-pypi job (and to the upstream create-release job, if the caller exposes it) in the downstream workflow and configuring required reviewers on that environment in repo settings.
Important
One-time PyPI Trusted Publisher setup. Each downstream repository must register a Trusted Publisher entry on PyPI for its own caller workflow. The publisher config matches against the OIDC job_workflow_ref claim, which names the downstream’s workflow file (typically .github/workflows/release.yaml). Without this registration, the first PyPI upload after migration fails cleanly with a publisher mismatch error. See the PyPI Trusted Publishers documentation for the registration steps.
Third-party action minimization¶
Every third-party GitHub Action executes with access to GITHUB_TOKEN and repository secrets. Each action is a trust delegation: you depend on the maintainer’s security practices, their CI pipeline, and their transitive dependencies. A compromised action can steal secrets, inject code into builds, or tamper with releases.
repomatic has systematically eliminated 18 third-party actions since late 2025, replacing them with internal CLI commands, SHA-256-verified binary downloads, and runner built-in tools:
Removed action |
Replacement |
Strategy |
|---|---|---|
|
|
Internal CLI |
|
|
Internal CLI |
|
|
Internal CLI |
|
|
Internal CLI |
|
|
Direct binary + SHA-256 |
|
|
Direct binary + SHA-256 |
|
|
Direct binary + SHA-256 |
|
|
Direct binary + SHA-256 |
|
|
Direct binary + SHA-256 |
|
Direct |
Direct binary + SHA-256 |
|
|
Runner built-in |
|
Bash + |
Runner built-in |
|
Runner built-in Rust |
Runner built-in |
|
|
Consolidated |
|
|
First-party replacement |
|
|
Pinned CLI |
|
|
Pinned CLI |
|
Explicit |
Removed entirely |
The remaining third-party actions (5 of 14 total) are:
Action |
Purpose |
|---|---|
|
Core toolchain: installs |
|
Creates autofix PRs |
|
Locks inactive issues |
|
Dependency updates |
|
Debug diagnostics (no secrets access) |
Replacement strategies, ordered from most to least isolated:
Internal CLI: the operation runs inside
repomaticPython code with no external process.Direct binary download: checksummed binary fetched from a GitHub release URL, no action code path involved.
Runner built-in: uses tools pre-installed on the GitHub Actions runner (
gh, Rust toolchain).First-party replacement: swaps a community action for an official
actions/*equivalent maintained by GitHub.
Ruff consolidation¶
Eight separate Python linters and formatters have been absorbed into ruff, eliminating eight runtime or dev dependencies:
Removed tool |
What it did |
Replaced |
|---|---|---|
|
Static analysis and linting |
Feb 2023 |
|
Docstring convention enforcement |
Feb 2023 |
|
Unused import removal |
Feb 2023 |
|
Python syntax modernization |
Feb 2023 |
|
Import sorting |
Feb 2023 |
|
Code formatting |
Sep 2023 |
|
Docstring formatting |
Jan 2024 |
|
Python formatting in Markdown code blocks |
Feb 2026 |
The mdformat-black plugin was also swapped for mdformat-ruff (Aug 2024): same dependency count, but aligns the Markdown pipeline with ruff’s formatting rules.
autopep8 is the only legacy formatter still in use: it handles long-line comment wrapping that ruff does not yet cover.
uv consolidation¶
Five separate packaging and install tools have been absorbed into uv, which now handles dependency management, builds, publishing, auditing, and Python version installation:
Removed tool |
What it did |
Replaced |
|---|---|---|
|
Dependency management, lock files, virtual environments |
Jun 2024 |
|
Package building (wheels and sdists) |
Sep 2024 |
|
PyPI uploads |
Jan 2025 |
|
Wheel validation |
Jan 2025 |
|
Vulnerability scanning |
Mar 2026 |
uv also consolidated command-line usage that previously required separate tools: pip install became uv pip install / uv sync, pipx became uvx, and actions/setup-python was replaced by astral-sh/setup-uv (counted in the action minimization table above).
Two other Python packages were eliminated outside the ruff/uv consolidations: pipdeptree (replaced by an internal deps-graph implementation) and gitignore-parser (replaced by py-walk).
Permissions and token¶
Several workflows need a REPOMATIC_PAT secret to create PRs that modify files in .github/workflows/ and to trigger downstream workflows. Without it, those jobs silently fall back to the default GITHUB_TOKEN, which lacks the required permissions.
After your first push, the setup-guide job automatically opens an issue with step-by-step instructions to create and configure the token.
Concurrency and cancellation¶
All workflows use a concurrency directive to prevent redundant runs and save CI resources. When a new commit is pushed, any in-progress workflow runs for the same branch or PR are automatically cancelled.
Workflows are grouped by:
Pull requests:
{workflow-name}-{pr-number}— Multiple commits to the same PR cancel previous runsBranch pushes:
{workflow-name}-{branch-ref}— Multiple pushes to the same branch cancel previous runs
release.yaml uses a stronger protection: release commits get a unique concurrency group based on the commit SHA, so they can never be cancelled. This ensures tagging, PyPI publishing, and GitHub release creation complete successfully.
Additionally, cancel-runs.yaml actively cancels in-progress and queued runs when a PR is closed. This complements passive concurrency groups, which only trigger cancellation when a new run enters the same group — closing a PR doesn’t produce such an event.
Tip
For implementation details on how concurrency groups are computed and why release.yaml needs special handling, see the repomatic.github.actions module docstring.
AV false-positive submissions¶
Compiled Python binaries (built with Nuitka --onefile) are frequently flagged as malicious by heuristic AV engines. The onefile packaging technique (self-extracting archive with embedded Python runtime) triggers generic “packed/suspicious” signatures. This is a known issue across the Nuitka ecosystem.
The scan-virustotal job in _release-engine.yaml uploads all compiled binaries to VirusTotal on every release. This seeds AV vendor databases to reduce false positive rates for downstream distributors (Chocolatey, Scoop, etc.).
When a release is flagged, the /av-false-positive skill generates per-vendor submission files with pre-written text and form field mappings. The vendor details below document the process for manual reference.
Vendor portals¶
Vendor |
Engines covered |
Portal |
Format |
Turnaround |
|---|---|---|---|---|
Microsoft |
|
One file per form, 1900 char limit on additional info |
Fastest |
|
BitDefender |
|
One file per form, screenshot mandatory |
Fast |
|
ESET |
|
Email to |
Single email, password-protected ZIP ( |
Reliable |
Symantec |
|
Hash submission only (no |
3-7 business days |
|
Avast/AVG |
|
One file per form, shared engine |
Medium |
|
Sophos |
|
One file per form, 25 MB max per submission |
Up to 15 business days |
Submission priority¶
Submit in this order to maximize impact:
Microsoft: most influential engine. ML detections (
Sabsik,Wacatac) have the broadest downstream effect.BitDefender: powers ~6 downstream vendor engines. Highest detection-removal-per-submission ratio.
ESET: email-based channel with no portal dependency. The most reliable submission path.
Symantec: ML detections (
ML.Attribute.*) may take longer to process.Avast/AVG: shared engine, so one submission covers both.
Sophos: PUA detections require justification of the software’s legitimate purpose.
Submission content¶
Every false-positive submission should include:
The binary’s VirusTotal report link.
VirusTotal links for the clean
.whland.tar.gzsource distributions (as comparison evidence).The GitHub release link and direct download URL for the binary.
Project homepage and PyPI URL.
License from
pyproject.toml.Reference to any prior false-positive issue in the repository.
All submission text should mention that the binary is compiled with Nuitka --onefile from an open-source project.
Known portal issues¶
Microsoft: CORS errors or stuck progress modals during upload (auth session expiring). Workaround: sign out, clear cookies for
microsoft.com, sign back in, submit immediately.BitDefender: form sometimes returns “Your request could not be registered!” with no details. Retry later.
Avast: form sometimes returns “An internal error occurred while sending the form.” Retry later.
repomatic.virustotal API¶
Upload release binaries to VirusTotal and update GitHub release notes.
Submits compiled binaries (.bin, .exe) to the VirusTotal API for malware
scanning, then appends analysis links to the GitHub release body so users can
verify scan results.
Supports two-phase operation: phase 1 uploads files and writes an initial table with scan links, phase 2 polls for analysis completion and replaces the table with detection statistics.
Note
The free-tier API allows 4 requests per minute. All API calls (uploads and polls) are rate-limited with a sleep between each request.
- repomatic.virustotal.VIRUSTOTAL_GUI_URL = 'https://www.virustotal.com/gui/file/{sha256}'¶
URL template for the VirusTotal file analysis page.
- repomatic.virustotal.VIRUSTOTAL_SECTION_HEADER = '### 🛡️ VirusTotal scans'¶
Markdown header identifying the VirusTotal section in release bodies.
Used for idempotency: if this header is already present, the release body is not modified (unless
replace=Trueis passed toupdate_release_body).
- repomatic.virustotal.DETECTION_PENDING = '*pending*'¶
Placeholder text for the Detections column when analysis is not yet complete.
- class repomatic.virustotal.DetectionStats(malicious, suspicious, undetected, harmless)[source]¶
Bases:
objectDetection statistics from a completed VirusTotal analysis.
Stores only the four categories that constitute a definitive verdict.
type-unsupported,timeout, andfailurefrom the API response are excluded from the total.
- class repomatic.virustotal.ScanResult(filename, sha256, analysis_url, detection_stats=None)[source]¶
Bases:
objectResult of uploading a single file to VirusTotal.
- detection_stats: DetectionStats | None = None¶
Detection statistics, or
Noneif analysis is still pending.
- repomatic.virustotal.scan_files(api_key, file_paths, rate_limit=4)[source]¶
Upload files to VirusTotal and return scan results.
Uses the synchronous
vt.ClientAPI. Sleeps between uploads to respect the free-tier rate limit.
- repomatic.virustotal.poll_detection_stats(api_key, results, rate_limit=4, timeout=600)[source]¶
Poll VirusTotal for detection statistics of previously uploaded files.
Queries
GET /files/:sha256:for each file until analysis completes or the timeout is reached. Respects the free-tier rate limit for all API calls.- Parameters:
api_key (
str) – VirusTotal API key.results (
list[ScanResult]) – Scan results from a previous upload.rate_limit (
int) – Maximum API requests per minute (shared with uploads).timeout (
int) – Maximum seconds to wait for all analyses to complete.
- Return type:
- Returns:
Results with
detection_statspopulated (orNonefor files whose analysis did not complete before the timeout).
- repomatic.virustotal.format_virustotal_section(results, repo='', tag='')[source]¶
Format scan results as a markdown section for a GitHub release body.
When any result has
detection_stats, a three-column table is rendered with a Detections column. Otherwise the simpler two-column format is used.- Parameters:
results (
list[ScanResult]) – Scan results to format.repo (
str) – Repository inowner/repoformat. When provided along withtag, binary names are linked to their GitHub release download URLs.tag (
str) – Release tag (e.g.,v6.11.1).
- Return type:
- Returns:
Markdown string, or empty string if no results.
- repomatic.virustotal.update_release_body(repo, tag, results, replace=False)[source]¶
Append or replace VirusTotal scan links in a GitHub release body.
- Parameters:
repo (
str) – Repository inowner/repoformat.tag (
str) – Release tag (e.g.,v6.11.1).results (
list[ScanResult]) – Scan results to write.replace (
bool) – WhenTrue, replace an existing VirusTotal section instead of skipping. WhenFalse(default), skip if the section is already present.
- Return type:
- Returns:
Trueif the body was updated,Falseif skipped.
repomatic.checksums API¶
Update SHA-256 checksums for binary downloads.
Two update modes:
Workflow files — scans for GitHub release download URLs paired with
sha256sum --checkverification lines. Replaces stale hashes in-place.Tool registry — iterates
TOOL_REGISTRYentries withbinaryspecs, downloads each URL, and replaces stale hashes intool_runner.py.
Designed to be called by Renovate postUpgradeTasks after version bumps,
but also works standalone for manual checksum updates.
- repomatic.checksums.update_checksums(file_path)[source]¶
Update SHA-256 checksums in a workflow file.
repomatic.binary API¶
Binary build targets and verification utilities.
Defines the Nuitka compilation targets for all supported platforms and provides binary architecture verification using exiftool.
- repomatic.binary.NUITKA_BUILD_TARGETS = {'linux-arm64': {'arch': 'arm64', 'extension': 'bin', 'os': 'ubuntu-24.04-arm', 'platform_id': 'linux'}, 'linux-x64': {'arch': 'x64', 'extension': 'bin', 'os': 'ubuntu-24.04', 'platform_id': 'linux'}, 'macos-arm64': {'arch': 'arm64', 'extension': 'bin', 'os': 'macos-26', 'platform_id': 'macos'}, 'macos-x64': {'arch': 'x64', 'extension': 'bin', 'os': 'macos-26-intel', 'platform_id': 'macos'}, 'windows-arm64': {'arch': 'arm64', 'extension': 'exe', 'os': 'windows-11-arm', 'platform_id': 'windows'}, 'windows-x64': {'arch': 'x64', 'extension': 'exe', 'os': 'windows-2025', 'platform_id': 'windows'}}¶
List of GitHub-hosted runners used for Nuitka builds.
The key of the dictionary is the target name, which is used as a short name for user-friendlyness. As such, it is used to name the compiled binary.
Values are dictionaries with the following keys:
os: Operating system name, as used in GitHub-hosted runners.Hint
We choose to run the compilation only on the latest supported version of each OS, for each architecture. Note that macOS and Windows do not have the latest version available for each architecture.
platform_id: Platform identifier, as defined by Extra Platform.arch: Architecture identifier.Note
Architecture IDs are inspired from those specified for self-hosted runners
Note
Maybe we should just adopt target triple.
extension: File extension of the compiled binary.
- repomatic.binary.FLAT_BUILD_TARGETS = [{'arch': 'arm64', 'extension': 'bin', 'os': 'ubuntu-24.04-arm', 'platform_id': 'linux', 'target': 'linux-arm64'}, {'arch': 'x64', 'extension': 'bin', 'os': 'ubuntu-24.04', 'platform_id': 'linux', 'target': 'linux-x64'}, {'arch': 'arm64', 'extension': 'bin', 'os': 'macos-26', 'platform_id': 'macos', 'target': 'macos-arm64'}, {'arch': 'x64', 'extension': 'bin', 'os': 'macos-26-intel', 'platform_id': 'macos', 'target': 'macos-x64'}, {'arch': 'arm64', 'extension': 'exe', 'os': 'windows-11-arm', 'platform_id': 'windows', 'target': 'windows-arm64'}, {'arch': 'x64', 'extension': 'exe', 'os': 'windows-2025', 'platform_id': 'windows', 'target': 'windows-x64'}]¶
List of build targets in a flat format, suitable for matrix inclusion.
- repomatic.binary.BINARY_AFFECTING_PATHS: Final[tuple[str, ...]] = ('.github/workflows/release.yaml', 'pyproject.toml', 'tests/', 'uv.lock')¶
Path prefixes that always affect compiled binaries, regardless of the project.
Project-specific source directories (derived from
[project.scripts]inpyproject.toml) are added dynamically bybinary_affecting_paths.
- repomatic.binary.SKIP_BINARY_BUILD_BRANCHES: Final[frozenset[str]] = frozenset({'format-images', 'format-json', 'format-markdown', 'format-shell', 'sync-gitignore', 'sync-mailmap', 'update-deps-graph'})¶
Autofix branches whose changes cannot affect compiled binaries.
Members are PR branch names produced by autofix jobs that touch only repository housekeeping (
.mailmap,.gitignore, JSON, Markdown, images, shell scripts, dependency graph). The binary output is unchanged, soskip_binary_buildreturnsTruewhen the PR head branch matches a member, saving an expensive Nuitka compilation.Note
This set is intentionally disjoint from
VERSION_BUMP_BRANCHES: version-bump branches do change binaries (they rewrite the version string baked into the build), so they belong to a different policy.
- repomatic.binary.VERSION_BUMP_BRANCHES: Final[frozenset[str]] = frozenset({'major-version-increment', 'minor-version-increment', 'prepare-release'})¶
PR branches that carry only automated version-bump and lockfile churn.
Members are bot-authored draft PRs created by the
bump-versionandprepare-releasejobs inchangelog.yaml. Their working tree is byte-identical tomainexcept for the version string inpyproject.toml,**/__init__.py,changelog.md,citation.cff, anduv.lock. Heavy PR-time workflows (tests.yaml,lint.yaml,labels.yaml) list these branches underpull_request.branches-ignoreso the matrix doesn’t burn CI minutes for a guaranteed-passing run.Note
These branches are not binary-neutral: the rewritten version string is baked into the Nuitka binary, so they are deliberately absent from
SKIP_BINARY_BUILD_BRANCHES. Post-merge release artifacts onmainare still produced.
- repomatic.binary.MANUAL_VERSION_BUMP_COMMIT_PREFIXES: Final[frozenset[str]] = frozenset({'Bump major version to ', 'Bump minor version to '})¶
Head-commit-message prefixes for user-initiated version bumps.
Members are the
bump-versionjob’sBump $part version to \``v$version\commit messages (rendered from thebump-versiontemplate’s title). These merges land as a single commit onmainand carry no other payload, so workflows can short-circuit on them safely.The release-cycle prefix
[changelog] Post-release bump `` is deliberately absent from this set because the ``prepare-releasemerge bundles the post-release-bump commit with the actual release commit ([changelog] Release vX.Y.Z) in a single push. Workflows that gate on the head commit message (tests.yaml,release.yaml::compile-binaries) must run on those pushes to test the release commit and build its binary — so they consult only this subset.
- repomatic.binary.VERSION_BUMP_COMMIT_PREFIXES: Final[frozenset[str]] = frozenset({'Bump major version to ', 'Bump minor version to ', '[changelog] Post-release bump '})¶
Full set of head-commit-message prefixes that mark a version-bump push.
Combines
MANUAL_VERSION_BUMP_COMMIT_PREFIXESwith the[changelog] Post-release bump `` prefix produced by ``prepare-releasemerges. Workflows without a release-artifact dependency (lint.yaml,labels.yaml) use this full set in theirmetadatajob’sif:gate so the entire job graph skips for any push generated by the version-bump PR family. Workflows that do produce release artifacts on the same push useMANUAL_VERSION_BUMP_COMMIT_PREFIXESinstead.
- repomatic.binary.BINARY_ARCH_MAPPINGS: Final[dict[str, tuple[str, str]]] = {'linux-arm64': ('CPUType', 'Arm 64-bits'), 'linux-x64': ('CPUType', 'AMD x86-64'), 'macos-arm64': ('CPUType', 'ARM 64-bit'), 'macos-x64': ('CPUType', 'x86 64-bit'), 'windows-arm64': ('MachineType', 'ARM64'), 'windows-x64': ('MachineType', 'AMD64')}¶
Mapping of build targets to (exiftool_field, expected_substring) tuples.
ABI signatures reported by
file(1)for each compiled binary:linux-arm64: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, strippedlinux-x64: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, strippedmacos-arm64: Mach-O 64-bit executable arm64macos-x64: Mach-O 64-bit executable x86_64windows-arm64: PE32+ executable (console) Aarch64, for MS Windowswindows-x64: PE32+ executable (console) x86-64, for MS Windows
- repomatic.binary.get_exiftool_command()[source]¶
Return the platform-appropriate exiftool command.
On Windows, exiftool is installed as
exiftool.exe.- Return type:
- repomatic.binary.run_exiftool(binary_path)[source]¶
Run exiftool on a binary and return parsed JSON output.
- Parameters:
binary_path (
Path) – Path to the binary file.- Return type:
- Returns:
Dictionary of exiftool metadata.
- Raises:
subprocess.CalledProcessError – If exiftool fails.
json.JSONDecodeError – If output is not valid JSON.
- repomatic.binary.verify_binary_arch(target, binary_path)[source]¶
Verify that a binary matches the expected architecture for a target.
- Parameters:
- Raises:
ValueError – If target is unknown.
AssertionError – If binary architecture does not match expected.
- Return type: