pragmatiks-github-provider 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.3
2
+ Name: pragmatiks-github-provider
3
+ Version: 0.2.1
4
+ Summary: GitHub provider for Pragmatiks
5
+ Author: Pragmatiks
6
+ Author-email: Pragmatiks <team@pragmatiks.io>
7
+ Requires-Dist: pragmatiks-sdk>=0.32.4
8
+ Requires-Dist: httpx>=0.28.0
9
+ Requires-Dist: pynacl>=1.5.0
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # GitHub Provider for Pragmatiks
14
+
15
+ Manage GitHub repositories, environments, and secrets through declarative resources using the GitHub REST API.
16
+
17
+ ## Resources
18
+
19
+ - **Repository** - Create and manage GitHub repositories with visibility, features, and branch settings
20
+ - **Environment** - Configure deployment environments on repositories with protection rules
21
+ - **Secret** - Manage repository-level and environment-level secrets with automatic encryption
22
+
23
+ ## Authentication
24
+
25
+ This provider uses GitHub Personal Access Tokens (classic or fine-grained) for authentication. Generate a token at https://github.com/settings/tokens.
@@ -0,0 +1,13 @@
1
+ # GitHub Provider for Pragmatiks
2
+
3
+ Manage GitHub repositories, environments, and secrets through declarative resources using the GitHub REST API.
4
+
5
+ ## Resources
6
+
7
+ - **Repository** - Create and manage GitHub repositories with visibility, features, and branch settings
8
+ - **Environment** - Configure deployment environments on repositories with protection rules
9
+ - **Secret** - Manage repository-level and environment-level secrets with automatic encryption
10
+
11
+ ## Authentication
12
+
13
+ This provider uses GitHub Personal Access Tokens (classic or fine-grained) for authentication. Generate a token at https://github.com/settings/tokens.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "pragmatiks-github-provider"
3
+ version = "0.2.1"
4
+ description = "GitHub provider for Pragmatiks"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "pragmatiks-sdk>=0.32.4",
9
+ "httpx>=0.28.0",
10
+ "pynacl>=1.5.0",
11
+ ]
12
+
13
+ authors = [
14
+ { name = "Pragmatiks", email = "team@pragmatiks.io" },
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.4.1",
20
+ "pytest-asyncio>=1.0.0",
21
+ "pytest-cov>=7.0.0",
22
+ "pytest-mock>=3.14.0",
23
+ "respx>=0.22.0",
24
+ "ty>=0.0.14",
25
+ ]
26
+
27
+ [tool.pragma]
28
+ # Provider metadata - used by the platform for discovery and deployment
29
+ provider = "github"
30
+ package = "github_provider"
31
+ display_name = "GitHub"
32
+ description = "Manage GitHub repositories, environments, and secrets through declarative resources using the GitHub REST API."
33
+ tags = ["github", "repositories", "secrets", "environments", "source-control"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ asyncio_default_fixture_loop_scope = "function"
38
+
39
+ [tool.commitizen]
40
+ name = "cz_conventional_commits"
41
+ version = "0.2.1"
42
+ version_files = ["pyproject.toml:^version"]
43
+ tag_format = "github-v$version"
44
+ update_changelog_on_bump = true
45
+ changelog_file = "CHANGELOG.md"
46
+
47
+ [tool.ruff]
48
+ extend = "../../pyproject.toml"
49
+ line-length = 120
50
+
51
+ [tool.uv.build-backend]
52
+ module-name = "github_provider"
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.9.8,<0.10.0"]
56
+ build-backend = "uv_build"
@@ -0,0 +1,43 @@
1
+ """GitHub provider for Pragmatiks.
2
+
3
+ Provides GitHub resources for managing repositories, deployment environments,
4
+ and secrets via the GitHub REST API.
5
+ """
6
+
7
+ from pragma_sdk import Provider
8
+
9
+ from github_provider.resources import (
10
+ Environment,
11
+ EnvironmentConfig,
12
+ EnvironmentOutputs,
13
+ ProtectionRulesConfig,
14
+ Repository,
15
+ RepositoryConfig,
16
+ RepositoryOutputs,
17
+ ReviewerConfig,
18
+ Secret,
19
+ SecretConfig,
20
+ SecretOutputs,
21
+ )
22
+
23
+
24
+ github = Provider()
25
+
26
+ github.resource("repository")(Repository)
27
+ github.resource("environment")(Environment)
28
+ github.resource("secret")(Secret)
29
+
30
+ __all__ = [
31
+ "github",
32
+ "Environment",
33
+ "EnvironmentConfig",
34
+ "EnvironmentOutputs",
35
+ "ProtectionRulesConfig",
36
+ "Repository",
37
+ "RepositoryConfig",
38
+ "RepositoryOutputs",
39
+ "ReviewerConfig",
40
+ "Secret",
41
+ "SecretConfig",
42
+ "SecretOutputs",
43
+ ]
@@ -0,0 +1,59 @@
1
+ """GitHub REST API HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ _BASE_URL = "https://api.github.com"
11
+
12
+
13
+ def create_github_client(access_token: str) -> httpx.AsyncClient:
14
+ """Create an authenticated httpx client for the GitHub REST API.
15
+
16
+ Args:
17
+ access_token: GitHub personal access token (classic or fine-grained).
18
+
19
+ Returns:
20
+ Configured async HTTP client with authorization headers and base URL.
21
+ """
22
+ return httpx.AsyncClient(
23
+ base_url=_BASE_URL,
24
+ headers={
25
+ "Authorization": f"Bearer {access_token}",
26
+ "Accept": "application/vnd.github+json",
27
+ "X-GitHub-Api-Version": "2022-11-28",
28
+ },
29
+ timeout=httpx.Timeout(60.0, connect=10.0),
30
+ )
31
+
32
+
33
+ async def raise_for_status(response: httpx.Response) -> None:
34
+ """Raise a descriptive error if the response indicates failure.
35
+
36
+ Extracts the error message from the GitHub API response body when
37
+ available, falling back to the raw status code otherwise.
38
+
39
+ Args:
40
+ response: HTTP response to check.
41
+
42
+ Raises:
43
+ httpx.HTTPStatusError: If the response status code indicates an error,
44
+ with the API error message included.
45
+ """
46
+ if response.is_success:
47
+ return
48
+
49
+ try:
50
+ body: dict[str, Any] = response.json()
51
+ message = body.get("message") or str(body)
52
+ except Exception:
53
+ message = response.text or f"HTTP {response.status_code}"
54
+
55
+ raise httpx.HTTPStatusError(
56
+ message=f"GitHub API error: {message}",
57
+ request=response.request,
58
+ response=response,
59
+ )
@@ -0,0 +1,37 @@
1
+ """Resource definitions for GitHub provider.
2
+
3
+ Import and export your Resource classes here for discovery by the runtime.
4
+ """
5
+
6
+ from github_provider.resources.environment import (
7
+ Environment,
8
+ EnvironmentConfig,
9
+ EnvironmentOutputs,
10
+ ProtectionRulesConfig,
11
+ ReviewerConfig,
12
+ )
13
+ from github_provider.resources.repository import (
14
+ Repository,
15
+ RepositoryConfig,
16
+ RepositoryOutputs,
17
+ )
18
+ from github_provider.resources.secret import (
19
+ Secret,
20
+ SecretConfig,
21
+ SecretOutputs,
22
+ )
23
+
24
+
25
+ __all__ = [
26
+ "Environment",
27
+ "EnvironmentConfig",
28
+ "EnvironmentOutputs",
29
+ "ProtectionRulesConfig",
30
+ "Repository",
31
+ "RepositoryConfig",
32
+ "RepositoryOutputs",
33
+ "ReviewerConfig",
34
+ "Secret",
35
+ "SecretConfig",
36
+ "SecretOutputs",
37
+ ]
@@ -0,0 +1,235 @@
1
+ """GitHub Environment resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pragma_sdk import Config, Field, ImmutableField, Outputs, Resource, SensitiveField
8
+ from pydantic import BaseModel
9
+ from pydantic import Field as PydanticField
10
+
11
+ from github_provider.client import create_github_client, raise_for_status
12
+
13
+
14
+ class ReviewerConfig(BaseModel):
15
+ """Configuration for an environment protection rule reviewer.
16
+
17
+ Attributes:
18
+ reviewer_type: Type of reviewer (``User`` or ``Team``).
19
+ reviewer_id: GitHub ID of the user or team.
20
+ """
21
+
22
+ model_config = {"extra": "forbid"}
23
+
24
+ reviewer_type: str = "User"
25
+ reviewer_id: int = 0
26
+
27
+
28
+ class ProtectionRulesConfig(BaseModel):
29
+ """Configuration for environment protection rules.
30
+
31
+ Attributes:
32
+ wait_timer: Number of minutes to wait before allowing deployments
33
+ (0 to 43200). Set to 0 to disable the wait timer.
34
+ reviewers: List of required reviewers for deployments.
35
+ """
36
+
37
+ model_config = {"extra": "forbid"}
38
+
39
+ wait_timer: int = 0
40
+ reviewers: list[ReviewerConfig] = PydanticField(default_factory=list)
41
+
42
+
43
+ class EnvironmentConfig(Config):
44
+ """Configuration for a GitHub deployment environment.
45
+
46
+ Attributes:
47
+ access_token: GitHub personal access token for authentication.
48
+ owner: GitHub user or organization that owns the repository.
49
+ repository: Repository name where the environment is created.
50
+ environment_name: Name of the deployment environment
51
+ (e.g., ``production``, ``staging``).
52
+ protection_rules: Optional protection rules for the environment.
53
+ """
54
+
55
+ access_token: SensitiveField[str]
56
+ owner: ImmutableField[str]
57
+ repository: ImmutableField[str]
58
+ environment_name: ImmutableField[str]
59
+ protection_rules: Field[ProtectionRulesConfig] | None = None
60
+
61
+
62
+ class EnvironmentOutputs(Outputs):
63
+ """Outputs from GitHub environment creation.
64
+
65
+ Attributes:
66
+ environment_id: Numeric environment ID assigned by GitHub.
67
+ environment_name: Name of the deployment environment.
68
+ html_url: URL to the environment settings page on GitHub.
69
+ wait_timer: Configured wait timer in minutes (0 if not set).
70
+ reviewers_count: Number of required reviewers.
71
+ """
72
+
73
+ environment_id: int
74
+ environment_name: str
75
+ html_url: str
76
+ wait_timer: int
77
+ reviewers_count: int
78
+
79
+
80
+ def _build_create_body(config: EnvironmentConfig) -> dict[str, Any]:
81
+ """Build the API request body for environment creation or update.
82
+
83
+ Args:
84
+ config: Environment configuration.
85
+
86
+ Returns:
87
+ Dictionary suitable for PUT /repos/{owner}/{repo}/environments/{env}.
88
+ """
89
+ body: dict[str, Any] = {}
90
+
91
+ if config.protection_rules is not None:
92
+ body["wait_timer"] = config.protection_rules.wait_timer
93
+
94
+ if config.protection_rules.reviewers:
95
+ body["reviewers"] = [
96
+ {"type": reviewer.reviewer_type, "id": reviewer.reviewer_id}
97
+ for reviewer in config.protection_rules.reviewers
98
+ ]
99
+
100
+ return body
101
+
102
+
103
+ def _build_outputs(data: dict[str, Any], owner: str, repository: str) -> EnvironmentOutputs:
104
+ """Build outputs from GitHub environment API response data.
105
+
106
+ Args:
107
+ data: Raw environment data from the GitHub API.
108
+ owner: Repository owner.
109
+ repository: Repository name.
110
+
111
+ Returns:
112
+ EnvironmentOutputs with environment metadata.
113
+ """
114
+ protection_rules = data.get("protection_rules", [])
115
+ wait_timer = 0
116
+ reviewers_count = 0
117
+
118
+ for rule in protection_rules:
119
+ if rule.get("type") == "wait_timer":
120
+ wait_timer = rule.get("wait_timer", 0)
121
+ elif rule.get("type") == "required_reviewers":
122
+ reviewers = rule.get("reviewers", [])
123
+ reviewers_count = len(reviewers)
124
+
125
+ return EnvironmentOutputs(
126
+ environment_id=data["id"],
127
+ environment_name=data["name"],
128
+ html_url=f"https://github.com/{owner}/{repository}/settings/environments/{data['id']}",
129
+ wait_timer=wait_timer,
130
+ reviewers_count=reviewers_count,
131
+ )
132
+
133
+
134
+ class Environment(Resource[EnvironmentConfig, EnvironmentOutputs]):
135
+ """GitHub deployment environment resource.
136
+
137
+ Creates and manages deployment environments on GitHub repositories
138
+ via the REST API. Environments can have protection rules such as
139
+ wait timers and required reviewers.
140
+
141
+ Lifecycle:
142
+ - on_create: Creates or updates an environment using PUT (the
143
+ GitHub API uses PUT for both create and update). Idempotent.
144
+ - on_update: Updates the environment configuration by re-applying
145
+ the PUT request with the new settings.
146
+ - on_delete: Deletes the environment. Idempotent -- succeeds if
147
+ the environment does not exist.
148
+
149
+ Example::
150
+
151
+ resources:
152
+ - name: production-env
153
+ provider: github
154
+ type: environment
155
+ config:
156
+ access_token:
157
+ provider: pragma
158
+ resource: secret
159
+ name: github-token
160
+ field: outputs.value
161
+ owner: my-org
162
+ repository: my-repo
163
+ environment_name: production
164
+ protection_rules:
165
+ wait_timer: 30
166
+ reviewers:
167
+ - reviewer_type: User
168
+ reviewer_id: 12345
169
+ """
170
+
171
+ async def _apply_environment(self) -> EnvironmentOutputs:
172
+ """Create or update the environment and return outputs.
173
+
174
+ Returns:
175
+ EnvironmentOutputs with environment details.
176
+ """
177
+ client = create_github_client(self.config.access_token)
178
+
179
+ try:
180
+ body = _build_create_body(self.config)
181
+ response = await client.put(
182
+ f"/repos/{self.config.owner}/{self.config.repository}/environments/{self.config.environment_name}",
183
+ json=body,
184
+ )
185
+ await raise_for_status(response)
186
+
187
+ return _build_outputs(response.json(), self.config.owner, self.config.repository)
188
+ finally:
189
+ await client.aclose()
190
+
191
+ async def on_create(self) -> EnvironmentOutputs:
192
+ """Create a deployment environment on the repository.
193
+
194
+ Returns:
195
+ EnvironmentOutputs with environment details.
196
+ """
197
+ return await self._apply_environment()
198
+
199
+ async def on_update(self, previous_config: EnvironmentConfig) -> EnvironmentOutputs:
200
+ """Update the environment configuration.
201
+
202
+ Args:
203
+ previous_config: The previous configuration before update.
204
+
205
+ Returns:
206
+ EnvironmentOutputs with updated environment details.
207
+ """
208
+ return await self._apply_environment()
209
+
210
+ async def on_delete(self) -> None:
211
+ """Delete the deployment environment.
212
+
213
+ Idempotent: Succeeds if the environment does not exist.
214
+ """
215
+ client = create_github_client(self.config.access_token)
216
+
217
+ try:
218
+ response = await client.delete(
219
+ f"/repos/{self.config.owner}/{self.config.repository}/environments/{self.config.environment_name}",
220
+ )
221
+
222
+ if response.status_code == 404:
223
+ return
224
+
225
+ await raise_for_status(response)
226
+ finally:
227
+ await client.aclose()
228
+
229
+ @classmethod
230
+ def upgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
231
+ return config, outputs
232
+
233
+ @classmethod
234
+ def downgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
235
+ return config, outputs
@@ -0,0 +1,257 @@
1
+ """GitHub Repository resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pragma_sdk import Config, Field, ImmutableField, Outputs, Resource, SensitiveField
8
+
9
+ from github_provider.client import create_github_client, raise_for_status
10
+
11
+
12
+ class RepositoryConfig(Config):
13
+ """Configuration for a GitHub repository.
14
+
15
+ Attributes:
16
+ access_token: GitHub personal access token for authentication.
17
+ Use a pragma/secret resource with a FieldReference to provide this.
18
+ owner: GitHub user or organization that owns the repository.
19
+ name: Repository name. Must be unique within the owner's account.
20
+ description: Short description of the repository.
21
+ visibility: Repository visibility (``public`` or ``private``).
22
+ default_branch: Default branch name. Only applied on creation
23
+ when ``auto_init`` is True.
24
+ has_issues: Whether to enable the Issues feature.
25
+ has_wiki: Whether to enable the Wiki feature.
26
+ has_projects: Whether to enable the Projects feature.
27
+ auto_init: Whether to initialize the repository with a README.
28
+ Only used on creation.
29
+ delete_branch_on_merge: Whether to automatically delete head
30
+ branches after pull requests are merged.
31
+ allow_squash_merge: Whether to allow squash-merging pull requests.
32
+ allow_merge_commit: Whether to allow merging pull requests with a merge commit.
33
+ allow_rebase_merge: Whether to allow rebase-merging pull requests.
34
+ """
35
+
36
+ access_token: SensitiveField[str]
37
+ owner: ImmutableField[str]
38
+ name: ImmutableField[str]
39
+ description: Field[str] = ""
40
+ visibility: Field[str] = "private"
41
+ default_branch: Field[str] = "main"
42
+ has_issues: Field[bool] = True
43
+ has_wiki: Field[bool] = False
44
+ has_projects: Field[bool] = False
45
+ auto_init: Field[bool] = True
46
+ delete_branch_on_merge: Field[bool] = True
47
+ allow_squash_merge: Field[bool] = True
48
+ allow_merge_commit: Field[bool] = True
49
+ allow_rebase_merge: Field[bool] = True
50
+
51
+
52
+ class RepositoryOutputs(Outputs):
53
+ """Outputs from GitHub repository creation.
54
+
55
+ Attributes:
56
+ repository_id: Numeric repository ID assigned by GitHub.
57
+ full_name: Full repository name in ``owner/repo`` format.
58
+ html_url: URL to the repository on GitHub.
59
+ clone_url: HTTPS clone URL.
60
+ ssh_url: SSH clone URL.
61
+ default_branch: Default branch name.
62
+ visibility: Repository visibility.
63
+ """
64
+
65
+ repository_id: int
66
+ full_name: str
67
+ html_url: str
68
+ clone_url: str
69
+ ssh_url: str
70
+ default_branch: str
71
+ visibility: str
72
+
73
+
74
+ def _build_create_body(config: RepositoryConfig) -> dict[str, Any]:
75
+ """Build the API request body for repository creation.
76
+
77
+ Args:
78
+ config: Repository configuration.
79
+
80
+ Returns:
81
+ Dictionary suitable for POST /orgs/{org}/repos or POST /user/repos.
82
+ """
83
+ return {
84
+ "name": config.name,
85
+ "description": config.description,
86
+ "private": config.visibility == "private",
87
+ "auto_init": config.auto_init,
88
+ "has_issues": config.has_issues,
89
+ "has_wiki": config.has_wiki,
90
+ "has_projects": config.has_projects,
91
+ "delete_branch_on_merge": config.delete_branch_on_merge,
92
+ "allow_squash_merge": config.allow_squash_merge,
93
+ "allow_merge_commit": config.allow_merge_commit,
94
+ "allow_rebase_merge": config.allow_rebase_merge,
95
+ }
96
+
97
+
98
+ def _build_update_body(config: RepositoryConfig) -> dict[str, Any]:
99
+ """Build the API request body for repository update.
100
+
101
+ Only includes mutable fields. Owner and name are immutable.
102
+
103
+ Args:
104
+ config: Current repository configuration.
105
+
106
+ Returns:
107
+ Dictionary suitable for PATCH /repos/{owner}/{repo}.
108
+ """
109
+ return {
110
+ "description": config.description,
111
+ "private": config.visibility == "private",
112
+ "has_issues": config.has_issues,
113
+ "has_wiki": config.has_wiki,
114
+ "has_projects": config.has_projects,
115
+ "delete_branch_on_merge": config.delete_branch_on_merge,
116
+ "allow_squash_merge": config.allow_squash_merge,
117
+ "allow_merge_commit": config.allow_merge_commit,
118
+ "allow_rebase_merge": config.allow_rebase_merge,
119
+ }
120
+
121
+
122
+ def _build_outputs(data: dict[str, Any]) -> RepositoryOutputs:
123
+ """Build outputs from GitHub repository API response data.
124
+
125
+ Args:
126
+ data: Raw repository data from the GitHub API.
127
+
128
+ Returns:
129
+ RepositoryOutputs with repository metadata.
130
+ """
131
+ return RepositoryOutputs(
132
+ repository_id=data["id"],
133
+ full_name=data["full_name"],
134
+ html_url=data["html_url"],
135
+ clone_url=data["clone_url"],
136
+ ssh_url=data["ssh_url"],
137
+ default_branch=data.get("default_branch", "main"),
138
+ visibility=data.get("visibility", "private"),
139
+ )
140
+
141
+
142
+ class Repository(Resource[RepositoryConfig, RepositoryOutputs]):
143
+ """GitHub repository resource.
144
+
145
+ Creates and manages GitHub repositories via the REST API. Repositories
146
+ are created under the specified owner (user or organization) and can
147
+ be configured with visibility, features, and merge settings.
148
+
149
+ Lifecycle:
150
+ - on_create: Creates a new repository. Uses the organization endpoint
151
+ when the owner is an organization, or the user endpoint otherwise.
152
+ Not idempotent -- duplicate calls create duplicate repositories.
153
+ - on_update: Updates mutable repository settings (description,
154
+ visibility, features, merge options). Owner and name are immutable.
155
+ - on_delete: Deletes the repository. Idempotent -- succeeds if the
156
+ repository does not exist.
157
+
158
+ Example::
159
+
160
+ resources:
161
+ - name: my-repo
162
+ provider: github
163
+ type: repository
164
+ config:
165
+ access_token:
166
+ provider: pragma
167
+ resource: secret
168
+ name: github-token
169
+ field: outputs.value
170
+ owner: my-org
171
+ name: my-repo
172
+ description: My application repository
173
+ visibility: private
174
+ auto_init: true
175
+ has_issues: true
176
+ has_wiki: false
177
+ """
178
+
179
+ async def on_create(self) -> RepositoryOutputs:
180
+ """Create a GitHub repository.
181
+
182
+ Returns:
183
+ RepositoryOutputs with repository details.
184
+ """
185
+ client = create_github_client(self.config.access_token)
186
+
187
+ try:
188
+ body = _build_create_body(self.config)
189
+
190
+ response = await client.post(f"/orgs/{self.config.owner}/repos", json=body)
191
+
192
+ if response.status_code == 404:
193
+ response = await client.post("/user/repos", json=body)
194
+
195
+ await raise_for_status(response)
196
+
197
+ return _build_outputs(response.json())
198
+ finally:
199
+ await client.aclose()
200
+
201
+ async def on_update(self, previous_config: RepositoryConfig) -> RepositoryOutputs:
202
+ """Update the repository settings if changed.
203
+
204
+ Args:
205
+ previous_config: The previous configuration before update.
206
+
207
+ Returns:
208
+ RepositoryOutputs with current repository state.
209
+
210
+ Raises:
211
+ RuntimeError: If no existing outputs are available for the repository.
212
+ """
213
+ if self.outputs is None:
214
+ msg = "Cannot update repository without existing outputs"
215
+ raise RuntimeError(msg)
216
+
217
+ client = create_github_client(self.config.access_token)
218
+
219
+ try:
220
+ update_body = _build_update_body(self.config)
221
+ response = await client.patch(
222
+ f"/repos/{self.config.owner}/{self.config.name}",
223
+ json=update_body,
224
+ )
225
+ await raise_for_status(response)
226
+
227
+ return _build_outputs(response.json())
228
+ finally:
229
+ await client.aclose()
230
+
231
+ async def on_delete(self) -> None:
232
+ """Delete the GitHub repository.
233
+
234
+ Idempotent: Succeeds if the repository does not exist.
235
+ """
236
+ if self.outputs is None:
237
+ return
238
+
239
+ client = create_github_client(self.config.access_token)
240
+
241
+ try:
242
+ response = await client.delete(f"/repos/{self.config.owner}/{self.config.name}")
243
+
244
+ if response.status_code == 404:
245
+ return
246
+
247
+ await raise_for_status(response)
248
+ finally:
249
+ await client.aclose()
250
+
251
+ @classmethod
252
+ def upgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
253
+ return config, outputs
254
+
255
+ @classmethod
256
+ def downgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
257
+ return config, outputs
@@ -0,0 +1,317 @@
1
+ """GitHub Secret resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any
7
+
8
+ from nacl.encoding import Base64Encoder
9
+ from nacl.public import PublicKey, SealedBox
10
+ from pragma_sdk import Config, Field, ImmutableField, Outputs, Resource, SensitiveField
11
+
12
+ from github_provider.client import create_github_client, raise_for_status
13
+
14
+
15
+ class SecretConfig(Config):
16
+ """Configuration for a GitHub secret.
17
+
18
+ Secrets can be scoped to a repository or to a specific deployment
19
+ environment within a repository. When ``environment_name`` is set,
20
+ the secret is created as an environment secret.
21
+
22
+ Attributes:
23
+ access_token: GitHub personal access token for authentication.
24
+ owner: GitHub user or organization that owns the repository.
25
+ repository: Repository name.
26
+ secret_name: Name of the secret. Must match the pattern
27
+ ``[A-Z_][A-Z0-9_]*``.
28
+ secret_value: Plaintext secret value. Encrypted automatically
29
+ before upload using the repository or environment public key.
30
+ environment_name: Optional deployment environment name. When set,
31
+ the secret is scoped to this environment instead of the repository.
32
+ """
33
+
34
+ access_token: SensitiveField[str]
35
+ owner: ImmutableField[str]
36
+ repository: ImmutableField[str]
37
+ secret_name: ImmutableField[str]
38
+ secret_value: SensitiveField[str]
39
+ environment_name: Field[str] | None = None
40
+
41
+
42
+ class SecretOutputs(Outputs):
43
+ """Outputs from GitHub secret creation.
44
+
45
+ Attributes:
46
+ secret_name: Name of the secret.
47
+ scope: Scope of the secret (``repository`` or ``environment``).
48
+ environment_name: Environment name if this is an environment secret,
49
+ empty string otherwise.
50
+ created_at: ISO 8601 timestamp of when the secret was created.
51
+ updated_at: ISO 8601 timestamp of when the secret was last updated.
52
+ """
53
+
54
+ secret_name: str
55
+ scope: str
56
+ environment_name: str
57
+ created_at: str
58
+ updated_at: str
59
+
60
+
61
+ def _encrypt_secret(public_key_b64: str, secret_value: str) -> str:
62
+ """Encrypt a secret value using the repository's NaCl public key.
63
+
64
+ Args:
65
+ public_key_b64: Base64-encoded public key from the GitHub API.
66
+ secret_value: Plaintext secret value to encrypt.
67
+
68
+ Returns:
69
+ Base64-encoded encrypted secret value.
70
+ """
71
+ public_key_bytes = base64.b64decode(public_key_b64)
72
+ public_key = PublicKey(public_key_bytes)
73
+ sealed_box = SealedBox(public_key)
74
+ encrypted = sealed_box.encrypt(secret_value.encode(), encoder=Base64Encoder)
75
+
76
+ return encrypted.decode("utf-8")
77
+
78
+
79
+ class Secret(Resource[SecretConfig, SecretOutputs]):
80
+ """GitHub secret resource.
81
+
82
+ Creates and manages secrets on GitHub repositories or deployment
83
+ environments via the REST API. Secrets are encrypted using the
84
+ repository or environment public key (NaCl sealed box) before upload.
85
+
86
+ GitHub secrets cannot be read back after creation -- only the
87
+ metadata (name, creation time, update time) is available.
88
+
89
+ Lifecycle:
90
+ - on_create: Fetches the public key, encrypts the secret value,
91
+ and creates or replaces the secret. Idempotent -- creating an
92
+ existing secret replaces its value.
93
+ - on_update: Re-encrypts and replaces the secret value using PUT.
94
+ Same operation as create.
95
+ - on_delete: Deletes the secret. Idempotent -- succeeds if the
96
+ secret does not exist.
97
+
98
+ Example::
99
+
100
+ resources:
101
+ - name: deploy-key
102
+ provider: github
103
+ type: secret
104
+ config:
105
+ access_token:
106
+ provider: pragma
107
+ resource: secret
108
+ name: github-token
109
+ field: outputs.value
110
+ owner: my-org
111
+ repository: my-repo
112
+ secret_name: DEPLOY_KEY
113
+ secret_value:
114
+ provider: pragma
115
+ resource: secret
116
+ name: deploy-key
117
+ field: outputs.value
118
+
119
+ - name: env-secret
120
+ provider: github
121
+ type: secret
122
+ config:
123
+ access_token:
124
+ provider: pragma
125
+ resource: secret
126
+ name: github-token
127
+ field: outputs.value
128
+ owner: my-org
129
+ repository: my-repo
130
+ secret_name: API_KEY
131
+ secret_value:
132
+ provider: pragma
133
+ resource: secret
134
+ name: api-key
135
+ field: outputs.value
136
+ environment_name: production
137
+ """
138
+
139
+ def _is_environment_secret(self) -> bool:
140
+ """Check whether this secret is scoped to an environment.
141
+
142
+ Returns:
143
+ True if environment_name is set.
144
+ """
145
+ return self.config.environment_name is not None
146
+
147
+ async def _fetch_public_key(self, client: Any) -> tuple[str, str]:
148
+ """Fetch the public key for secret encryption.
149
+
150
+ Uses the environment public key endpoint when the secret is
151
+ environment-scoped, otherwise uses the repository endpoint.
152
+
153
+ Args:
154
+ client: Authenticated GitHub API client.
155
+
156
+ Returns:
157
+ Tuple of (key_b64, key_id).
158
+ """
159
+ if self._is_environment_secret():
160
+ response = await client.get(
161
+ f"/repos/{self.config.owner}/{self.config.repository}"
162
+ f"/environments/{self.config.environment_name}/secrets/public-key",
163
+ )
164
+ else:
165
+ response = await client.get(
166
+ f"/repos/{self.config.owner}/{self.config.repository}/actions/secrets/public-key",
167
+ )
168
+
169
+ await raise_for_status(response)
170
+ data = response.json()
171
+
172
+ return data["key"], data["key_id"]
173
+
174
+ async def _put_secret(self, client: Any, encrypted_value: str, key_id: str) -> None:
175
+ """Create or replace the secret with the encrypted value.
176
+
177
+ Args:
178
+ client: Authenticated GitHub API client.
179
+ encrypted_value: Base64-encoded encrypted secret value.
180
+ key_id: Public key ID used for encryption.
181
+ """
182
+ body = {
183
+ "encrypted_value": encrypted_value,
184
+ "key_id": key_id,
185
+ }
186
+
187
+ if self._is_environment_secret():
188
+ response = await client.put(
189
+ f"/repos/{self.config.owner}/{self.config.repository}"
190
+ f"/environments/{self.config.environment_name}/secrets/{self.config.secret_name}",
191
+ json=body,
192
+ )
193
+ else:
194
+ response = await client.put(
195
+ f"/repos/{self.config.owner}/{self.config.repository}/actions/secrets/{self.config.secret_name}",
196
+ json=body,
197
+ )
198
+
199
+ await raise_for_status(response)
200
+
201
+ async def _fetch_secret_metadata(self, client: Any) -> dict[str, Any]:
202
+ """Fetch secret metadata (name, timestamps).
203
+
204
+ Args:
205
+ client: Authenticated GitHub API client.
206
+
207
+ Returns:
208
+ Secret metadata dictionary from the API.
209
+ """
210
+ if self._is_environment_secret():
211
+ response = await client.get(
212
+ f"/repos/{self.config.owner}/{self.config.repository}"
213
+ f"/environments/{self.config.environment_name}/secrets/{self.config.secret_name}",
214
+ )
215
+ else:
216
+ response = await client.get(
217
+ f"/repos/{self.config.owner}/{self.config.repository}/actions/secrets/{self.config.secret_name}",
218
+ )
219
+
220
+ await raise_for_status(response)
221
+
222
+ return response.json()
223
+
224
+ def _build_outputs(self, metadata: dict[str, Any]) -> SecretOutputs:
225
+ """Build outputs from secret metadata.
226
+
227
+ Args:
228
+ metadata: Secret metadata from the GitHub API.
229
+
230
+ Returns:
231
+ SecretOutputs with secret metadata.
232
+ """
233
+ scope = "environment" if self._is_environment_secret() else "repository"
234
+
235
+ return SecretOutputs(
236
+ secret_name=metadata["name"],
237
+ scope=scope,
238
+ environment_name=self.config.environment_name or "",
239
+ created_at=metadata.get("created_at", ""),
240
+ updated_at=metadata.get("updated_at", ""),
241
+ )
242
+
243
+ async def _apply_secret(self) -> SecretOutputs:
244
+ """Encrypt and create or replace the secret.
245
+
246
+ Returns:
247
+ SecretOutputs with secret metadata.
248
+ """
249
+ client = create_github_client(self.config.access_token)
250
+
251
+ try:
252
+ key_b64, key_id = await self._fetch_public_key(client)
253
+ encrypted_value = _encrypt_secret(key_b64, self.config.secret_value)
254
+ await self._put_secret(client, encrypted_value, key_id)
255
+ metadata = await self._fetch_secret_metadata(client)
256
+
257
+ return self._build_outputs(metadata)
258
+ finally:
259
+ await client.aclose()
260
+
261
+ async def on_create(self) -> SecretOutputs:
262
+ """Create or replace a GitHub secret.
263
+
264
+ Returns:
265
+ SecretOutputs with secret metadata.
266
+ """
267
+ return await self._apply_secret()
268
+
269
+ async def on_update(self, previous_config: SecretConfig) -> SecretOutputs:
270
+ """Update the secret value.
271
+
272
+ Secrets are replaced entirely on every update since the value
273
+ cannot be read back for comparison.
274
+
275
+ Args:
276
+ previous_config: The previous configuration before update.
277
+
278
+ Returns:
279
+ SecretOutputs with updated secret metadata.
280
+ """
281
+ return await self._apply_secret()
282
+
283
+ async def on_delete(self) -> None:
284
+ """Delete the GitHub secret.
285
+
286
+ Idempotent: Succeeds if the secret does not exist.
287
+ """
288
+ if self.outputs is None:
289
+ return
290
+
291
+ client = create_github_client(self.config.access_token)
292
+
293
+ try:
294
+ if self._is_environment_secret():
295
+ response = await client.delete(
296
+ f"/repos/{self.config.owner}/{self.config.repository}"
297
+ f"/environments/{self.config.environment_name}/secrets/{self.config.secret_name}",
298
+ )
299
+ else:
300
+ response = await client.delete(
301
+ f"/repos/{self.config.owner}/{self.config.repository}/actions/secrets/{self.config.secret_name}",
302
+ )
303
+
304
+ if response.status_code == 404:
305
+ return
306
+
307
+ await raise_for_status(response)
308
+ finally:
309
+ await client.aclose()
310
+
311
+ @classmethod
312
+ def upgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
313
+ return config, outputs
314
+
315
+ @classmethod
316
+ def downgrade(cls, config: dict, outputs: dict) -> tuple[dict, dict]: # noqa: D102
317
+ return config, outputs