diff --git a/Compendium.sln b/Compendium.sln
index ffc591c..933c44e 100644
--- a/Compendium.sln
+++ b/Compendium.sln
@@ -183,6 +183,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Cac
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Caching.Tests", "tests\Unit\Compendium.Abstractions.Caching.Tests\Compendium.Abstractions.Caching.Tests.csproj", "{262B8E91-B53C-49BC-833D-739DD4CFC2E1}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git", "src\Abstractions\Compendium.Abstractions.Git\Compendium.Abstractions.Git.csproj", "{25E575EB-5677-4BEE-A92A-D8832C27F87A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Git.Tests", "tests\Unit\Compendium.Abstractions.Git.Tests\Compendium.Abstractions.Git.Tests.csproj", "{C0C1EAC1-5C33-4451-8CD6-2521A8D13557}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub", "src\Adapters\Compendium.Adapters.GitHub\Compendium.Adapters.GitHub.csproj", "{4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub.Tests", "tests\Unit\Compendium.Adapters.GitHub.Tests\Compendium.Adapters.GitHub.Tests.csproj", "{5FE1601C-93E1-4E92-9AC9-521AA26592CB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -472,6 +480,22 @@ Global
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{262B8E91-B53C-49BC-833D-739DD4CFC2E1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {25E575EB-5677-4BEE-A92A-D8832C27F87A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C0C1EAC1-5C33-4451-8CD6-2521A8D13557}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6}
@@ -557,5 +581,9 @@ Global
{585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
{4A6496B8-5833-428F-92D7-3EB331F5CA32} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
{262B8E91-B53C-49BC-833D-739DD4CFC2E1} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
+ {25E575EB-5677-4BEE-A92A-D8832C27F87A} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
+ {C0C1EAC1-5C33-4451-8CD6-2521A8D13557} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
+ {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB}
+ {5FE1601C-93E1-4E92-9AC9-521AA26592CB} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
EndGlobalSection
EndGlobal
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9788d79..074e96e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -38,6 +38,11 @@
+
+
+
+
diff --git a/docs/adr/0006-multi-repo-adapter-split.md b/docs/adr/0006-multi-repo-adapter-split.md
index 8fe3947..b040755 100644
--- a/docs/adr/0006-multi-repo-adapter-split.md
+++ b/docs/adr/0006-multi-repo-adapter-split.md
@@ -133,4 +133,14 @@ Adapters extracted from this monorepo (chronological):
All seven heavy adapters now live in their own repositories. The framework's `tests/Integration` no longer requires Docker (per [ADR-0007](0007-integration-test-split-and-inmemory-defaults.md)); per-adapter integration tests live alongside the adapter that owns them. The framework CI's line-coverage gate is now strict at ≥ 90 %.
+### In-tree adapters staged for extraction
+
+New adapters may incubate in-tree while their abstraction stabilizes, then follow the same extraction path once the abstraction ships a stable (non-preview) tag:
+
+| Date added in-tree | Adapter | External SDK(s) | Scheduled extraction repo | Extraction trigger |
+|---|---|---|---|---|
+| 2026-07-24 | `Compendium.Adapters.GitHub` | Octokit, Sodium.Core | `compendium-adapter-github` | First stable (non-preview) tag of `Compendium.Abstractions.Git`. |
+
+`Compendium.Adapters.GitHub` is unit-testable end-to-end (WireMock for the REST paths, NSubstitute for the Octokit paths), so unlike the integration-bound adapters above it does not depress the framework's unit-coverage gate and can incubate in-tree without a coverage exemption until the Git abstraction stabilizes.
+
The convenience meta-package `Compendium.Extensions.ExternalAdapters` (which previously re-exposed Zitadel + Listmonk + LemonSqueezy DI helpers) was removed as part of this transition — consumers wire each adapter directly through its own DI extension method (`AddStripeBilling`, `AddZitadelIdentity`, etc.).
diff --git a/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs b/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs
new file mode 100644
index 0000000..58f279b
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/AccessControl/IGitAccessControlService.cs
@@ -0,0 +1,124 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.AccessControl;
+
+///
+/// Teams and repository access management on a namespace. Only meaningful for
+/// organization namespaces — adapters fail with Git.CapabilityNotSupported
+/// on user accounts. Requires
+/// .
+///
+public interface IGitAccessControlService
+{
+ ///
+ /// Creates the team when absent, updates it otherwise (idempotent).
+ ///
+ Task> EnsureTeamAsync(
+ GitConnection connection,
+ string @namespace,
+ EnsureGitTeam request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Adds a user to a team (or updates their role in it).
+ ///
+ Task AddTeamMemberAsync(
+ GitConnection connection,
+ string @namespace,
+ string teamSlug,
+ string username,
+ GitTeamRole role,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Grants (or updates) a team's role on a repository.
+ ///
+ Task SetTeamRepositoryRoleAsync(
+ GitConnection connection,
+ string @namespace,
+ string teamSlug,
+ GitRepositoryRef repository,
+ GitRepositoryRole role,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Grants (or updates) an individual user's role on a repository.
+ ///
+ Task SetUserRepositoryRoleAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string username,
+ GitRepositoryRole role,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a user's direct access to a repository. Removing absent access
+ /// succeeds (idempotent).
+ ///
+ Task RemoveUserFromRepositoryAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string username,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Request to create or update a team.
+///
+public sealed record EnsureGitTeam
+{
+ /// Gets the team display name; the provider derives the slug from it.
+ public required string Name { get; init; }
+
+ /// Gets the optional team description.
+ public string? Description { get; init; }
+}
+
+///
+/// A team on a namespace.
+///
+/// The provider-side team slug used in subsequent calls.
+/// The team display name.
+public sealed record GitTeam(string Slug, string Name);
+
+///
+/// A user's role inside a team.
+///
+public enum GitTeamRole
+{
+ /// Regular team member.
+ Member,
+
+ /// Team maintainer (can manage membership).
+ Maintainer,
+}
+
+///
+/// A neutral repository access role. Adapters map onto their native role set
+/// and document lossy mappings in their CAPABILITIES.md.
+///
+public enum GitRepositoryRole
+{
+ /// Read-only access.
+ Read,
+
+ /// Read plus issue/PR triage.
+ Triage,
+
+ /// Read/write access.
+ Write,
+
+ /// Write plus repository settings short of admin.
+ Maintain,
+
+ /// Full administrative access.
+ Admin,
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs
new file mode 100644
index 0000000..e83c483
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitCapability.cs
@@ -0,0 +1,74 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Capabilities;
+
+///
+/// The optional capabilities a git-server adapter may support. Adapters declare
+/// their support level per capability in ;
+/// operations relying on an unsupported capability fail with
+/// Git.CapabilityNotSupported instead of throwing.
+///
+public enum GitCapability
+{
+ /// Create a repository from a template/scaffold repository.
+ RepositoryFromTemplate,
+
+ /// Read repository metadata, contents, commits, and branches.
+ RepositoryManagement,
+
+ /// Create tags and publish releases.
+ TagsAndReleases,
+
+ /// Set repository-scoped CI secrets (encrypted, write-only).
+ CiSecrets,
+
+ /// Set repository-scoped CI variables (plaintext configuration).
+ CiVariables,
+
+ /// Set namespace-scoped (organization/group) CI secrets and variables.
+ NamespaceSecrets,
+
+ /// Trigger a CI pipeline / workflow run.
+ PipelineTrigger,
+
+ /// Read CI pipeline run status and history.
+ PipelineStatus,
+
+ /// Create and manage deployment environments.
+ DeploymentEnvironments,
+
+ /// Set secrets scoped to a deployment environment.
+ EnvironmentSecrets,
+
+ /// Apply branch protection policies (rulesets, protected branches).
+ BranchPolicies,
+
+ /// Manage teams and repository access roles.
+ TeamsAndPermissions,
+
+ /// Create and manage outgoing webhook subscriptions on repositories or namespaces.
+ WebhookManagement,
+
+ /// Verify and parse inbound webhook deliveries into neutral events.
+ WebhookIngestion,
+
+ /// Create the namespace (organization/group) itself via API.
+ NamespaceProvisioning,
+
+ /// Authenticate as a platform app installed on the customer namespace (e.g. GitHub App).
+ AppInstallationAuth,
+
+ /// Authenticate with a durable service-account / bot token.
+ ServiceAccountAuth,
+
+ /// Authenticate with an OAuth user access token.
+ OAuthUserAuth,
+
+ /// Narrow a minted credential to specific repositories/permissions at mint time.
+ ScopedTokenMinting,
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs
new file mode 100644
index 0000000..59551f0
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Capabilities/GitServerCapabilities.cs
@@ -0,0 +1,75 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Capabilities;
+
+///
+/// The support level an adapter declares for a .
+///
+public enum GitCapabilityLevel
+{
+ /// The capability is not available on this provider.
+ None,
+
+ /// The capability is available with limitations (documented in the adapter's CAPABILITIES.md).
+ Partial,
+
+ /// The capability is fully supported.
+ Full,
+}
+
+///
+/// An adapter's declared support for a single capability.
+///
+/// The support level.
+///
+/// A short human-readable limitation note, required when
+/// is or
+/// for a capability the provider conceptually has but the adapter cannot reach
+/// (e.g. "github.com org creation requires an enterprise-owner user token").
+///
+public sealed record GitCapabilitySupport(GitCapabilityLevel Level, string? Limitation = null);
+
+///
+/// The declarative capability matrix of a git-server adapter. Consumers use it
+/// to drive UI affordances (hide or disable unsupported features with a reason)
+/// and adapters use to fail uniformly.
+///
+public sealed record GitServerCapabilities
+{
+ ///
+ /// Gets the provider identifier these capabilities describe (matches
+ /// ).
+ ///
+ public required string Provider { get; init; }
+
+ ///
+ /// Gets the declared support per capability. Capabilities absent from the
+ /// dictionary are treated as .
+ ///
+ public required IReadOnlyDictionary Entries { get; init; }
+
+ ///
+ /// Returns whether the capability is available at any level
+ /// ( or ).
+ ///
+ public bool Supports(GitCapability capability) =>
+ Entries.TryGetValue(capability, out var support) && support.Level != GitCapabilityLevel.None;
+
+ ///
+ /// Returns success when the capability is available, otherwise the standard
+ /// Git.CapabilityNotSupported failure. Adapters call this at the top
+ /// of optional-capability methods so every provider fails identically.
+ ///
+ public Result EnsureSupported(GitCapability capability) =>
+ Supports(capability)
+ ? Result.Success()
+ : Result.Failure(GitErrors.NotSupported(
+ Provider,
+ capability,
+ Entries.TryGetValue(capability, out var support) ? support.Limitation : null));
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs b/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs
new file mode 100644
index 0000000..cfdbbea
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/CiConfiguration/IGitCiConfigurationService.cs
@@ -0,0 +1,95 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.CiConfiguration;
+
+///
+/// CI secrets and variables management. Secrets are write-only on every
+/// provider — values can be set and deleted but never read back. Adapters
+/// perform any provider-required client-side encryption (GitHub: libsodium
+/// sealed box) transparently.
+///
+public interface IGitCiConfigurationService
+{
+ ///
+ /// Creates or updates CI secrets at . Requires
+ /// (repository scope),
+ /// (namespace scope), or
+ /// (environment scope).
+ ///
+ Task SetSecretsAsync(
+ GitConnection connection,
+ GitConfigurationScope scope,
+ IReadOnlyDictionary secrets,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a CI secret at . Deleting an absent
+ /// secret succeeds (idempotent).
+ ///
+ Task DeleteSecretAsync(
+ GitConnection connection,
+ GitConfigurationScope scope,
+ string name,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates or updates CI variables (plaintext configuration) at
+ /// . Requires
+ /// .
+ ///
+ Task SetVariablesAsync(
+ GitConnection connection,
+ GitConfigurationScope scope,
+ IReadOnlyDictionary variables,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a CI variable at . Deleting an absent
+ /// variable succeeds (idempotent).
+ ///
+ Task DeleteVariableAsync(
+ GitConnection connection,
+ GitConfigurationScope scope,
+ string name,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Where a CI secret or variable lives, as a closed union: on a repository, on
+/// a namespace (organization/group), or on a deployment environment of a
+/// repository.
+///
+public abstract record GitConfigurationScope
+{
+ private GitConfigurationScope()
+ {
+ }
+
+ ///
+ /// Repository-scoped configuration.
+ ///
+ /// The repository.
+ public sealed record Repository(GitRepositoryRef Ref) : GitConfigurationScope;
+
+ ///
+ /// Namespace-scoped (organization/group) configuration, shared by the
+ /// namespace's repositories.
+ ///
+ /// The namespace login.
+ public sealed record Namespace(string Name) : GitConfigurationScope;
+
+ ///
+ /// Environment-scoped configuration on a repository's deployment environment.
+ ///
+ /// The repository.
+ /// The deployment environment name.
+ public sealed record Environment(GitRepositoryRef Ref, string EnvironmentName) : GitConfigurationScope;
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj b/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj
new file mode 100644
index 0000000..ed17128
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Compendium.Abstractions.Git.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Compendium.Abstractions.Git
+ Compendium.Abstractions.Git
+ Compendium.Abstractions.Git
+ Git-server abstractions for Compendium Framework: the IGitServer facade with concern-scoped ports (credentials, repositories, pipelines, CI configuration, deployment environments, branch policies, access control, webhooks, namespace provisioning), a provider-neutral connection/credential model, and declarative per-adapter capabilities. Provider-agnostic ports for GitHub, GitLab, Gitea/Forgejo, Azure DevOps, and other git servers.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs
new file mode 100644
index 0000000..10ff0fd
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccessToken.cs
@@ -0,0 +1,61 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Connections;
+
+///
+/// A ready-to-use, short-lived token minted for a .
+/// Usable against the provider's API and for git-over-HTTPS via
+/// .
+///
+public sealed record GitAccessToken
+{
+ ///
+ /// Gets the token material. Redacted in ToString().
+ ///
+ public required string Token { get; init; }
+
+ ///
+ /// Gets the instant the token expires. For providers whose tokens do not
+ /// expire (durable bot tokens passed through), adapters report a far-future
+ /// value and document it in CAPABILITIES.md.
+ ///
+ public required DateTimeOffset ExpiresAt { get; init; }
+
+ ///
+ /// Gets the username to pair with the token for HTTP basic authentication
+ /// on git operations (GitHub: "x-access-token").
+ ///
+ public required string HttpBasicUsername { get; init; }
+
+ ///
+ public override string ToString() => $"GitAccessToken(***, ExpiresAt={ExpiresAt:O})";
+}
+
+///
+/// Optional narrowing applied when minting a token (GitHub: repository_ids
+/// — at most 500 — plus a permissions subset). Providers without token
+/// scoping ignore the request; they declare
+/// as unsupported.
+///
+public sealed record GitAccessTokenScope
+{
+ ///
+ /// Gets the repositories the token should be limited to;
+ /// keeps the credential's full repository access.
+ ///
+ public IReadOnlyList? Repositories { get; init; }
+
+ ///
+ /// Gets the permission subset to request, keyed by provider-native
+ /// permission names (stringly-typed in v1; see the adapter's CAPABILITIES.md
+ /// for the accepted keys).
+ ///
+ public IReadOnlyDictionary? Permissions { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs
new file mode 100644
index 0000000..5cb1de9
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitAccountType.cs
@@ -0,0 +1,20 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Connections;
+
+///
+/// The kind of account a namespace or credential identity refers to.
+///
+public enum GitAccountType
+{
+ /// An organization / group account.
+ Organization,
+
+ /// A personal user account.
+ User,
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs
new file mode 100644
index 0000000..cfae6c5
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitConnection.cs
@@ -0,0 +1,37 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Connections;
+
+///
+/// Identifies which git server to talk to and with what credential. Every port
+/// method takes a connection explicitly — ports are stateless singletons and a
+/// single adapter instance serves any number of tenants.
+///
+public sealed record GitConnection
+{
+ ///
+ /// Gets the provider identifier (matches ,
+ /// e.g. "github", "gitlab", "gitea").
+ ///
+ public required string Provider { get; init; }
+
+ ///
+ /// Gets the API base URL for self-hosted instances — the full prefix API
+ /// requests are issued against, not the web host root (GitHub Enterprise
+ /// Server: https://ghes.example.com/api/v3; GitLab on-prem:
+ /// https://gitlab.example.com/api/v4).
+ /// targets the provider's cloud service. Adapters must follow this
+ /// convention regardless of their SDK's own base-URL handling.
+ ///
+ public Uri? ServerUrl { get; init; }
+
+ ///
+ /// Gets the credential used for this connection.
+ ///
+ public required GitCredential Credential { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs
new file mode 100644
index 0000000..919bdc8
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/GitCredential.cs
@@ -0,0 +1,66 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Connections;
+
+///
+/// The credential material of a , as a closed union.
+/// Token-bearing members redact their material in ToString() so a logged
+/// connection never leaks a secret.
+///
+public abstract record GitCredential
+{
+ private GitCredential()
+ {
+ }
+
+ ///
+ /// A platform-owned app installed on the customer namespace (GitHub App
+ /// installation). The adapter mints short-lived tokens from the app's
+ /// private key; no durable secret is stored per connection.
+ ///
+ /// The provider-side installation identifier.
+ ///
+ /// Selects an app registration from the adapter options;
+ /// selects the default app. Per-instance apps created via a manifest flow
+ /// plug in here later without a contract change.
+ ///
+ public sealed record AppInstallation(string InstallationId, string? AppKey = null) : GitCredential;
+
+ ///
+ /// A durable machine token (GitLab group access token, Gitea/Forgejo bot
+ /// PAT, Azure DevOps service principal secret).
+ ///
+ /// The token material. Redacted in ToString().
+ public sealed record ServiceAccountToken(string Token) : GitCredential
+ {
+ ///
+ public override string ToString() => "ServiceAccountToken(***)";
+ }
+
+ ///
+ /// A caller-supplied user personal access token, held in memory for the
+ /// duration of the call only — never persisted, logged, or cached.
+ ///
+ /// The token material. Redacted in ToString().
+ public sealed record PersonalAccessToken(string Token) : GitCredential
+ {
+ ///
+ public override string ToString() => "PersonalAccessToken(***)";
+ }
+
+ ///
+ /// An OAuth user access token (user-to-server), used to act as — or verify
+ /// the identity of — a specific human.
+ ///
+ /// The token material. Redacted in ToString().
+ public sealed record OAuthAccessToken(string AccessToken) : GitCredential
+ {
+ ///
+ public override string ToString() => "OAuthAccessToken(***)";
+ }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs b/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs
new file mode 100644
index 0000000..ba3a4f1
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Connections/IGitCredentialBroker.cs
@@ -0,0 +1,85 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Connections;
+
+///
+/// Mints, validates, and discovers credentials for git connections. This is the
+/// auth seam of the abstraction: app-installation providers (GitHub) mint
+/// ephemeral installation tokens from the platform app's private key, while
+/// token-based providers (GitLab, Gitea) wrap the connection's durable token.
+///
+public interface IGitCredentialBroker
+{
+ ///
+ /// Mints a short-lived, ready-to-use token for the connection, optionally
+ /// narrowed by . Adapters cache minted tokens per
+ /// credential identity and re-mint before expiry.
+ ///
+ Task> MintAsync(
+ GitConnection connection,
+ GitAccessTokenScope? scope = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates the connection's credential and reports the identity behind it.
+ /// Fails with Git.AuthenticationFailed when the credential is
+ /// expired, revoked, or malformed.
+ ///
+ Task> ValidateAsync(
+ GitConnection connection,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// App-installation mode only: discovers the platform app's installation on
+ /// a namespace. Fails with Git.AppNotInstalled (metadata carries
+ /// installUrl) when the app is not installed there.
+ ///
+ /// The organization/group or user login to probe.
+ /// Selects an app registration; = default app.
+ /// A cancellation token.
+ Task> ResolveAppInstallationAsync(
+ string @namespace,
+ string? appKey = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// App-installation mode only: lists every installation of the platform app
+ /// across all accounts. Adapters page through the provider API internally
+ /// and return the full list. Used by reconciliation jobs to heal missed
+ /// webhooks.
+ ///
+ /// Selects an app registration; = default app.
+ /// A cancellation token.
+ Task>> ListAppInstallationsAsync(
+ string? appKey = null,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// The identity behind a validated credential.
+///
+/// The login of the account the credential acts as.
+/// Whether that account is an organization or a user.
+/// The account's display name, when the provider reports one.
+public sealed record GitConnectionIdentity(
+ string AccountLogin,
+ GitAccountType AccountType,
+ string? DisplayName = null);
+
+///
+/// A platform-app installation on a customer account.
+///
+/// The provider-side installation identifier.
+/// The login of the account the app is installed on.
+/// Whether that account is an organization or a user.
+/// True when the installation is currently suspended.
+public sealed record GitInstallationInfo(
+ string InstallationId,
+ string AccountLogin,
+ GitAccountType AccountType,
+ bool Suspended = false);
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs b/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs
new file mode 100644
index 0000000..7c8a965
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Environments/IGitEnvironmentService.cs
@@ -0,0 +1,64 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Environments;
+
+///
+/// Deployment-environment management on a repository (GitHub Environments,
+/// GitLab environments). Requires
+/// .
+/// Environment-scoped secrets are set via
+/// with an
+/// scope.
+///
+public interface IGitEnvironmentService
+{
+ ///
+ /// Creates the environment when absent, updates it otherwise (idempotent).
+ ///
+ Task> EnsureAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ EnsureGitEnvironment request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes the environment. Deleting an absent environment succeeds (idempotent).
+ ///
+ Task DeleteAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string environmentName,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the repository's deployment environments.
+ ///
+ Task>> ListAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Request to create or update a deployment environment.
+///
+public sealed record EnsureGitEnvironment
+{
+ /// Gets the environment name (e.g. "production").
+ public required string Name { get; init; }
+}
+
+///
+/// A deployment environment on a repository.
+///
+/// The environment name.
+/// The web URL of the environment, when the provider reports one.
+public sealed record GitDeploymentEnvironment(string Name, string? HtmlUrl = null);
diff --git a/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs b/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs
new file mode 100644
index 0000000..7c193e5
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/GitErrors.cs
@@ -0,0 +1,144 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Capabilities;
+
+namespace Compendium.Abstractions.Git;
+
+///
+/// Provides standardized error definitions for git-server operations. Adapters
+/// map provider responses onto these errors so consumers can handle failures
+/// uniformly across providers.
+///
+public static class GitErrors
+{
+ ///
+ /// Gets the error code prefix for git-server errors.
+ ///
+ public const string Prefix = "Git";
+
+ ///
+ /// The git server is not configured on this host (missing app registration,
+ /// credentials, or base URL). Returned by
+ /// and by adapters whose required options are absent.
+ ///
+ public static Error NotConfigured(string? provider = null) =>
+ Error.Unavailable(
+ $"{Prefix}.NotConfigured",
+ provider is null
+ ? "No git server is configured on this host."
+ : $"The '{provider}' git server is not configured on this host.");
+
+ ///
+ /// The operation requires a capability the provider does not support (or
+ /// supports only partially). Carries provider and capability
+ /// metadata; see the adapter's CAPABILITIES.md for the support matrix.
+ ///
+ public static Error NotSupported(string provider, GitCapability capability, string? limitation = null)
+ {
+ var metadata = new Dictionary
+ {
+ ["provider"] = provider,
+ ["capability"] = capability.ToString(),
+ };
+ if (limitation is not null)
+ {
+ metadata["limitation"] = limitation;
+ }
+
+ return Error.Unavailable(
+ $"{Prefix}.CapabilityNotSupported",
+ limitation is null
+ ? $"Provider '{provider}' does not support capability '{capability}'."
+ : $"Provider '{provider}' does not support capability '{capability}': {limitation}",
+ metadata);
+ }
+
+ ///
+ /// The provider rejected the supplied credential (expired token, revoked
+ /// installation, bad signature on an app JWT, …).
+ ///
+ public static Error AuthenticationFailed(string provider, string? detail = null) =>
+ Error.Unauthorized(
+ $"{Prefix}.AuthenticationFailed",
+ detail is null
+ ? $"Authentication against '{provider}' failed."
+ : $"Authentication against '{provider}' failed: {detail}");
+
+ ///
+ /// The platform app is not installed on the target namespace. The
+ /// installUrl metadata entry, when present, is the URL a user should
+ /// visit to install the app.
+ ///
+ public static Error AppNotInstalled(string @namespace, string? installUrl = null)
+ {
+ var metadata = new Dictionary { ["namespace"] = @namespace };
+ if (installUrl is not null)
+ {
+ metadata["installUrl"] = installUrl;
+ }
+
+ return Error.Failure(
+ $"{Prefix}.AppNotInstalled",
+ $"The platform app is not installed on '{@namespace}'.",
+ metadata);
+ }
+
+ ///
+ /// The requested repository does not exist or is not visible to the credential.
+ ///
+ public static Error RepositoryNotFound(string repository) =>
+ Error.NotFound($"{Prefix}.RepositoryNotFound", $"Repository '{repository}' was not found.");
+
+ ///
+ /// The requested namespace (organization / group / user account) does not
+ /// exist or is not visible to the credential.
+ ///
+ public static Error NamespaceNotFound(string @namespace) =>
+ Error.NotFound($"{Prefix}.NamespaceNotFound", $"Namespace '{@namespace}' was not found.");
+
+ ///
+ /// A resource with the same identifier already exists (repository name,
+ /// namespace slug, environment name, …).
+ ///
+ public static Error Conflict(string resource) =>
+ Error.Conflict($"{Prefix}.Conflict", $"'{resource}' already exists.");
+
+ ///
+ /// The provider rejected the request because of rate limiting. Adapters
+ /// surface the provider's retry hint when available; callers must not retry
+ /// before it elapses.
+ ///
+ public static Error Throttled(TimeSpan? retryAfter = null) =>
+ Error.TooManyRequests(
+ $"{Prefix}.Throttled",
+ retryAfter.HasValue
+ ? $"Git provider throttled the request. Retry after {retryAfter.Value.TotalSeconds:F0} seconds."
+ : "Git provider throttled the request. Please try again later.",
+ retryAfter.HasValue
+ ? new Dictionary { ["retryAfterSeconds"] = retryAfter.Value.TotalSeconds }
+ : null);
+
+ ///
+ /// An inbound webhook delivery failed signature verification. The delivery
+ /// must be rejected without processing.
+ ///
+ public static Error WebhookSignatureInvalid() =>
+ Error.Unauthorized(
+ $"{Prefix}.WebhookSignatureInvalid",
+ "The webhook delivery signature is missing or invalid.");
+
+ ///
+ /// The provider rejected the request for a reason not covered by a more
+ /// specific error. Carries provider and statusCode metadata.
+ ///
+ public static Error ProviderRejected(string provider, int statusCode, string detail) =>
+ Error.Failure(
+ $"{Prefix}.ProviderRejected",
+ $"Provider '{provider}' rejected the request ({statusCode}): {detail}",
+ new Dictionary { ["provider"] = provider, ["statusCode"] = statusCode });
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs
new file mode 100644
index 0000000..d2393ca
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/GlobalUsings.cs
@@ -0,0 +1,8 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+global using Compendium.Core.Results;
diff --git a/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs b/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs
new file mode 100644
index 0000000..a334405
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/IGitServer.cs
@@ -0,0 +1,75 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.AccessControl;
+using Compendium.Abstractions.Git.Capabilities;
+using Compendium.Abstractions.Git.CiConfiguration;
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Environments;
+using Compendium.Abstractions.Git.Pipelines;
+using Compendium.Abstractions.Git.Protection;
+using Compendium.Abstractions.Git.Provisioning;
+using Compendium.Abstractions.Git.Repositories;
+using Compendium.Abstractions.Git.Webhooks;
+
+namespace Compendium.Abstractions.Git;
+
+///
+/// The git-server facade: one instance per provider adapter, carrying the
+/// provider discriminator, the declarative capability matrix, and the
+/// concern-scoped ports. Consumers resolve IEnumerable<IGitServer>
+/// and dispatch on (the sub-ports are also registered
+/// individually for single-concern consumers).
+///
+///
+/// Implementations are stateless singletons: all per-tenant state travels in
+/// the passed to every method, so a
+/// single adapter instance serves any number of tenants concurrently.
+///
+public interface IGitServer
+{
+ ///
+ /// Gets the provider identifier used for dispatch (e.g. "github",
+ /// "gitlab", "gitea").
+ ///
+ string Provider { get; }
+
+ ///
+ /// Gets the adapter's declared capability matrix.
+ ///
+ GitServerCapabilities Capabilities { get; }
+
+ /// Gets the credential minting/validation/discovery port.
+ IGitCredentialBroker Credentials { get; }
+
+ /// Gets the repository lifecycle and read port.
+ IGitRepositoryService Repositories { get; }
+
+ /// Gets the CI pipeline port.
+ IGitPipelineService Pipelines { get; }
+
+ /// Gets the CI secrets/variables port.
+ IGitCiConfigurationService CiConfiguration { get; }
+
+ /// Gets the deployment-environments port.
+ IGitEnvironmentService Environments { get; }
+
+ /// Gets the branch protection policies port.
+ IGitBranchPolicyService BranchPolicies { get; }
+
+ /// Gets the teams and repository access port.
+ IGitAccessControlService AccessControl { get; }
+
+ /// Gets the outgoing webhook subscription port.
+ IGitWebhookService Webhooks { get; }
+
+ /// Gets the inbound webhook verification/parsing port.
+ IGitWebhookIngestor WebhookIngestor { get; }
+
+ /// Gets the optional namespace provisioning port.
+ IGitNamespaceProvisioner NamespaceProvisioner { get; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs b/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs
new file mode 100644
index 0000000..91cff0b
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Pipelines/IGitPipelineService.cs
@@ -0,0 +1,148 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Pipelines;
+
+///
+/// CI pipeline operations: trigger runs and read their status.
+///
+public interface IGitPipelineService
+{
+ ///
+ /// Triggers a pipeline run. Requires
+ /// .
+ ///
+ ///
+ /// Some providers do not return a run identifier on trigger (GitHub's
+ /// workflow_dispatch is fire-and-forget): the handle's
+ /// is there
+ /// and callers correlate via . Each adapter
+ /// documents this in its CAPABILITIES.md.
+ ///
+ Task> TriggerAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ TriggerGitPipeline request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a single pipeline run by identifier. Requires
+ /// .
+ ///
+ Task> GetRunAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string runId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists pipeline runs, newest first. Requires
+ /// .
+ ///
+ Task>> ListRunsAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ ListGitPipelineRuns query,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Request to trigger a pipeline run.
+///
+public sealed record TriggerGitPipeline
+{
+ ///
+ /// Gets the pipeline selector. GitHub: the workflow file name or id
+ /// (e.g. "bootstrap.yml"); GitLab: the ref's .gitlab-ci.yml
+ /// pipeline. Each adapter documents its mapping.
+ ///
+ public required string Pipeline { get; init; }
+
+ ///
+ /// Gets the git reference to run on — a branch or tag name, never a raw
+ /// commit SHA (GitHub rejects one; to run against a specific commit, pass a
+ /// branch/tag here and put the SHA in ).
+ ///
+ public required string Reference { get; init; }
+
+ ///
+ /// Gets the input parameters passed to the pipeline, when it takes any.
+ ///
+ public IReadOnlyDictionary? Inputs { get; init; }
+}
+
+///
+/// The immediate result of triggering a pipeline.
+///
+///
+/// The created run's identifier, when the provider reports one at trigger time;
+/// for fire-and-forget providers.
+///
+public sealed record GitPipelineRunHandle(string? RunId);
+
+///
+/// A pipeline run as reported by the provider.
+///
+/// The run identifier.
+/// The pipeline the run belongs to.
+/// The neutral run status.
+/// The git reference the run executed on.
+/// The web URL of the run.
+/// When the run was created.
+public sealed record GitPipelineRun(
+ string Id,
+ string Pipeline,
+ GitPipelineStatus Status,
+ string Reference,
+ string HtmlUrl,
+ DateTimeOffset CreatedAt);
+
+///
+/// The neutral status of a pipeline run. Adapters map provider states onto this
+/// set and use for states with no equivalent.
+///
+public enum GitPipelineStatus
+{
+ /// The run is waiting to start.
+ Queued,
+
+ /// The run is executing.
+ Running,
+
+ /// The run completed successfully.
+ Succeeded,
+
+ /// The run completed with a failure.
+ Failed,
+
+ /// The run was cancelled before completion.
+ Cancelled,
+
+ /// The run was skipped.
+ Skipped,
+
+ /// The provider reported a state with no neutral equivalent.
+ Unknown,
+}
+
+///
+/// Filters for .
+///
+public sealed record ListGitPipelineRuns
+{
+ /// Gets the pipeline to filter on; all pipelines when null.
+ public string? Pipeline { get; init; }
+
+ /// Gets the git reference to filter on; all references when null.
+ public string? Reference { get; init; }
+
+ /// Gets the maximum number of runs returned (a single page). Defaults to 20.
+ public int Limit { get; init; } = 20;
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs b/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs
new file mode 100644
index 0000000..ecb5057
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Protection/IGitBranchPolicyService.cs
@@ -0,0 +1,89 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Protection;
+
+///
+/// Branch protection policies on a repository. The policy model is neutral:
+/// adapters map it onto their native mechanism (GitHub: repository rulesets;
+/// GitLab: protected branches + push rules) and document lossy mappings in
+/// their CAPABILITIES.md. Requires
+/// .
+///
+public interface IGitBranchPolicyService
+{
+ ///
+ /// Applies a protection policy. When a policy with the same
+ /// already exists, it is
+ /// updated (idempotent).
+ ///
+ Task> ApplyAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ GitBranchPolicyRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a policy by identifier. Removing an absent policy succeeds (idempotent).
+ ///
+ Task RemoveAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string policyId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the repository's protection policies.
+ ///
+ Task>> ListAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// A neutral branch protection policy request.
+///
+public sealed record GitBranchPolicyRequest
+{
+ /// Gets the branch name pattern the policy targets (e.g. "main", "release/*").
+ public required string Pattern { get; init; }
+
+ /// Gets whether changes must go through a pull/merge request. Defaults to true.
+ public bool RequirePullRequest { get; init; } = true;
+
+ /// Gets the number of approvals required on the pull request.
+ public int RequiredApprovals { get; init; }
+
+ /// Gets whether stale approvals are dismissed when new commits are pushed.
+ public bool DismissStaleApprovals { get; init; }
+
+ /// Gets the CI status checks that must pass before merging, when any.
+ public IReadOnlyList? RequiredStatusChecks { get; init; }
+
+ /// Gets whether force pushes to matching branches are blocked. Defaults to true.
+ public bool BlockForcePush { get; init; } = true;
+
+ /// Gets whether deletion of matching branches is blocked. Defaults to true.
+ public bool BlockDeletion { get; init; } = true;
+
+ /// Gets whether a linear history (no merge commits) is required.
+ public bool RequireLinearHistory { get; init; }
+
+ /// Gets whether the policy also applies to repository administrators.
+ public bool EnforceForAdmins { get; init; }
+}
+
+///
+/// An applied branch protection policy.
+///
+/// The provider-side policy identifier.
+/// The branch name pattern the policy targets.
+public sealed record GitBranchPolicy(string Id, string Pattern);
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs b/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs
new file mode 100644
index 0000000..0c4ac99
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Provisioning/IGitNamespaceProvisioner.cs
@@ -0,0 +1,55 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+
+namespace Compendium.Abstractions.Git.Provisioning;
+
+///
+/// OPTIONAL capability: creates the namespace (organization/group) itself.
+/// Trivial on GitLab (POST /groups) and Gitea (POST /orgs);
+/// on github.com it requires an enterprise-owner user token and is declared
+/// at best; absent on
+/// Azure DevOps. Always check
+/// first.
+///
+public interface IGitNamespaceProvisioner
+{
+ ///
+ /// Creates a namespace. Fails with Git.Conflict when the slug is
+ /// already taken.
+ ///
+ Task> CreateNamespaceAsync(
+ GitConnection connection,
+ CreateGitNamespace request,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Request to create a namespace.
+///
+public sealed record CreateGitNamespace
+{
+ /// Gets the namespace slug/login (e.g. "NXS-Acme").
+ public required string Name { get; init; }
+
+ /// Gets the display/profile name; defaults to .
+ public string? DisplayName { get; init; }
+
+ /// Gets the billing email, where the provider requires one (GitHub enterprise org creation).
+ public string? BillingEmail { get; init; }
+
+ /// Gets the logins granted admin/owner on the new namespace, where the provider supports it.
+ public IReadOnlyList? AdminLogins { get; init; }
+}
+
+///
+/// A created namespace.
+///
+/// The namespace slug/login.
+/// The web URL of the namespace, when the provider reports one.
+public sealed record GitNamespace(string Name, string? HtmlUrl = null);
diff --git a/src/Abstractions/Compendium.Abstractions.Git/README.md b/src/Abstractions/Compendium.Abstractions.Git/README.md
new file mode 100644
index 0000000..61d87a1
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/README.md
@@ -0,0 +1,31 @@
+# Compendium.Abstractions.Git
+
+Provider-agnostic git-server ports for the Compendium Framework.
+
+A platform that drives a git server (create repositories from templates, configure CI
+secrets/variables, trigger pipelines, manage deployment environments, branch policies,
+teams, and webhooks) programs against the `IGitServer` facade and its concern-scoped
+sub-ports. Concrete providers (GitHub, GitLab, Gitea/Forgejo, Azure DevOps, …) are
+supplied by `Compendium.Adapters.*` packages.
+
+## Design
+
+- **`IGitServer`** — facade carrying the `Provider` discriminator, the declarative
+ `GitServerCapabilities`, and the sub-ports. Consumers resolve `IEnumerable`
+ and dispatch on `Provider`.
+- **`GitConnection` / `GitCredential`** — every call takes an explicit connection.
+ Credentials are a closed union: platform app installation (GitHub App), durable
+ service-account token (GitLab group token, Gitea bot PAT), one-shot personal access
+ token, or OAuth user token. Token-bearing records redact in `ToString()`.
+- **`GitServerCapabilities`** — adapters declare what they support (`None`/`Partial`/`Full`
+ per `GitCapability`). Unsupported operations fail with `Git.CapabilityNotSupported`
+ (`Result` failure, never an exception). Each adapter ships a `CAPABILITIES.md` matrix.
+- **`GitWebhookEvent`** — neutral inbound-event union produced by `IGitWebhookIngestor`
+ from a raw, signature-verified delivery.
+- All operations return `Result`/`Result` from `Compendium.Core.Results`.
+
+## Testing
+
+`Compendium.Testing` provides `InMemoryGitServer` (full-fidelity fake) and
+`GitServerContractTests` (the behavioral contract every adapter must pass).
+This package also ships `NullGitServer`, a fail-fast stub for unconfigured hosts.
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs b/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs
new file mode 100644
index 0000000..6d705c9
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Repositories/GitRepositoryModels.cs
@@ -0,0 +1,116 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Git.Repositories;
+
+///
+/// Identifies a repository by its namespace (organization/group/user login) and name.
+///
+/// The owning namespace login (e.g. "acme").
+/// The repository name (e.g. "billing-api").
+public sealed record GitRepositoryRef(string Namespace, string Name)
+{
+ ///
+ /// Gets the namespace/name form of the reference.
+ ///
+ public string FullName => $"{Namespace}/{Name}";
+
+ ///
+ public override string ToString() => FullName;
+}
+
+///
+/// A repository as reported by the provider.
+///
+/// The repository reference.
+/// The HTTPS clone URL.
+/// The web URL of the repository.
+/// The default branch name (e.g. "main").
+/// Whether the repository is private.
+public sealed record GitRepository(
+ GitRepositoryRef Ref,
+ string CloneUrl,
+ string HtmlUrl,
+ string DefaultBranch,
+ bool Private);
+
+///
+/// A single commit, surfaced so a caller can pick a commit to deploy.
+///
+/// The full commit SHA.
+/// The commit message (first line is the summary).
+/// The author's display name, when available.
+/// When the commit was authored, when available.
+/// A web URL to view the commit.
+public sealed record GitCommit(
+ string Sha,
+ string Message,
+ string? AuthorName,
+ DateTimeOffset? AuthoredAt,
+ string HtmlUrl);
+
+///
+/// A branch on a repository.
+///
+/// The branch name (e.g. "main").
+/// The commit SHA the branch currently points at.
+/// Whether the branch is protected.
+public sealed record GitBranch(string Name, string Sha, bool Protected);
+
+///
+/// A tag on a repository.
+///
+/// The tag name (e.g. "v1.2.3").
+/// The commit SHA the tag points at.
+public sealed record GitTag(string Name, string Sha);
+
+///
+/// A published release.
+///
+/// The provider-side release identifier.
+/// The tag the release was published from.
+/// The web URL of the release.
+public sealed record GitRelease(string Id, string TagName, string HtmlUrl);
+
+///
+/// Request to create a repository from a template/scaffold repository.
+///
+public sealed record CreateRepositoryFromTemplate
+{
+ /// Gets the template repository to instantiate.
+ public required GitRepositoryRef Template { get; init; }
+
+ /// Gets the namespace the new repository is created under.
+ public required string Namespace { get; init; }
+
+ /// Gets the new repository's name.
+ public required string Name { get; init; }
+
+ /// Gets the optional repository description.
+ public string? Description { get; init; }
+
+ /// Gets whether the new repository is private. Defaults to true.
+ public bool Private { get; init; } = true;
+}
+
+///
+/// Request to publish a release.
+///
+public sealed record CreateGitRelease
+{
+ /// Gets the tag to release. Created at when absent.
+ public required string TagName { get; init; }
+
+ /// Gets the commit SHA to tag when the tag does not exist yet; defaults to the head of the default branch.
+ public string? TargetCommitSha { get; init; }
+
+ /// Gets the release title; defaults to the tag name.
+ public string? Title { get; init; }
+
+ /// Gets the release notes body, when any.
+ public string? Body { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs b/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs
new file mode 100644
index 0000000..7469d02
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Repositories/IGitRepositoryService.cs
@@ -0,0 +1,109 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+
+namespace Compendium.Abstractions.Git.Repositories;
+
+///
+/// Repository lifecycle and read operations: create from template, inspect
+/// contents, list commits/branches/tags, create tags and releases.
+///
+public interface IGitRepositoryService
+{
+ ///
+ /// Creates a repository from a template repository. Requires
+ /// .
+ ///
+ Task> CreateFromTemplateAsync(
+ GitConnection connection,
+ CreateRepositoryFromTemplate request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a repository by reference. Fails with Git.RepositoryNotFound
+ /// when absent or not visible to the credential.
+ ///
+ Task> GetAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the repositories of visible to the
+ /// connection's credential.
+ ///
+ Task>> ListAsync(
+ GitConnection connection,
+ string @namespace,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks whether exists in the repository at
+ /// (default branch when null). Used e.g. to
+ /// verify a repository finished bootstrapping before tagging a release.
+ ///
+ Task> FileExistsAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string path,
+ string? gitRef = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists recent commits, newest first.
+ ///
+ /// The connection to operate with.
+ /// The repository to read.
+ /// Optional branch/ref/SHA to list from; the default branch when null or empty.
+ /// Maximum number of commits returned (a single page).
+ /// A cancellation token.
+ Task>> ListCommitsAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string? reference,
+ int limit,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the repository's branches.
+ ///
+ Task>> ListBranchesAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a tag. When is null, tags the head
+ /// of the default branch. Requires
+ /// .
+ ///
+ Task> CreateTagAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ string tagName,
+ string? commitSha = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the repository's tags.
+ ///
+ Task>> ListTagsAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Publishes a release. Requires
+ /// .
+ ///
+ Task> CreateReleaseAsync(
+ GitConnection connection,
+ GitRepositoryRef repository,
+ CreateGitRelease request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs b/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs
new file mode 100644
index 0000000..b7b8347
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Stubs/NullGitServer.cs
@@ -0,0 +1,293 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.AccessControl;
+using Compendium.Abstractions.Git.Capabilities;
+using Compendium.Abstractions.Git.CiConfiguration;
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Environments;
+using Compendium.Abstractions.Git.Pipelines;
+using Compendium.Abstractions.Git.Protection;
+using Compendium.Abstractions.Git.Provisioning;
+using Compendium.Abstractions.Git.Repositories;
+using Compendium.Abstractions.Git.Webhooks;
+
+namespace Compendium.Abstractions.Git.Stubs;
+
+///
+/// Fail-fast stub for hosts without a configured git server: every operation
+/// returns Git.NotConfigured and the capability matrix is empty. Register
+/// it in the unconfigured DI branch so a misconfigured deployment fails loudly
+/// per-request instead of throwing, and never with fake data.
+///
+public sealed class NullGitServer :
+ IGitServer,
+ IGitCredentialBroker,
+ IGitRepositoryService,
+ IGitPipelineService,
+ IGitCiConfigurationService,
+ IGitEnvironmentService,
+ IGitBranchPolicyService,
+ IGitAccessControlService,
+ IGitWebhookService,
+ IGitWebhookIngestor,
+ IGitNamespaceProvisioner
+{
+ ///
+ /// The provider discriminator reported by the stub.
+ ///
+ public const string ProviderName = "null";
+
+ ///
+ public string Provider => ProviderName;
+
+ ///
+ public GitServerCapabilities Capabilities { get; } = new()
+ {
+ Provider = ProviderName,
+ Entries = new Dictionary(),
+ };
+
+ ///
+ public IGitCredentialBroker Credentials => this;
+
+ ///
+ public IGitRepositoryService Repositories => this;
+
+ ///
+ public IGitPipelineService Pipelines => this;
+
+ ///
+ public IGitCiConfigurationService CiConfiguration => this;
+
+ ///
+ public IGitEnvironmentService Environments => this;
+
+ ///
+ public IGitBranchPolicyService BranchPolicies => this;
+
+ ///
+ public IGitAccessControlService AccessControl => this;
+
+ ///
+ public IGitWebhookService Webhooks => this;
+
+ ///
+ public IGitWebhookIngestor WebhookIngestor => this;
+
+ ///
+ public IGitNamespaceProvisioner NamespaceProvisioner => this;
+
+ private static Result NotConfigured() => Result.Failure(GitErrors.NotConfigured());
+
+ private static Result NotConfigured() => Result.Failure(GitErrors.NotConfigured());
+
+ private static Task NotConfiguredTask() => Task.FromResult(NotConfigured());
+
+ private static Task> NotConfiguredTask() => Task.FromResult(NotConfigured());
+
+ ///
+ public Task> MintAsync(
+ GitConnection connection, GitAccessTokenScope? scope = null, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> ValidateAsync(
+ GitConnection connection, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> ResolveAppInstallationAsync(
+ string @namespace, string? appKey = null, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListAppInstallationsAsync(
+ string? appKey = null, CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> CreateFromTemplateAsync(
+ GitConnection connection, CreateRepositoryFromTemplate request, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> GetAsync(
+ GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListAsync(
+ GitConnection connection, string @namespace, CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> FileExistsAsync(
+ GitConnection connection, GitRepositoryRef repository, string path, string? gitRef = null,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListCommitsAsync(
+ GitConnection connection, GitRepositoryRef repository, string? reference, int limit,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task>> ListBranchesAsync(
+ GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> CreateTagAsync(
+ GitConnection connection, GitRepositoryRef repository, string tagName, string? commitSha = null,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListTagsAsync(
+ GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> CreateReleaseAsync(
+ GitConnection connection, GitRepositoryRef repository, CreateGitRelease request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> TriggerAsync(
+ GitConnection connection, GitRepositoryRef repository, TriggerGitPipeline request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> GetRunAsync(
+ GitConnection connection, GitRepositoryRef repository, string runId,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListRunsAsync(
+ GitConnection connection, GitRepositoryRef repository, ListGitPipelineRuns query,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task SetSecretsAsync(
+ GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary secrets,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task DeleteSecretAsync(
+ GitConnection connection, GitConfigurationScope scope, string name,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task SetVariablesAsync(
+ GitConnection connection, GitConfigurationScope scope, IReadOnlyDictionary variables,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task DeleteVariableAsync(
+ GitConnection connection, GitConfigurationScope scope, string name,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> EnsureAsync(
+ GitConnection connection, GitRepositoryRef repository, EnsureGitEnvironment request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task DeleteAsync(
+ GitConnection connection, GitRepositoryRef repository, string environmentName,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task>> ListAsync(
+ GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken = default)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> ApplyAsync(
+ GitConnection connection, GitRepositoryRef repository, GitBranchPolicyRequest request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task RemoveAsync(
+ GitConnection connection, GitRepositoryRef repository, string policyId,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ Task>> IGitBranchPolicyService.ListAsync(
+ GitConnection connection, GitRepositoryRef repository, CancellationToken cancellationToken)
+ => NotConfiguredTask>();
+
+ ///
+ public Task> EnsureTeamAsync(
+ GitConnection connection, string @namespace, EnsureGitTeam request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task AddTeamMemberAsync(
+ GitConnection connection, string @namespace, string teamSlug, string username, GitTeamRole role,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task SetTeamRepositoryRoleAsync(
+ GitConnection connection, string @namespace, string teamSlug, GitRepositoryRef repository,
+ GitRepositoryRole role, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task SetUserRepositoryRoleAsync(
+ GitConnection connection, GitRepositoryRef repository, string username, GitRepositoryRole role,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task RemoveUserFromRepositoryAsync(
+ GitConnection connection, GitRepositoryRef repository, string username,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task> EnsureAsync(
+ GitConnection connection, GitWebhookTarget target, EnsureGitWebhook request,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ public Task DeleteAsync(
+ GitConnection connection, GitWebhookTarget target, string subscriptionId,
+ CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+
+ ///
+ Task>> IGitWebhookService.ListAsync(
+ GitConnection connection, GitWebhookTarget target, CancellationToken cancellationToken)
+ => NotConfiguredTask>();
+
+ ///
+ public Result Parse(GitWebhookDelivery delivery, string secret)
+ => NotConfigured();
+
+ ///
+ public Task> CreateNamespaceAsync(
+ GitConnection connection, CreateGitNamespace request, CancellationToken cancellationToken = default)
+ => NotConfiguredTask();
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs
new file mode 100644
index 0000000..35ee182
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookIngestor.cs
@@ -0,0 +1,148 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Webhooks;
+
+///
+/// Inbound side of webhooks: verifies a raw delivery's signature and parses it
+/// into the neutral union. Pure and synchronous —
+/// it owns no HTTP endpoint; the host maps its own route and hands the raw
+/// body/headers here. Requires
+/// .
+///
+public interface IGitWebhookIngestor
+{
+ ///
+ /// Verifies the delivery signature against
+ /// (fail-closed: missing or invalid signatures return
+ /// Git.WebhookSignatureInvalid) and parses the payload. Event types
+ /// the platform does not consume parse to
+ /// — callers acknowledge those
+ /// with a 2xx and never retry.
+ ///
+ Result Parse(GitWebhookDelivery delivery, string secret);
+}
+
+///
+/// A raw inbound webhook delivery as received over HTTP.
+///
+public sealed record GitWebhookDelivery
+{
+ ///
+ /// Gets the raw request body exactly as received — signature verification
+ /// runs over these bytes, so the body must not be re-serialized upstream.
+ ///
+ public required string Body { get; init; }
+
+ ///
+ /// Gets the request headers. Lookups are case-insensitive; hosts should
+ /// build the dictionary with .
+ ///
+ public required IReadOnlyDictionary Headers { get; init; }
+}
+
+///
+/// A neutral, provider-agnostic inbound webhook event, as a closed union for
+/// exhaustive pattern matching.
+///
+public abstract record GitWebhookEvent
+{
+ ///
+ /// Gets the provider-assigned delivery identifier, used for idempotent
+ /// processing (the same delivery may be received more than once).
+ ///
+ public required string DeliveryId { get; init; }
+
+ ///
+ /// Gets the repository the event concerns, when the event is repository-scoped.
+ ///
+ public GitRepositoryRef? Repository { get; init; }
+
+ ///
+ /// Commits were pushed to a branch.
+ ///
+ /// The pushed reference (e.g. "refs/heads/main").
+ /// The SHA of the new head commit.
+ public sealed record Push(string Reference, string HeadCommitSha) : GitWebhookEvent;
+
+ ///
+ /// A tag was pushed.
+ ///
+ /// The tag name.
+ /// The SHA the tag points at.
+ public sealed record TagPushed(string Tag, string CommitSha) : GitWebhookEvent;
+
+ ///
+ /// A pull/merge request changed state.
+ ///
+ /// The provider-native action (e.g. "opened", "closed").
+ /// The pull request number.
+ /// The source branch.
+ /// The target branch.
+ public sealed record PullRequestChanged(
+ string Action,
+ int Number,
+ string SourceReference,
+ string TargetReference) : GitWebhookEvent;
+
+ ///
+ /// A pipeline run reached a terminal state.
+ ///
+ /// The run identifier.
+ /// The pipeline the run belongs to.
+ /// The neutral terminal status.
+ /// The git reference the run executed on.
+ public sealed record PipelineRunCompleted(
+ string RunId,
+ string Pipeline,
+ Pipelines.GitPipelineStatus Status,
+ string Reference) : GitWebhookEvent;
+
+ ///
+ /// The platform app's installation on an account changed (installed,
+ /// suspended, unsuspended, repositories added/removed, or uninstalled).
+ ///
+ /// The account login the installation lives on.
+ /// Whether that account is an organization or a user.
+ /// The provider-side installation identifier.
+ /// The neutral change kind.
+ public sealed record ConnectionChanged(
+ string Namespace,
+ Connections.GitAccountType AccountType,
+ string InstallationId,
+ GitConnectionChangeKind Change) : GitWebhookEvent;
+
+ ///
+ /// An event type the platform does not consume. Acknowledge with a 2xx and
+ /// do not retry.
+ ///
+ /// The provider-native event type name.
+ public sealed record Unsupported(string ProviderEventType) : GitWebhookEvent;
+}
+
+///
+/// The kind of change reported by .
+///
+public enum GitConnectionChangeKind
+{
+ /// The app was installed on the account.
+ Installed,
+
+ /// The installation was suspended.
+ Suspended,
+
+ /// The installation was unsuspended.
+ Unsuspended,
+
+ /// The set of repositories the installation can access changed.
+ RepositoriesChanged,
+
+ /// The app was uninstalled from the account.
+ Uninstalled,
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs
new file mode 100644
index 0000000..2b4ad19
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Git/Webhooks/IGitWebhookService.cs
@@ -0,0 +1,108 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Git.Connections;
+using Compendium.Abstractions.Git.Repositories;
+
+namespace Compendium.Abstractions.Git.Webhooks;
+
+///
+/// Outgoing webhook subscription management on a repository or namespace.
+/// Requires .
+///
+public interface IGitWebhookService
+{
+ ///
+ /// Creates the subscription when absent (matched by URL), updates it
+ /// otherwise (idempotent).
+ ///
+ Task> EnsureAsync(
+ GitConnection connection,
+ GitWebhookTarget target,
+ EnsureGitWebhook request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a subscription by identifier. Deleting an absent subscription
+ /// succeeds (idempotent).
+ ///
+ Task DeleteAsync(
+ GitConnection connection,
+ GitWebhookTarget target,
+ string subscriptionId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the target's webhook subscriptions.
+ ///
+ Task>> ListAsync(
+ GitConnection connection,
+ GitWebhookTarget target,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Where a webhook subscription lives, as a closed union.
+///
+public abstract record GitWebhookTarget
+{
+ private GitWebhookTarget()
+ {
+ }
+
+ ///
+ /// A repository-level webhook.
+ ///
+ /// The repository.
+ public sealed record Repository(GitRepositoryRef Ref) : GitWebhookTarget;
+
+ ///
+ /// A namespace-level (organization/group) webhook.
+ ///
+ /// The namespace login.
+ public sealed record Namespace(string Name) : GitWebhookTarget;
+}
+
+///
+/// Request to create or update a webhook subscription.
+///
+public sealed record EnsureGitWebhook
+{
+ /// Gets the delivery URL.
+ public required Uri Url { get; init; }
+
+ ///
+ /// Gets the shared secret used to sign deliveries. Redacted in ToString().
+ ///
+ public required string Secret { get; init; }
+
+ ///
+ /// Gets the provider-native event names to subscribe to (e.g. GitHub
+ /// "push", "workflow_run"). Each adapter documents its
+ /// accepted names.
+ ///
+ public required IReadOnlyList Events { get; init; }
+
+ /// Gets whether the subscription is active. Defaults to true.
+ public bool Active { get; init; } = true;
+
+ ///
+ public override string ToString() => $"EnsureGitWebhook(Url={Url}, Secret=***, Events=[{string.Join(", ", Events)}])";
+}
+
+///
+/// A webhook subscription as reported by the provider.
+///
+/// The provider-side subscription identifier.
+/// The delivery URL.
+/// The provider-native event names subscribed to.
+/// Whether the subscription is active.
+public sealed record GitWebhookSubscription(
+ string Id,
+ Uri Url,
+ IReadOnlyList Events,
+ bool Active);
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs
new file mode 100644
index 0000000..2492e5e
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/AuthorizedConnection.cs
@@ -0,0 +1,20 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.GitHub.Auth;
+
+///
+/// A minted bearer token paired with the resolved REST API base URL for a
+/// connection — the authorization preamble the REST-backed services share.
+///
+/// The bearer token to send. Redacted in .
+/// The trailing-slashed REST API base URL for the connection.
+internal sealed record AuthorizedConnection(string Token, Uri ApiBase)
+{
+ ///
+ public override string ToString() => $"AuthorizedConnection(***, {ApiBase})";
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs
new file mode 100644
index 0000000..091cb31
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubAppTokenService.cs
@@ -0,0 +1,256 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Compendium.Adapters.GitHub.Configuration;
+using Compendium.Adapters.GitHub.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Compendium.Adapters.GitHub.Auth;
+
+///
+/// Mints GitHub App credentials: signs App-level JWTs (RS256) with a registration's
+/// private key and exchanges them for installation access tokens via
+/// POST /app/installations/{id}/access_tokens. Unscoped tokens are cached
+/// per (app, installation) and refreshed shortly before expiry; a stale-JWT 401 on
+/// mint is retried once with a freshly signed JWT. Independent of Octokit so the
+/// minted token works against REST, GraphQL, and git-over-HTTPS alike.
+///
+internal sealed class GitHubAppTokenService
+{
+ private static readonly TimeSpan JwtLifetime = TimeSpan.FromMinutes(9);
+ private static readonly TimeSpan JwtBackdate = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal);
+
+ public GitHubAppTokenService(IHttpClientFactory httpClientFactory, ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Signs an App-level JWT (RS256) for with
+ /// iss = the app id and a lifetime under GitHub's 10-minute ceiling.
+ ///
+ public Result CreateAppJwt(GitHubAppRegistration app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ if (string.IsNullOrWhiteSpace(app.AppId))
+ {
+ return Result.Failure(Error.Failure(
+ "GitHubApp.AppIdMissing", "The GitHub App registration has no AppId configured."));
+ }
+
+ if (string.IsNullOrWhiteSpace(app.PrivateKeyPem))
+ {
+ return Result.Failure(Error.Failure(
+ "GitHubApp.PrivateKeyMissing", "The GitHub App registration has no private key configured."));
+ }
+
+ try
+ {
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(app.PrivateKeyPem);
+
+ var now = DateTimeOffset.UtcNow;
+ var payload = new
+ {
+ iat = now.Subtract(JwtBackdate).ToUnixTimeSeconds(),
+ exp = now.Add(JwtLifetime).ToUnixTimeSeconds(),
+ iss = app.AppId,
+ };
+
+ var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "RS256", typ = "JWT" }));
+ var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload));
+ var signingInput = $"{encodedHeader}.{encodedPayload}";
+
+ var signature = rsa.SignData(
+ Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ return Result.Success($"{signingInput}.{Base64UrlEncode(signature)}");
+ }
+ catch (Exception ex) when (ex is CryptographicException or ArgumentException or FormatException)
+ {
+ _logger.LogError(ex, "Failed to sign a GitHub App JWT");
+ return Result.Failure(Error.Failure(
+ "GitHubApp.JwtSigningFailed", $"Failed to sign the GitHub App JWT: {ex.Message}"));
+ }
+ }
+
+ ///
+ /// Returns an installation access token for ,
+ /// serving a cached unscoped token when it is still fresh. Scoped mints
+ /// ( non-null) always bypass the cache.
+ ///
+ public async Task> GetInstallationTokenAsync(
+ GitHubAppRegistration app,
+ string? appKey,
+ Uri apiBaseUrl,
+ string installationId,
+ GitAccessTokenScope? scope,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ ArgumentNullException.ThrowIfNull(apiBaseUrl);
+
+ if (string.IsNullOrWhiteSpace(installationId))
+ {
+ return Result.Failure(Error.Validation(
+ "GitHubApp.InstallationIdRequired", "A GitHub App installation id is required."));
+ }
+
+ var cacheKey = $"{appKey ?? "default"}:{installationId}";
+ if (scope is null
+ && _cache.TryGetValue(cacheKey, out var cached)
+ && cached.ExpiresAt > DateTimeOffset.UtcNow.Add(RefreshSkew))
+ {
+ return Result.Success(cached);
+ }
+
+ var minted = await MintAsync(app, apiBaseUrl, installationId, scope, retryOnUnauthorized: true, cancellationToken)
+ .ConfigureAwait(false);
+ if (minted.IsFailure)
+ {
+ return minted;
+ }
+
+ if (scope is null)
+ {
+ _cache[cacheKey] = minted.Value;
+ }
+
+ return minted;
+ }
+
+ /// Drops any cached token for an (app, installation) pair.
+ public void Invalidate(string? appKey, string installationId) =>
+ _cache.TryRemove($"{appKey ?? "default"}:{installationId}", out _);
+
+ private async Task> MintAsync(
+ GitHubAppRegistration app,
+ Uri apiBaseUrl,
+ string installationId,
+ GitAccessTokenScope? scope,
+ bool retryOnUnauthorized,
+ CancellationToken cancellationToken)
+ {
+ var jwtResult = CreateAppJwt(app);
+ if (jwtResult.IsFailure)
+ {
+ return Result.Failure(jwtResult.Error);
+ }
+
+ try
+ {
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post,
+ new Uri(apiBaseUrl, $"app/installations/{Uri.EscapeDataString(installationId)}/access_tokens"));
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtResult.Value);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ request.Headers.Add("X-GitHub-Api-Version", GitHubDefaults.ApiVersion);
+ request.Headers.UserAgent.Add(new ProductInfoHeaderValue(GitHubDefaults.ProductName, "1.0"));
+
+ var body = BuildScopeBody(scope);
+ if (body is not null)
+ {
+ request.Content = JsonContent.Create(body);
+ }
+
+ var http = _httpClientFactory.CreateClient(GitHubDefaults.HttpClientName);
+ using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ // A stale App JWT (clock skew) reads as 401; re-sign once and retry.
+ if (response.StatusCode == HttpStatusCode.Unauthorized && retryOnUnauthorized)
+ {
+ _logger.LogWarning(
+ "GitHub rejected the App JWT minting installation {InstallationId}; re-signing and retrying once.",
+ installationId);
+ return await MintAsync(app, apiBaseUrl, installationId, scope, retryOnUnauthorized: false, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return Result.Failure(GitHubErrorMapper.FromStatus(
+ (int)response.StatusCode,
+ GitRestErrorContext.None,
+ $"installation token request returned {(int)response.StatusCode}"));
+ }
+
+ var payload = await response.Content
+ .ReadFromJsonAsync(cancellationToken).ConfigureAwait(false);
+
+ if (payload is null || string.IsNullOrEmpty(payload.Token))
+ {
+ return Result.Failure(Error.Failure(
+ "GitHubApp.InstallationTokenMalformed", "GitHub returned an empty installation-token payload."));
+ }
+
+ return Result.Success(new GitAccessToken
+ {
+ Token = payload.Token,
+ ExpiresAt = payload.ExpiresAt,
+ HttpBasicUsername = GitHubDefaults.HttpBasicUsername,
+ });
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, "HTTP failure minting installation token for {InstallationId}", installationId);
+ return Result.Failure(Error.Failure(
+ "GitHubApp.InstallationTokenNetwork", $"HTTP error minting the installation token: {ex.Message}"));
+ }
+ }
+
+ private static object? BuildScopeBody(GitAccessTokenScope? scope)
+ {
+ if (scope is null)
+ {
+ return null;
+ }
+
+ var body = new Dictionary(StringComparer.Ordinal);
+
+ // The create-installation-token endpoint accepts repository NAMES (which the
+ // neutral scope carries) as well as numeric repository_ids; names avoid a lookup.
+ if (scope.Repositories is { Count: > 0 } repositories)
+ {
+ body["repositories"] = repositories.Select(r => r.Name).ToArray();
+ }
+
+ if (scope.Permissions is { Count: > 0 } permissions)
+ {
+ body["permissions"] = permissions.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
+ }
+
+ return body.Count > 0 ? body : null;
+ }
+
+ private static string Base64UrlEncode(byte[] input) =>
+ Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private sealed class InstallationTokenResponse
+ {
+ [JsonPropertyName("token")]
+ public string Token { get; init; } = string.Empty;
+
+ [JsonPropertyName("expires_at")]
+ public DateTimeOffset ExpiresAt { get; init; }
+ }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs
new file mode 100644
index 0000000..a2089cd
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Auth/GitHubCredentialBroker.cs
@@ -0,0 +1,227 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Adapters.GitHub.Configuration;
+using Compendium.Adapters.GitHub.Http;
+using Microsoft.Extensions.Options;
+
+namespace Compendium.Adapters.GitHub.Auth;
+
+///
+/// The GitHub auth seam. Mints short-lived installation tokens from the platform
+/// App's private key for connections,
+/// and passes through caller-supplied service-account / PAT / OAuth tokens.
+/// Validates credentials and discovers App installations across accounts.
+///
+internal sealed class GitHubCredentialBroker : IGitCredentialBroker
+{
+ private static readonly TimeSpan DurableTokenLifetime = TimeSpan.FromDays(3650);
+ private static readonly TimeSpan OAuthTokenLifetime = TimeSpan.FromHours(8);
+
+ private readonly GitHubAdapterOptions _options;
+ private readonly GitHubAppTokenService _tokenService;
+ private readonly GitHubRestExecutor _rest;
+
+ public GitHubCredentialBroker(
+ IOptions options,
+ GitHubAppTokenService tokenService,
+ GitHubRestExecutor rest)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ _options = options.Value;
+ _tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService));
+ _rest = rest ?? throw new ArgumentNullException(nameof(rest));
+ }
+
+ ///
+ /// Mints an unscoped token for a connection and pairs it with the connection's
+ /// resolved API base URL — the common preamble for the REST-backed services.
+ ///
+ public async Task> AuthorizeAsync(
+ GitConnection connection, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ var mint = await MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false);
+ return mint.IsFailure
+ ? Result.Failure(mint.Error)
+ : Result.Success(new AuthorizedConnection(mint.Value.Token, connection.ApiBase(_options)));
+ }
+
+ ///
+ public Task> MintAsync(
+ GitConnection connection, GitAccessTokenScope? scope = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ switch (connection.Credential)
+ {
+ case GitCredential.AppInstallation app:
+ if (_options.ResolveApp(app.AppKey) is not { } registration)
+ {
+ return Task.FromResult(Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider)));
+ }
+
+ return _tokenService.GetInstallationTokenAsync(
+ registration, app.AppKey, connection.ApiBase(_options), app.InstallationId, scope, cancellationToken);
+
+ case GitCredential.ServiceAccountToken sat:
+ return Task.FromResult(Result.Success(PassThrough(sat.Token, DurableTokenLifetime)));
+
+ case GitCredential.PersonalAccessToken pat:
+ return Task.FromResult(Result.Success(PassThrough(pat.Token, DurableTokenLifetime)));
+
+ case GitCredential.OAuthAccessToken oauth:
+ return Task.FromResult(Result.Success(PassThrough(oauth.AccessToken, OAuthTokenLifetime)));
+
+ default:
+ return Task.FromResult(Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider)));
+ }
+ }
+
+ ///
+ public async Task> ValidateAsync(
+ GitConnection connection, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ if (connection.Credential is GitCredential.AppInstallation app)
+ {
+ if (_options.ResolveApp(app.AppKey) is not { } registration)
+ {
+ return Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider));
+ }
+
+ var jwt = _tokenService.CreateAppJwt(registration);
+ if (jwt.IsFailure)
+ {
+ return Result.Failure(jwt.Error);
+ }
+
+ var installation = await _rest.GetAsync(
+ connection.ApiBase(_options), jwt.Value,
+ $"app/installations/{Uri.EscapeDataString(app.InstallationId)}",
+ GitRestErrorContext.None, cancellationToken).ConfigureAwait(false);
+
+ if (installation.IsFailure)
+ {
+ return Result.Failure(installation.Error);
+ }
+
+ var account = installation.Value.Account;
+ return Result.Success(new GitConnectionIdentity(
+ account?.Login ?? string.Empty,
+ account?.ToAccountType() ?? GitAccountType.Organization,
+ account?.Name));
+ }
+
+ var mint = await MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false);
+ if (mint.IsFailure)
+ {
+ return Result.Failure(mint.Error);
+ }
+
+ var user = await _rest.GetAsync(
+ connection.ApiBase(_options), mint.Value.Token, "user", GitRestErrorContext.None, cancellationToken)
+ .ConfigureAwait(false);
+
+ return user.IsFailure
+ ? Result.Failure(user.Error)
+ : Result.Success(new GitConnectionIdentity(user.Value.Login, user.Value.ToAccountType(), user.Value.Name));
+ }
+
+ ///
+ public async Task> ResolveAppInstallationAsync(
+ string @namespace, string? appKey = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(@namespace);
+
+ if (_options.ResolveApp(appKey) is not { } registration)
+ {
+ return Result.Failure(GitErrors.NotConfigured(GitHubDefaults.Provider));
+ }
+
+ var jwt = _tokenService.CreateAppJwt(registration);
+ if (jwt.IsFailure)
+ {
+ return Result.Failure(jwt.Error);
+ }
+
+ var apiBase = GitHubDefaults.EnsureTrailingSlash(_options.ApiBaseUrl);
+ var escaped = Uri.EscapeDataString(@namespace);
+
+ var org = await _rest.GetAsync(
+ apiBase, jwt.Value, $"orgs/{escaped}/installation", GitRestErrorContext.None, cancellationToken)
+ .ConfigureAwait(false);
+ if (org.IsSuccess)
+ {
+ return Result.Success(org.Value.ToInstallationInfo());
+ }
+
+ var user = await _rest.GetAsync(
+ apiBase, jwt.Value, $"users/{escaped}/installation", GitRestErrorContext.None, cancellationToken)
+ .ConfigureAwait(false);
+ if (user.IsSuccess)
+ {
+ return Result.Success(user.Value.ToInstallationInfo());
+ }
+
+ return Result.Failure(GitErrors.AppNotInstalled(@namespace, BuildInstallUrl(registration)));
+ }
+
+ ///
+ public async Task>> ListAppInstallationsAsync(
+ string? appKey = null, CancellationToken cancellationToken = default)
+ {
+ if (_options.ResolveApp(appKey) is not { } registration)
+ {
+ return Result.Failure>(GitErrors.NotConfigured(GitHubDefaults.Provider));
+ }
+
+ var jwt = _tokenService.CreateAppJwt(registration);
+ if (jwt.IsFailure)
+ {
+ return Result.Failure>(jwt.Error);
+ }
+
+ var apiBase = GitHubDefaults.EnsureTrailingSlash(_options.ApiBaseUrl);
+ const int perPage = 100;
+ var installations = new List();
+
+ for (var page = 1; ; page++)
+ {
+ var pageResult = await _rest.GetAsync>(
+ apiBase, jwt.Value, $"app/installations?per_page={perPage}&page={page}",
+ GitRestErrorContext.None, cancellationToken).ConfigureAwait(false);
+
+ if (pageResult.IsFailure)
+ {
+ return Result.Failure>(pageResult.Error);
+ }
+
+ installations.AddRange(pageResult.Value.Select(i => i.ToInstallationInfo()));
+
+ if (pageResult.Value.Count < perPage)
+ {
+ break;
+ }
+ }
+
+ return Result.Success>(installations);
+ }
+
+ private static GitAccessToken PassThrough(string token, TimeSpan lifetime) => new()
+ {
+ Token = token,
+ ExpiresAt = DateTimeOffset.UtcNow.Add(lifetime),
+ HttpBasicUsername = GitHubDefaults.HttpBasicUsername,
+ };
+
+ private static string? BuildInstallUrl(GitHubAppRegistration registration) =>
+ string.IsNullOrWhiteSpace(registration.AppSlug)
+ ? null
+ : $"https://github.com/apps/{registration.AppSlug}/installations/new";
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md b/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md
new file mode 100644
index 0000000..0c474e9
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/CAPABILITIES.md
@@ -0,0 +1,36 @@
+# Compendium.Adapters.GitHub — capability matrix
+
+Provider discriminator: **`github`**. Targets github.com and GitHub Enterprise
+Server (set `GitConnection.ServerUrl` to the GHES API base).
+
+Levels: **Full** — supported without caveats · **Partial** — supported with the
+noted limitation · **None** — not reachable by this adapter (with the reason).
+
+| Capability | Level | GitHub mechanism | Limitations |
+|---|---|---|---|
+| `RepositoryFromTemplate` | Full | `POST /repos/{template_owner}/{template_repo}/generate` | — |
+| `RepositoryManagement` | Full | `GET /repos/*`, contents, commits, branches | — |
+| `TagsAndReleases` | Full | `POST /repos/{o}/{r}/git/refs` (tags), `POST .../releases` | Tag creation makes a lightweight git ref; a release is a separate `CreateReleaseAsync` call. |
+| `CiSecrets` | Full | Actions repo secrets, libsodium sealed box | Write-only (values never read back). |
+| `CiVariables` | Full | Actions repo variables | — |
+| `NamespaceSecrets` | Full | Actions org secrets (`visibility: all`), sealed box | Org variables/secrets default to `all` visibility. |
+| `PipelineTrigger` | **Partial** | `POST /repos/{o}/{r}/actions/workflows/{wf}/dispatches` | `workflow_dispatch` returns **204 with no run id** — `GitPipelineRunHandle.RunId` is `null`; correlate the created run via `ListRunsAsync`. |
+| `PipelineStatus` | Full | `GET .../actions/runs/{id}`, `.../runs` | Status/conclusion mapped onto `GitPipelineStatus` (unmapped states → `Unknown`). |
+| `DeploymentEnvironments` | Full | `PUT/GET/DELETE /repos/{o}/{r}/environments/{name}` | `PUT` is create-or-update (idempotent). |
+| `EnvironmentSecrets` | Full | Environment secrets keyed by numeric repository id, sealed box | The repository id is resolved with an extra `GET /repos/{o}/{r}`. |
+| `BranchPolicies` | Full | Repository **rulesets** (`/repos/{o}/{r}/rulesets`) | Neutral policy → one ruleset named `compendium:{pattern}`; not classic branch protection. Admin bypass added when `EnforceForAdmins` is false. |
+| `TeamsAndPermissions` | Full | Teams, team membership, `AddOrUpdateTeamRepositoryPermissions`, collaborators | Organization namespaces only; team ops on a user account fail as a mapped provider error. Roles map to `pull/triage/push/maintain/admin`. |
+| `WebhookManagement` | Full | Repository and organization hooks | Matched by delivery URL for idempotent ensure. |
+| `WebhookIngestion` | Full | `X-Hub-Signature-256` HMAC-SHA256 over the raw body | Fail-closed: missing/invalid signature → `Git.WebhookSignatureInvalid`. Consumed events: `push` (incl. tag pushes), `pull_request`, `workflow_run` (completed), `installation`, `installation_repositories`; everything else → `Unsupported`. |
+| `NamespaceProvisioning` | **None** | — | github.com organization creation requires an **enterprise-owner user token**; unreachable with App credentials. `CreateNamespaceAsync` always returns `Git.CapabilityNotSupported`. |
+| `AppInstallationAuth` | Full | App JWT (RS256) → installation access token | Tokens cached per (app, installation), refreshed 60 s before expiry; a stale-JWT 401 on mint is retried once. |
+| `ServiceAccountAuth` | Full | Bearer pass-through of a durable token | Reported with a far-future expiry. |
+| `OAuthUserAuth` | Full | Bearer pass-through of an OAuth user token | Reported with an 8-hour expiry. |
+| `ScopedTokenMinting` | Full | `repositories` + `permissions` on the create-installation-token request | Scoped tokens bypass the token cache and are minted fresh each call. |
+
+## Token scoping note
+
+The neutral `GitAccessTokenScope` carries repository **references** (namespace/name).
+GitHub's create-installation-access-token endpoint accepts either numeric
+`repository_ids` or a `repositories` array of names; the adapter sends the names,
+avoiding an id lookup. At most 500 repositories may be listed.
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj b/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj
new file mode 100644
index 0000000..0e3fb14
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Compendium.Adapters.GitHub.csproj
@@ -0,0 +1,36 @@
+
+
+
+ Compendium.Adapters.GitHub
+ Compendium.Adapters.GitHub
+ Compendium.Adapters.GitHub
+ GitHub adapter for Compendium Framework: a concrete IGitServer implementing the Compendium.Abstractions.Git ports against github.com and GitHub Enterprise Server. Provides GitHub App installation-token auth (RS256 App JWT plus per-installation token minting and caching), repository/tag/release management, CI secrets (libsodium sealed-box encrypted) and variables, deployment environments, repository-ruleset branch policies, teams and collaborators, outbound webhook subscriptions, and fail-closed HMAC-SHA256 inbound webhook ingestion.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs b/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs
new file mode 100644
index 0000000..19777a9
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Configuration/GitHubAdapterOptions.cs
@@ -0,0 +1,99 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.GitHub.Configuration;
+
+///
+/// Options for the GitHub adapter. Holds the platform's GitHub App registration(s)
+/// used to mint installation tokens and to build install URLs, plus the REST API
+/// base URL for github.com (overridden per connection for GitHub Enterprise Server
+/// via ).
+///
+public sealed class GitHubAdapterOptions
+{
+ ///
+ /// Gets or sets the default GitHub App registration, selected when a
+ /// carries a
+ /// AppKey. Its private key and webhook secret are sensitive — bind them
+ /// from a secret store, never from source or plaintext configuration files.
+ ///
+ public GitHubAppRegistration DefaultApp { get; set; } = new();
+
+ ///
+ /// Gets the additional named App registrations, selected by
+ /// . Reserved for the
+ /// per-instance manifest-creation flow; empty in v1.
+ ///
+ public Dictionary Apps { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets or sets the github.com REST API base URL. Defaults to
+ /// https://api.github.com; a connection's
+ /// overrides it for GHES.
+ ///
+ public Uri ApiBaseUrl { get; set; } = GitHubDefaults.DefaultApiBase;
+
+ ///
+ /// Resolves a registration by key: the when
+ /// is , otherwise the entry in
+ /// . Returns when a non-null key has
+ /// no registration.
+ ///
+ internal GitHubAppRegistration? ResolveApp(string? appKey)
+ {
+ if (appKey is null)
+ {
+ return DefaultApp;
+ }
+
+ return Apps.TryGetValue(appKey, out var app) ? app : null;
+ }
+}
+
+///
+/// A single GitHub App registration: the identity and private key used to mint
+/// App JWTs and installation tokens, the slug used to build the install URL, and
+/// the webhook signing secret.
+///
+public sealed class GitHubAppRegistration
+{
+ ///
+ /// Gets or sets the numeric GitHub App id, used as the App JWT iss claim.
+ ///
+ public string AppId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the App's URL slug (the segment after
+ /// https://github.com/apps/), used to build the install URL surfaced by
+ /// Git.AppNotInstalled.
+ ///
+ public string AppSlug { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the OAuth client id the App advertises, used for the optional
+ /// user-authorization leg of the install flow.
+ ///
+ public string ClientId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the OAuth client secret paired with .
+ /// Sensitive — bind from a secret store.
+ ///
+ public string ClientSecret { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the PEM-encoded RSA private key used to sign App JWTs.
+ /// Sensitive — bind from a secret store and keep out of logs.
+ ///
+ public string PrivateKeyPem { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the shared secret used to HMAC-verify inbound webhook
+ /// deliveries. Rotated independently of .
+ ///
+ public string WebhookSecret { get; set; } = string.Empty;
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs b/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..4ef8a8b
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,85 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Adapters.GitHub.Auth;
+using Compendium.Adapters.GitHub.Configuration;
+using Compendium.Adapters.GitHub.Http;
+using Compendium.Adapters.GitHub.Security;
+using Compendium.Adapters.GitHub.Services;
+using Compendium.Adapters.GitHub.Webhooks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Compendium.Adapters.GitHub.DependencyInjection;
+
+///
+/// DI extensions for registering the GitHub adapter.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers the GitHub git-server adapter and its concern-scoped ports. The
+ /// facade is contributed to IEnumerable<IGitServer> for
+ /// provider-dispatch consumers, and each port is also resolvable directly for
+ /// single-concern consumers.
+ ///
+ /// The service collection.
+ /// An optional callback to configure adapter options.
+ /// The service collection, for chaining.
+ public static IServiceCollection AddGitHubGitServer(
+ this IServiceCollection services, Action? configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+ else
+ {
+ services.AddOptions();
+ }
+
+ // A named HttpClient backs both installation-token minting and the raw REST executor.
+ services.AddHttpClient(GitHubDefaults.HttpClientName);
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ // The facade participates in provider dispatch.
+ services.TryAddEnumerable(ServiceDescriptor.Singleton(
+ sp => sp.GetRequiredService()));
+
+ // Each concern-scoped port is resolvable on its own for single-concern consumers.
+ services.TryAddSingleton(sp => sp.GetRequiredService().Credentials);
+ services.TryAddSingleton(sp => sp.GetRequiredService().Repositories);
+ services.TryAddSingleton(sp => sp.GetRequiredService().Pipelines);
+ services.TryAddSingleton(sp => sp.GetRequiredService().CiConfiguration);
+ services.TryAddSingleton(sp => sp.GetRequiredService().Environments);
+ services.TryAddSingleton(sp => sp.GetRequiredService().BranchPolicies);
+ services.TryAddSingleton(sp => sp.GetRequiredService().AccessControl);
+ services.TryAddSingleton(sp => sp.GetRequiredService().Webhooks);
+ services.TryAddSingleton(sp => sp.GetRequiredService().WebhookIngestor);
+ services.TryAddSingleton(sp => sp.GetRequiredService().NamespaceProvisioner);
+
+ return services;
+ }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs
new file mode 100644
index 0000000..63c8e2e
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubCapabilities.cs
@@ -0,0 +1,55 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.GitHub;
+
+///
+/// The declared capability matrix for the GitHub adapter on github.com / GHES.
+/// The full support/limitation table lives in CAPABILITIES.md; this is its
+/// executable counterpart, shared by the facade and the sub-services that guard
+/// optional capabilities via .
+///
+internal static class GitHubCapabilities
+{
+ ///
+ /// Gets the singleton capability matrix. Every capability the adapter can
+ /// reach on github.com is , except
+ /// (Partial: workflow_dispatch
+ /// returns no run id) and
+ /// (None: org creation needs an enterprise-owner user token).
+ ///
+ public static GitServerCapabilities Matrix { get; } = new()
+ {
+ Provider = GitHubDefaults.Provider,
+ Entries = new Dictionary
+ {
+ [GitCapability.RepositoryFromTemplate] = new(GitCapabilityLevel.Full),
+ [GitCapability.RepositoryManagement] = new(GitCapabilityLevel.Full),
+ [GitCapability.TagsAndReleases] = new(GitCapabilityLevel.Full),
+ [GitCapability.CiSecrets] = new(GitCapabilityLevel.Full),
+ [GitCapability.CiVariables] = new(GitCapabilityLevel.Full),
+ [GitCapability.NamespaceSecrets] = new(GitCapabilityLevel.Full),
+ [GitCapability.PipelineTrigger] = new(
+ GitCapabilityLevel.Partial,
+ "workflow_dispatch returns no run id; correlate the created run via ListRuns."),
+ [GitCapability.PipelineStatus] = new(GitCapabilityLevel.Full),
+ [GitCapability.DeploymentEnvironments] = new(GitCapabilityLevel.Full),
+ [GitCapability.EnvironmentSecrets] = new(GitCapabilityLevel.Full),
+ [GitCapability.BranchPolicies] = new(GitCapabilityLevel.Full),
+ [GitCapability.TeamsAndPermissions] = new(GitCapabilityLevel.Full),
+ [GitCapability.WebhookManagement] = new(GitCapabilityLevel.Full),
+ [GitCapability.WebhookIngestion] = new(GitCapabilityLevel.Full),
+ [GitCapability.NamespaceProvisioning] = new(
+ GitCapabilityLevel.None,
+ "github.com organization creation requires an enterprise-owner user token; unreachable with App credentials."),
+ [GitCapability.AppInstallationAuth] = new(GitCapabilityLevel.Full),
+ [GitCapability.ServiceAccountAuth] = new(GitCapabilityLevel.Full),
+ [GitCapability.OAuthUserAuth] = new(GitCapabilityLevel.Full),
+ [GitCapability.ScopedTokenMinting] = new(GitCapabilityLevel.Full),
+ },
+ };
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs
new file mode 100644
index 0000000..95fa8a8
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubConnectionExtensions.cs
@@ -0,0 +1,26 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Adapters.GitHub.Configuration;
+
+namespace Compendium.Adapters.GitHub;
+
+///
+/// Helpers for resolving the REST API base URL a connection should target:
+/// a connection's (GHES) when present,
+/// otherwise the adapter's configured github.com base, always trailing-slashed.
+///
+internal static class GitHubConnectionExtensions
+{
+ /// Resolves the normalized API base URL for a connection.
+ public static Uri ApiBase(this GitConnection connection, GitHubAdapterOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentNullException.ThrowIfNull(options);
+ return GitHubDefaults.EnsureTrailingSlash(connection.ServerUrl ?? options.ApiBaseUrl);
+ }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs
new file mode 100644
index 0000000..7d2c0a6
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubDefaults.cs
@@ -0,0 +1,45 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.GitHub;
+
+///
+/// Shared constants for the GitHub adapter: the provider discriminator, the
+/// product/user-agent header, the named
+/// used for raw REST calls, and the GitHub REST API version pin.
+///
+internal static class GitHubDefaults
+{
+ /// The provider discriminator (matches ).
+ public const string Provider = "github";
+
+ /// The Octokit / user-agent product name. Must contain no spaces.
+ public const string ProductName = "compendium-git";
+
+ /// The named HttpClient used for raw REST calls the Octokit client lags on.
+ public const string HttpClientName = "compendium-github";
+
+ /// The REST API version pin sent as X-GitHub-Api-Version.
+ public const string ApiVersion = "2022-11-28";
+
+ /// The HTTP-basic username paired with a minted token for git-over-HTTPS.
+ public const string HttpBasicUsername = "x-access-token";
+
+ /// The github.com REST API base URL used when no override is configured.
+ public static readonly Uri DefaultApiBase = new("https://api.github.com");
+
+ ///
+ /// Returns with a trailing slash so relative paths combine
+ /// against it without truncating a GHES /api/v3 prefix.
+ ///
+ public static Uri EnsureTrailingSlash(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+ return uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/");
+ }
+}
+
diff --git a/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs b/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs
new file mode 100644
index 0000000..37a49e6
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/GitHubGitServer.cs
@@ -0,0 +1,82 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Adapters.GitHub.Auth;
+
+namespace Compendium.Adapters.GitHub;
+
+///
+/// The GitHub facade. Declares Provider = "github"
+/// and the capability matrix, and exposes the concern-scoped ports. Stateless and
+/// registered as a singleton — all per-tenant state travels in the
+/// passed to each port method.
+///
+internal sealed class GitHubGitServer : IGitServer
+{
+ /// The provider discriminator reported by the adapter.
+ public const string ProviderName = GitHubDefaults.Provider;
+
+ public GitHubGitServer(
+ GitHubCredentialBroker credentials,
+ Services.GitHubRepositoryService repositories,
+ Services.GitHubPipelineService pipelines,
+ Services.GitHubCiConfigurationService ciConfiguration,
+ Services.GitHubEnvironmentService environments,
+ Services.GitHubBranchPolicyService branchPolicies,
+ Services.GitHubAccessControlService accessControl,
+ Services.GitHubWebhookService webhooks,
+ Webhooks.GitHubWebhookIngestor webhookIngestor,
+ Services.GitHubNamespaceProvisioner namespaceProvisioner)
+ {
+ Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials));
+ Repositories = repositories ?? throw new ArgumentNullException(nameof(repositories));
+ Pipelines = pipelines ?? throw new ArgumentNullException(nameof(pipelines));
+ CiConfiguration = ciConfiguration ?? throw new ArgumentNullException(nameof(ciConfiguration));
+ Environments = environments ?? throw new ArgumentNullException(nameof(environments));
+ BranchPolicies = branchPolicies ?? throw new ArgumentNullException(nameof(branchPolicies));
+ AccessControl = accessControl ?? throw new ArgumentNullException(nameof(accessControl));
+ Webhooks = webhooks ?? throw new ArgumentNullException(nameof(webhooks));
+ WebhookIngestor = webhookIngestor ?? throw new ArgumentNullException(nameof(webhookIngestor));
+ NamespaceProvisioner = namespaceProvisioner ?? throw new ArgumentNullException(nameof(namespaceProvisioner));
+ }
+
+ ///
+ public string Provider => ProviderName;
+
+ ///
+ public GitServerCapabilities Capabilities => GitHubCapabilities.Matrix;
+
+ ///
+ public IGitCredentialBroker Credentials { get; }
+
+ ///
+ public IGitRepositoryService Repositories { get; }
+
+ ///
+ public IGitPipelineService Pipelines { get; }
+
+ ///
+ public IGitCiConfigurationService CiConfiguration { get; }
+
+ ///
+ public IGitEnvironmentService Environments { get; }
+
+ ///
+ public IGitBranchPolicyService BranchPolicies { get; }
+
+ ///
+ public IGitAccessControlService AccessControl { get; }
+
+ ///
+ public IGitWebhookService Webhooks { get; }
+
+ ///
+ public IGitWebhookIngestor WebhookIngestor { get; }
+
+ ///
+ public IGitNamespaceProvisioner NamespaceProvisioner { get; }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs b/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs
new file mode 100644
index 0000000..575e320
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/GlobalUsings.cs
@@ -0,0 +1,19 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+global using Compendium.Abstractions.Git;
+global using Compendium.Abstractions.Git.AccessControl;
+global using Compendium.Abstractions.Git.Capabilities;
+global using Compendium.Abstractions.Git.CiConfiguration;
+global using Compendium.Abstractions.Git.Connections;
+global using Compendium.Abstractions.Git.Environments;
+global using Compendium.Abstractions.Git.Pipelines;
+global using Compendium.Abstractions.Git.Protection;
+global using Compendium.Abstractions.Git.Provisioning;
+global using Compendium.Abstractions.Git.Repositories;
+global using Compendium.Abstractions.Git.Webhooks;
+global using Compendium.Core.Results;
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs
new file mode 100644
index 0000000..4a4a413
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubClientProvider.cs
@@ -0,0 +1,87 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using Compendium.Adapters.GitHub.Auth;
+using Octokit;
+
+namespace Compendium.Adapters.GitHub.Http;
+
+///
+/// Produces authenticated Octokit clients for a connection.
+///
+internal interface IGitHubClientProvider
+{
+ /// Mints a token for the connection and returns an Octokit client carrying it.
+ Task> GetClientAsync(GitConnection connection, CancellationToken cancellationToken);
+}
+
+///
+/// Produces authenticated Octokit clients for a connection. A
+/// is cached per credential identity and target host
+/// (so repeated calls do not exhaust sockets), and its credentials are refreshed
+/// from a freshly minted token on every retrieval — installation tokens rotate
+/// roughly hourly, and the mint is cheap because the broker caches them.
+///
+internal sealed class GitHubClientProvider : IGitHubClientProvider
+{
+ private readonly GitHubCredentialBroker _broker;
+ private readonly ConcurrentDictionary _clients = new(StringComparer.Ordinal);
+
+ public GitHubClientProvider(GitHubCredentialBroker broker)
+ {
+ _broker = broker ?? throw new ArgumentNullException(nameof(broker));
+ }
+
+ ///
+ public async Task> GetClientAsync(GitConnection connection, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ var token = await _broker.MintAsync(connection, scope: null, cancellationToken).ConfigureAwait(false);
+ if (token.IsFailure)
+ {
+ return Result.Failure(token.Error);
+ }
+
+ var client = _clients.GetOrAdd(CacheKey(connection), _ => Build(connection));
+ client.Credentials = new Credentials(token.Value.Token);
+ return Result.Success(client);
+ }
+
+ private static GitHubClient Build(GitConnection connection)
+ {
+ var product = new ProductHeaderValue(GitHubDefaults.ProductName);
+ return connection.ServerUrl is null
+ ? new GitHubClient(product)
+ : new GitHubClient(product, GitHubDefaults.EnsureTrailingSlash(connection.ServerUrl));
+ }
+
+ private static string CacheKey(GitConnection connection)
+ {
+ var host = connection.ServerUrl?.AbsoluteUri ?? "github.com";
+ var identity = connection.Credential switch
+ {
+ GitCredential.AppInstallation app => $"app:{app.AppKey ?? "default"}:{app.InstallationId}",
+ GitCredential.ServiceAccountToken sat => $"tok:{Fingerprint(sat.Token)}",
+ GitCredential.PersonalAccessToken pat => $"tok:{Fingerprint(pat.Token)}",
+ GitCredential.OAuthAccessToken oauth => $"tok:{Fingerprint(oauth.AccessToken)}",
+ _ => "unknown",
+ };
+
+ return $"{identity}@{host}";
+ }
+
+ // A non-reversible fingerprint so the cache key never carries raw token material.
+ private static string Fingerprint(string token)
+ {
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
+ return Convert.ToHexString(hash.AsSpan(0, 6));
+ }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs
new file mode 100644
index 0000000..6ed2566
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubErrorMapper.cs
@@ -0,0 +1,124 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Net;
+using Octokit;
+
+namespace Compendium.Adapters.GitHub.Http;
+
+///
+/// Maps GitHub failures — both raw HTTP status codes (REST executor) and Octokit
+/// exceptions — onto the neutral so every provider fails
+/// uniformly. Never leaks token material: only status codes and provider messages
+/// flow through.
+///
+internal static class GitHubErrorMapper
+{
+ /// The provider discriminator carried in mapped errors.
+ public const string Provider = GitHubDefaults.Provider;
+
+ ///
+ /// Maps an HTTP status code (plus an optional rate-limit hint) onto a neutral error.
+ ///
+ public static Error FromStatus(
+ int statusCode,
+ GitRestErrorContext context,
+ string detail,
+ TimeSpan? retryAfter = null,
+ bool rateLimited = false)
+ {
+ switch (statusCode)
+ {
+ case 401:
+ return GitErrors.AuthenticationFailed(Provider, detail);
+
+ case 403 when rateLimited || retryAfter.HasValue:
+ case 429:
+ return GitErrors.Throttled(retryAfter);
+
+ case 404:
+ if (context.RepositoryFullName is { } repo)
+ {
+ return GitErrors.RepositoryNotFound(repo);
+ }
+
+ if (context.Namespace is { } ns)
+ {
+ return GitErrors.NamespaceNotFound(ns);
+ }
+
+ return GitErrors.ProviderRejected(Provider, statusCode, detail);
+
+ case 409:
+ case 422 when LooksLikeConflict(detail):
+ return GitErrors.Conflict(context.ConflictResource ?? detail);
+
+ default:
+ return GitErrors.ProviderRejected(Provider, statusCode, detail);
+ }
+ }
+
+ ///
+ /// Maps an Octokit exception onto a neutral error, honoring rate-limit resets
+ /// and treating "name already exists" validation failures as conflicts.
+ ///
+ public static Error FromException(Exception exception, GitRestErrorContext context)
+ {
+ switch (exception)
+ {
+ case RateLimitExceededException rate:
+ return GitErrors.Throttled(RetryAfter(rate.Reset));
+
+ case SecondaryRateLimitExceededException:
+ return GitErrors.Throttled(null);
+
+ case AbuseException abuse:
+ return GitErrors.Throttled(
+ abuse.RetryAfterSeconds is > 0 ? TimeSpan.FromSeconds(abuse.RetryAfterSeconds.Value) : null);
+
+ case AuthorizationException:
+ return GitErrors.AuthenticationFailed(Provider, exception.Message);
+
+ case NotFoundException:
+ if (context.RepositoryFullName is { } repo)
+ {
+ return GitErrors.RepositoryNotFound(repo);
+ }
+
+ if (context.Namespace is { } ns)
+ {
+ return GitErrors.NamespaceNotFound(ns);
+ }
+
+ return GitErrors.ProviderRejected(Provider, 404, exception.Message);
+
+ case ApiValidationException validation when LooksLikeConflict(validation.Message)
+ || LooksLikeConflict(DescribeValidation(validation)):
+ return GitErrors.Conflict(context.ConflictResource ?? validation.Message);
+
+ case ApiException api:
+ return GitErrors.ProviderRejected(Provider, (int)api.StatusCode, api.Message);
+
+ default:
+ return GitErrors.ProviderRejected(Provider, 0, exception.Message);
+ }
+ }
+
+ private static TimeSpan? RetryAfter(DateTimeOffset reset)
+ {
+ var delta = reset - DateTimeOffset.UtcNow;
+ return delta > TimeSpan.Zero ? delta : null;
+ }
+
+ private static bool LooksLikeConflict(string? message) =>
+ message is not null && message.Contains("already exists", StringComparison.OrdinalIgnoreCase);
+
+ private static string DescribeValidation(ApiValidationException validation) =>
+ validation.ApiError?.Errors is { Count: > 0 } errors
+ ? string.Join("; ", errors.Select(e => $"{e.Field} {e.Code} {e.Message}"))
+ : string.Empty;
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs
new file mode 100644
index 0000000..bfdbfc7
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestDtos.cs
@@ -0,0 +1,93 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Text.Json.Serialization;
+
+namespace Compendium.Adapters.GitHub.Http;
+
+/// A GitHub account (organization or user) as returned by the REST API.
+internal sealed class GitHubAccountDto
+{
+ [JsonPropertyName("login")]
+ public string Login { get; init; } = string.Empty;
+
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ /// Maps the provider account type onto the neutral enum (defaults to organization).
+ public GitAccountType ToAccountType() =>
+ string.Equals(Type, "User", StringComparison.OrdinalIgnoreCase)
+ ? GitAccountType.User
+ : GitAccountType.Organization;
+}
+
+/// A GitHub App installation as returned by the REST API.
+internal sealed class GitHubInstallationDto
+{
+ [JsonPropertyName("id")]
+ public long Id { get; init; }
+
+ [JsonPropertyName("account")]
+ public GitHubAccountDto? Account { get; init; }
+
+ [JsonPropertyName("suspended_at")]
+ public DateTimeOffset? SuspendedAt { get; init; }
+
+ /// Maps onto the neutral installation info, defaulting an absent account to an org.
+ public GitInstallationInfo ToInstallationInfo() => new(
+ Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Account?.Login ?? string.Empty,
+ Account?.ToAccountType() ?? GitAccountType.Organization,
+ SuspendedAt is not null);
+}
+
+/// A GitHub Actions public key (used to seal secrets before upload).
+internal sealed class GitHubPublicKeyDto
+{
+ [JsonPropertyName("key_id")]
+ public string KeyId { get; init; } = string.Empty;
+
+ [JsonPropertyName("key")]
+ public string Key { get; init; } = string.Empty;
+}
+
+/// A GitHub deployment environment as returned by the REST API.
+internal sealed class GitHubEnvironmentDto
+{
+ [JsonPropertyName("name")]
+ public string Name { get; init; } = string.Empty;
+
+ [JsonPropertyName("html_url")]
+ public string? HtmlUrl { get; init; }
+}
+
+/// The list envelope returned by GET /repos/{o}/{r}/environments.
+internal sealed class GitHubEnvironmentListDto
+{
+ [JsonPropertyName("environments")]
+ public List Environments { get; init; } = [];
+}
+
+/// A repository ruleset as returned by the rulesets REST API.
+internal sealed class GitHubRulesetDto
+{
+ [JsonPropertyName("id")]
+ public long Id { get; init; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; init; } = string.Empty;
+}
+
+/// The minimal repository shape needed to resolve a numeric repository id.
+internal sealed class GitHubRepositoryIdDto
+{
+ [JsonPropertyName("id")]
+ public long Id { get; init; }
+}
diff --git a/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs
new file mode 100644
index 0000000..c9a0296
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.GitHub/Http/GitHubRestExecutor.cs
@@ -0,0 +1,198 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace Compendium.Adapters.GitHub.Http;
+
+///
+/// A thin typed HTTP helper for the GitHub REST endpoints Octokit lags on
+/// (Actions variables, repository rulesets, deployment environments, environment
+/// and organization secrets, installation lookup). Sends bearer-authenticated
+/// requests against a normalized base URL, deserializes JSON, and maps every
+/// non-success response onto the neutral via
+/// . Stateless — safe as a singleton.
+///
+internal sealed class GitHubRestExecutor
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public GitHubRestExecutor(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+ }
+
+ /// Sends a GET and deserializes the JSON body into .
+ public Task> GetAsync(
+ Uri baseUrl, string token, string path, GitRestErrorContext context, CancellationToken cancellationToken)
+ => SendWithBodyAsync(HttpMethod.Get, baseUrl, token, path, body: null, context, cancellationToken);
+
+ /// Sends a request with an optional JSON body and deserializes the JSON response.
+ public Task> SendWithBodyAsync(
+ HttpMethod method,
+ Uri baseUrl,
+ string token,
+ string path,
+ object? body,
+ GitRestErrorContext context,
+ CancellationToken cancellationToken)
+ => ExecuteAsync(method, baseUrl, token, path, body, context, readBody: true, cancellationToken,
+ static async (response, ct) =>
+ {
+ var value = await response.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false);
+ return value is null
+ ? Result.Failure(Error.Failure(
+ "GitHub.EmptyResponse", "GitHub returned an empty response body where content was expected."))
+ : Result.Success(value);
+ });
+
+ /// Sends a request with an optional JSON body and ignores the response body.
+ public async Task SendAsync(
+ HttpMethod method,
+ Uri baseUrl,
+ string token,
+ string path,
+ object? body,
+ GitRestErrorContext context,
+ CancellationToken cancellationToken)
+ {
+ var result = await ExecuteAsync