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.
- pragmatiks_github_provider-0.2.1/PKG-INFO +25 -0
- pragmatiks_github_provider-0.2.1/README.md +13 -0
- pragmatiks_github_provider-0.2.1/pyproject.toml +56 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/__init__.py +43 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/client.py +59 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/resources/__init__.py +37 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/resources/environment.py +235 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/resources/repository.py +257 -0
- pragmatiks_github_provider-0.2.1/src/github_provider/resources/secret.py +317 -0
|
@@ -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
|