diff --git a/.github/config/auto-assign.yml b/.github/config/auto-assign.yml
new file mode 100644
index 0000000..652f35e
--- /dev/null
+++ b/.github/config/auto-assign.yml
@@ -0,0 +1,7 @@
+addAssignees: author
+
+addReviewers: false
+
+skipKeywords:
+ - wip
+ - draft
\ No newline at end of file
diff --git a/.github/config/labeler.yml b/.github/config/labeler.yml
new file mode 100644
index 0000000..e9fe5d5
--- /dev/null
+++ b/.github/config/labeler.yml
@@ -0,0 +1,43 @@
+abstractions:
+ - changed-files:
+ - any-glob-to-any-file:
+ - src/Abstractions/**
+
+runtime:
+ - changed-files:
+ - any-glob-to-any-file:
+ - src/Runtime/**
+
+examples:
+ - changed-files:
+ - any-glob-to-any-file:
+ - Examples/**
+
+tests:
+ - changed-files:
+ - any-glob-to-any-file:
+ - Tests/**
+ - '**/*Tests.cs'
+
+benchmark:
+ - changed-files:
+ - any-glob-to-any-file:
+ - Benchmarks/**
+
+documentation:
+ - changed-files:
+ - any-glob-to-any-file:
+ - Docs/**
+ - '*.md'
+ - readme.md
+
+architecture:
+ - changed-files:
+ - any-glob-to-any-file:
+ - Docs/Decision/**
+ - .github/config/**
+
+ci:
+ - changed-files:
+ - any-glob-to-any-file:
+ - .github/**
diff --git a/.github/config/labels.yml b/.github/config/labels.yml
new file mode 100644
index 0000000..48e0913
--- /dev/null
+++ b/.github/config/labels.yml
@@ -0,0 +1,51 @@
+- name: bug
+ color: "d73a4a"
+ description: Something is broken or incorrect
+
+- name: enhancement
+ color: "a2eeef"
+ description: New functionality or behavior
+
+- name: documentation
+ color: "0075ca"
+ description: Documentation updates and additions
+
+- name: architecture
+ color: "8B5E3C"
+ description: Design, structure, and API-shape changes
+
+- name: abstractions
+ color: "5B4B8A"
+ description: Public abstractions and contracts
+
+- name: runtime
+ color: "8A4F7D"
+ description: Runtime implementation and execution flow
+
+- name: examples
+ color: "1d76db"
+ description: Runnable examples and sample apps
+
+- name: ci
+ color: "5319e7"
+ description: CI/CD and repository automation changes
+
+- name: tests
+ color: "fbca04"
+ description: Test coverage and test changes
+
+- name: performance
+ color: "0e4d92"
+ description: Performance improvements or regressions
+
+- name: benchmark
+ color: "5319e7"
+ description: Benchmark coverage and performance measurement changes
+
+- name: breaking-change
+ color: "b60205"
+ description: Introduces a breaking change
+
+- name: skip-changelog
+ color: "ededed"
+ description: Exclude this change from release notes
diff --git a/.github/config/pr-title-checker.json b/.github/config/pr-title-checker.json
new file mode 100644
index 0000000..9541c2d
--- /dev/null
+++ b/.github/config/pr-title-checker.json
@@ -0,0 +1,20 @@
+{
+ "LABEL": {
+ "name": "",
+ "color": "EEEEEE"
+ },
+ "CHECKS": {
+ "regexp": "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\\([a-z0-9-]+\\))?: .+$",
+ "regexpFlags": "i",
+ "ignoreLabels": [
+ "State One",
+ "State Two"
+ ],
+ "alwaysPassCI": false
+ },
+ "MESSAGES": {
+ "success": "PR title matches the required format.",
+ "failure": "PR title must match: type(optional-scope): description",
+ "notice": "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore."
+ }
+}
diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml
new file mode 100644
index 0000000..f3073e2
--- /dev/null
+++ b/.github/workflows/labels-sync.yml
@@ -0,0 +1,27 @@
+name: Labels Sync
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - .github/config/labels.yml
+ - .github/workflows/labels-sync.yml
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ contents: read
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Sync repository labels
+ uses: EndBug/label-sync@v2
+ with:
+ config-file: .github/config/labels.yml
+ delete-other-labels: true
diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml
new file mode 100644
index 0000000..57eed27
--- /dev/null
+++ b/.github/workflows/pr-automation.yml
@@ -0,0 +1,97 @@
+name: Pull Request Automation
+
+on:
+ pull_request_target:
+ types: [ opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled ]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ assign-author:
+ if: github.event.action == 'opened' || github.event.action == 'ready_for_review'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Assign PR author
+ uses: kentaro-m/auto-assign-action@v2.0.0
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ configuration-path: .github/config/auto-assign.yml
+
+ title-check:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check PR title
+ uses: thehanimo/pr-title-checker@v1.4.3
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ pass_on_octokit_error: false
+ configuration_path: .github/config/pr-title-checker.json
+
+ labeler:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Label changed files
+ uses: actions/labeler@v6
+ with:
+ configuration-path: .github/config/labeler.yml
+ sync-labels: true
+
+ performance-labeler:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Apply Performance label from issues or PR text
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { owner, repo } = context.repo;
+ const pr = context.payload.pull_request;
+ const text = `${pr.title}\n${pr.body || ''}`.toLowerCase();
+ const keywordMatch = /(perf|performance|benchmark|optimiz|allocation|memory pressure|throughput|latency)/i.test(text);
+ const issueNumbers = new Set();
+
+ for (const match of text.matchAll(/(?:closes|fixes|related to)\s+#(\d+)/gi)) {
+ issueNumbers.add(Number(match[1]));
+ }
+
+ for (const match of text.matchAll(/#(\d+)/g)) {
+ issueNumbers.add(Number(match[1]));
+ }
+
+ let issueMatch = false;
+
+ for (const number of issueNumbers) {
+ const issue = await github.rest.issues.get({ owner, repo, issue_number: number });
+ const labels = issue.data.labels.map(label => label.name);
+
+ if (labels.includes('performance')) {
+ issueMatch = true;
+ break;
+ }
+ }
+
+ if (!keywordMatch && !issueMatch) {
+ core.info('Performance label not applicable for this PR.');
+ return;
+ }
+
+ const currentLabels = pr.labels.map(label => label.name);
+ if (currentLabels.includes('performance')) {
+ core.info('Performance label already present.');
+ return;
+ }
+
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: pr.number,
+ labels: ['performance']
+ });
+ core.info('Performance label added.');
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
new file mode 100644
index 0000000..2484cae
--- /dev/null
+++ b/.github/workflows/pr-check.yml
@@ -0,0 +1,31 @@
+name: PR Check
+
+on:
+ pull_request:
+ push:
+ branches: [ main, master, develop ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ configuration: [ Debug, Release ]
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore ModularityKit.Mutator.slnx
+
+ - name: Build ${{ matrix.configuration }}
+ run: dotnet build ModularityKit.Mutator.slnx -c ${{ matrix.configuration }} --no-restore
diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml
new file mode 100644
index 0000000..a8180be
--- /dev/null
+++ b/.github/workflows/publish-artifacts.yml
@@ -0,0 +1,59 @@
+name: Publish Artifacts
+
+on:
+ workflow_call:
+ inputs:
+ package_version:
+ description: "Optional package version, usually the release tag without the leading v."
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ name: Pack library
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore src/ModularityKit.Mutator.csproj
+
+ - name: Resolve package version
+ id: version
+ env:
+ PACKAGE_VERSION: ${{ inputs.package_version }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ version="$PACKAGE_VERSION"
+ if [ -z "$version" ]; then
+ version="$REF_NAME"
+ fi
+ version="${version#v}"
+ if ! printf '%s' "$version" | grep -Eq '^[0-9]+(\.[0-9]+){1,2}([-+][0-9A-Za-z.-]+)?$'; then
+ version="0.1.0"
+ fi
+ echo "package_version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Pack package
+ run: >
+ dotnet pack src/ModularityKit.Mutator.csproj
+ -c Release
+ --no-restore
+ -o nupkg
+ -p:PackageVersion=${{ steps.version.outputs.package_version }}
+
+ - name: Upload package
+ uses: actions/upload-artifact@v4
+ with:
+ name: ModularityKit.Mutator-nupkg
+ path: nupkg/*.nupkg
diff --git a/.github/workflows/publish-attested.yml b/.github/workflows/publish-attested.yml
new file mode 100644
index 0000000..e5171aa
--- /dev/null
+++ b/.github/workflows/publish-attested.yml
@@ -0,0 +1,42 @@
+name: Publish Attested
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+
+jobs:
+ publish:
+ uses: ./.github/workflows/publish-artifacts.yml
+
+ release:
+ name: Upload artifacts to draft release
+ runs-on: ubuntu-latest
+ needs: publish
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Download published artifacts
+ uses: actions/download-artifact@v6
+ with:
+ pattern: ModularityKit.Mutator-nupkg
+ path: dist
+ merge-multiple: true
+
+ - name: Create or update draft release
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ REPOSITORY: ${{ github.repository }}
+ DIST_DIR: dist
+ FIND_DRAFT: "true"
+ ENSURE_DRAFT: "true"
+ FAIL_MESSAGE: "No draft release found. Release Drafter must create the draft before artifacts can be uploaded."
+ ASSET_PATTERNS: |
+ *
+ run: python3 -m scripts.releases.upload_release_assets
diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml
new file mode 100644
index 0000000..39487da
--- /dev/null
+++ b/.github/workflows/release-drafter.yml
@@ -0,0 +1,69 @@
+name: Release Drafter
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Release version without the leading "v"
+ required: false
+ type: string
+ push:
+ branches: [ main ]
+
+permissions:
+ contents: write
+ pull-requests: read
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+
+jobs:
+ update-release-draft:
+ runs-on: ubuntu-latest
+ outputs:
+ tag_name: ${{ steps.release_drafter.outputs.tag_name }}
+ html_url: ${{ steps.release_drafter.outputs.html_url }}
+
+ steps:
+ - name: Update release draft
+ id: release_drafter
+ uses: release-drafter/release-drafter@v7
+ with:
+ config-name: release-drafter.yml
+ version: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ publish:
+ needs: update-release-draft
+ uses: ./.github/workflows/publish-artifacts.yml
+
+ upload-release-assets:
+ name: Upload artifacts to release draft
+ runs-on: ubuntu-latest
+ needs:
+ - update-release-draft
+ - publish
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Download published artifacts
+ uses: actions/download-artifact@v6
+ with:
+ pattern: ModularityKit.Mutator-nupkg
+ path: dist
+ merge-multiple: true
+
+ - name: Upload assets to Release Drafter draft
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ REPOSITORY: ${{ github.repository }}
+ RELEASE_TAG: ${{ needs.update-release-draft.outputs.tag_name }}
+ DIST_DIR: dist
+ ENSURE_DRAFT: "true"
+ FAIL_MESSAGE: "Release Drafter did not return a tag name."
+ ASSET_PATTERNS: |
+ *
+ run: python3 -m scripts.releases.upload_release_assets
diff --git a/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj b/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj
new file mode 100644
index 0000000..1383676
--- /dev/null
+++ b/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Benchmarks/MutationEngineBenchmarks.cs b/Benchmarks/MutationEngineBenchmarks.cs
new file mode 100644
index 0000000..e7cf79a
--- /dev/null
+++ b/Benchmarks/MutationEngineBenchmarks.cs
@@ -0,0 +1,161 @@
+using BenchmarkDotNet.Attributes;
+using Microsoft.Extensions.DependencyInjection;
+using ModularityKit.Mutator.Abstractions;
+using ModularityKit.Mutator.Abstractions.Changes;
+using ModularityKit.Mutator.Abstractions.Context;
+using ModularityKit.Mutator.Abstractions.Engine;
+using ModularityKit.Mutator.Abstractions.Intent;
+using ModularityKit.Mutator.Abstractions.Policies;
+using ModularityKit.Mutator.Abstractions.Results;
+using ModularityKit.Mutator.Runtime;
+
+namespace ModularityKit.Mutator.Benchmarks;
+
+[MemoryDiagnoser]
+[InProcess]
+public class MutationEngineBenchmarks
+{
+ private const string StateId = "benchmark-counter";
+
+ private IMutationEngine _performanceEngine = null!;
+ private IMutationEngine _strictEngine = null!;
+ private CounterState _state = null!;
+ private IncrementCounterMutation _commitMutation = null!;
+ private IncrementCounterMutation _simulateMutation = null!;
+ private IncrementCounterMutation _validateMutation = null!;
+ private IReadOnlyList> _batchMutations = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _performanceEngine = BuildEngine(MutationEngineOptions.Performance, addAllowPolicy: false);
+ _strictEngine = BuildEngine(MutationEngineOptions.Strict, addAllowPolicy: true);
+
+ _state = new CounterState(42);
+ _commitMutation = CreateMutation(MutationMode.Commit, "commit-one");
+ _simulateMutation = CreateMutation(MutationMode.Simulate, "simulate-one");
+ _validateMutation = CreateMutation(MutationMode.Validate, "validate-one");
+ _batchMutations = Enumerable.Range(0, 10)
+ .Select(i => CreateMutation(MutationMode.Commit, $"batch-{i}"))
+ .ToArray();
+ }
+
+ [Benchmark(Baseline = true)]
+ public async Task Commit_Performance_NoPolicy()
+ {
+ var result = await _performanceEngine.ExecuteAsync(_commitMutation, _state);
+ GC.KeepAlive(result);
+ }
+
+ [Benchmark]
+ public async Task Commit_Strict_WithPolicy()
+ {
+ var result = await _strictEngine.ExecuteAsync(_commitMutation, _state);
+ GC.KeepAlive(result);
+ }
+
+ [Benchmark]
+ public async Task Simulate_Strict_WithPolicy()
+ {
+ var result = await _strictEngine.ExecuteAsync(_simulateMutation, _state);
+ GC.KeepAlive(result);
+ }
+
+ [Benchmark]
+ public async Task ValidateOnly_Strict_WithPolicy()
+ {
+ var result = await _strictEngine.ExecuteAsync(_validateMutation, _state);
+ GC.KeepAlive(result);
+ }
+
+ [Benchmark]
+ public async Task Batch_Commit_Performance_NoPolicy()
+ {
+ var result = await _performanceEngine.ExecuteBatchAsync(_batchMutations, _state);
+ GC.KeepAlive(result);
+ }
+
+ private static IMutationEngine BuildEngine(
+ MutationEngineOptions options,
+ bool addAllowPolicy)
+ {
+ var services = new ServiceCollection();
+ services.AddMutators(options);
+ var provider = services.BuildServiceProvider();
+ var engine = provider.GetRequiredService();
+
+ if (addAllowPolicy)
+ engine.RegisterPolicy(new AllowAllCounterPolicy());
+
+ return engine;
+ }
+
+ private static IncrementCounterMutation CreateMutation(MutationMode mode, string operationSuffix)
+ {
+ var context = MutationContext.System("benchmark")
+ with
+ {
+ StateId = StateId,
+ Mode = mode,
+ CorrelationId = $"{StateId}:{operationSuffix}"
+ };
+
+ return new IncrementCounterMutation(context);
+ }
+
+ private sealed record CounterState(int Value);
+
+ private sealed class IncrementCounterMutation(MutationContext context) : IMutation
+ {
+ public MutationIntent Intent { get; } = new()
+ {
+ OperationName = "IncrementCounter",
+ Category = "Benchmark",
+ Description = "Increment the benchmark counter by one",
+ RiskLevel = MutationRiskLevel.Low,
+ IsReversible = true
+ };
+
+ public MutationContext Context { get; } = context;
+
+ public MutationResult Apply(CounterState state)
+ {
+ var next = state with { Value = state.Value + 1 };
+
+ return MutationResult.Success(
+ next,
+ ChangeSet.Single(StateChange.Modified(nameof(CounterState.Value), state.Value, next.Value)));
+ }
+
+ public ValidationResult Validate(CounterState state)
+ {
+ var result = ValidationResult.Success();
+
+ if (state.Value < 0)
+ result.AddError(nameof(CounterState.Value), "Counter value must be non-negative.");
+
+ return result;
+ }
+
+ public MutationResult Simulate(CounterState state)
+ {
+ var next = state with { Value = state.Value + 1 };
+
+ return MutationResult.Success(
+ next,
+ ChangeSet.Single(StateChange.Modified(nameof(CounterState.Value), state.Value, next.Value)));
+ }
+ }
+
+ private sealed class AllowAllCounterPolicy : IMutationPolicy
+ {
+ public string Name => nameof(AllowAllCounterPolicy);
+
+ public int Priority => 0;
+
+ public string? Description => "Always allows the benchmark counter mutation.";
+
+ public PolicyDecision Evaluate(IMutation mutation, CounterState state)
+ => PolicyDecision.Allow(Name, "Benchmark policy allows all mutations.");
+ }
+}
diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs
new file mode 100644
index 0000000..59830de
--- /dev/null
+++ b/Benchmarks/Program.cs
@@ -0,0 +1,16 @@
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Running;
+
+var artifactsPath = Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "..",
+ "..",
+ "..",
+ "obj",
+ "BenchmarkDotNet.Artifacts"));
+
+var config = ManualConfig.CreateMinimumViable()
+ .WithOptions(ConfigOptions.DisableLogFile)
+ .WithArtifactsPath(artifactsPath);
+
+BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
diff --git a/Benchmarks/README.md b/Benchmarks/README.md
new file mode 100644
index 0000000..d7862d6
--- /dev/null
+++ b/Benchmarks/README.md
@@ -0,0 +1,29 @@
+# Benchmarks
+
+This folder contains BenchmarkDotNet measurements for `ModularityKit.Mutator`.
+
+## What is benchmarked
+
+- commit execution without policy pressure
+- strict engine execution with policy checks
+- simulate and validate only paths
+- batch execution overhead
+
+## Run
+
+Build first:
+
+```bash
+dotnet build Benchmarks/ModularityKit.Mutator.Benchmarks.csproj -c Release
+```
+
+Run a specific benchmark:
+
+```bash
+dotnet Benchmarks/bin/Release/net10.0/ModularityKit.Mutator.Benchmarks.dll --filter '*MutationEngineBenchmarks.Commit_Performance_NoPolicy*'
+```
+
+## Notes
+
+- The benchmark harness is configured for the current environment.
+- Results are emitted by BenchmarkDotNet when the runner can write artifacts.
diff --git a/Tests/.gitkeep b/Tests/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/Tests/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/scripts/__init__.py
@@ -0,0 +1 @@
+
diff --git a/scripts/releases/__init__.py b/scripts/releases/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/scripts/releases/__init__.py
@@ -0,0 +1 @@
+
diff --git a/scripts/releases/upload_release_assets.py b/scripts/releases/upload_release_assets.py
new file mode 100644
index 0000000..780df63
--- /dev/null
+++ b/scripts/releases/upload_release_assets.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import json
+import os
+import pathlib
+import subprocess
+import sys
+from typing import Iterable
+
+
+def env(name: str, default: str = "") -> str:
+ return os.environ.get(name, default)
+
+
+def require_env(name: str) -> str:
+ value = env(name)
+ if not value:
+ print(f"Missing required environment variable: {name}", file=sys.stderr)
+ raise SystemExit(1)
+ return value
+
+
+def as_bool(value: str) -> bool:
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def gh_json(args: list[str]) -> object:
+ completed = subprocess.run(
+ ["gh", *args],
+ check=True,
+ capture_output=True,
+ text=True,
+ env=os.environ.copy(),
+ )
+ return json.loads(completed.stdout)
+
+
+def iter_assets(dist_dir: pathlib.Path, patterns: str) -> list[pathlib.Path]:
+ assets: list[pathlib.Path] = []
+ seen: set[pathlib.Path] = set()
+
+ for raw_pattern in patterns.splitlines():
+ pattern = raw_pattern.strip()
+ if not pattern:
+ continue
+
+ for match in sorted(dist_dir.glob(pattern)):
+ if match.is_file() and match not in seen:
+ seen.add(match)
+ assets.append(match)
+
+ return assets
+
+
+def find_release(repository: str, release_tag: str, find_draft: bool) -> dict[str, object] | None:
+ releases = gh_json(["api", f"repos/{repository}/releases?per_page=100"])
+ if not isinstance(releases, list):
+ raise RuntimeError("Unexpected GitHub API response for releases")
+
+ if release_tag:
+ for release in releases:
+ if isinstance(release, dict) and release.get("tag_name") == release_tag:
+ return release
+ return None
+
+ if find_draft:
+ for release in releases:
+ if isinstance(release, dict) and release.get("draft"):
+ return release
+ return None
+
+ return None
+
+
+def upload_assets(repository: str, tag_name: str, assets: Iterable[pathlib.Path]) -> int:
+ asset_args = [str(asset) for asset in assets]
+ if not asset_args:
+ print("No assets matched the configured patterns.", file=sys.stderr)
+ return 1
+
+ subprocess.run(
+ [
+ "gh",
+ "release",
+ "upload",
+ tag_name,
+ *asset_args,
+ "--clobber",
+ "--repo",
+ repository,
+ ],
+ check=True,
+ env=os.environ.copy(),
+ )
+ return 0
+
+
+def main() -> int:
+ repository = require_env("REPOSITORY")
+ dist_dir = pathlib.Path(env("DIST_DIR", "dist"))
+ asset_patterns = env("ASSET_PATTERNS", "*")
+ release_tag = env("RELEASE_TAG")
+ find_draft = as_bool(env("FIND_DRAFT", "false"))
+ ensure_draft = as_bool(env("ENSURE_DRAFT", "false"))
+ fail_message = env("FAIL_MESSAGE", "No matching release found.")
+
+ if not dist_dir.is_dir():
+ print(f"Distribution directory not found: {dist_dir}", file=sys.stderr)
+ return 1
+
+ release = find_release(repository, release_tag, find_draft)
+ if release is None:
+ if ensure_draft or release_tag or find_draft:
+ print(fail_message, file=sys.stderr)
+ return 1
+ print("No release target configured.", file=sys.stderr)
+ return 1
+
+ tag_name = release.get("tag_name")
+ if not isinstance(tag_name, str) or not tag_name:
+ print("Resolved release has no tag name.", file=sys.stderr)
+ return 1
+
+ assets = iter_assets(dist_dir, asset_patterns)
+ return upload_assets(repository, tag_name, assets)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())