ghfleet 0.1.0__py3-none-any.whl

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.
ghfleet/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Declare GitHub repositories, secrets, workflow permissions and branch protection
3
+ rulesets from a configuration dictionary.
4
+
5
+ from ghfleet import apply, load_config
6
+
7
+ apply(load_config("config.yml"))
8
+
9
+ `apply` takes the configuration as a plain dictionary, so it does not care whether it
10
+ came from YAML, JSON or was built in code. The two conventions it would otherwise
11
+ impose -- where the token of an owner comes from, and where the value of a secret
12
+ comes from -- are its keyword arguments.
13
+ """
14
+
15
+ from .config import (
16
+ BRANCH_PROTECTION_KEYS,
17
+ DEFAULT_BRANCH_PROTECTION_RULES,
18
+ REPOSITORY_SETTINGS_KEYS,
19
+ branch_protection_of,
20
+ load_config,
21
+ repositories_of,
22
+ repository_settings_of,
23
+ resolve_list_references,
24
+ ruleset_name_of,
25
+ )
26
+ from .resources import (
27
+ apply,
28
+ get_owner_plan,
29
+ secret_value_from_environment,
30
+ token_from_environment,
31
+ )
32
+
33
+ __all__ = [
34
+ "BRANCH_PROTECTION_KEYS",
35
+ "DEFAULT_BRANCH_PROTECTION_RULES",
36
+ "REPOSITORY_SETTINGS_KEYS",
37
+ "apply",
38
+ "branch_protection_of",
39
+ "get_owner_plan",
40
+ "load_config",
41
+ "repositories_of",
42
+ "repository_settings_of",
43
+ "resolve_list_references",
44
+ "ruleset_name_of",
45
+ "secret_value_from_environment",
46
+ "token_from_environment",
47
+ ]
ghfleet/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """Reading and validating the configuration."""
2
+
3
+ import yaml
4
+
5
+ # Repository properties this program is able to manage: every one of them is both
6
+ # readable from the `github.get_repository` data source and writable on the
7
+ # `github.Repository` resource. `repository_settings` in config.yml accepts exactly
8
+ # these keys, and whatever it leaves out is fed back from the data source so Pulumi
9
+ # never overwrites it with a provider default.
10
+ #
11
+ # Left out on purpose: `name` (set from the config), `default_branch` (deprecated on
12
+ # the resource, use `BranchDefault`), `private` (superseded by `visibility`), `fork`
13
+ # (a creation-time input), and `has_downloads` (deprecated on the provider, which
14
+ # warns once per repository on every run).
15
+ #
16
+ # `pages` is deliberately absent as well, and that absence is what disables GitHub
17
+ # Pages: the provider calls DisablePages when the property is empty. The documentation
18
+ # is served from Cloudflare (opensource.byjg.com), so no repository here should have a
19
+ # Pages site -- the one exception is the owner's user site, handled in __main__.py.
20
+ # It could not be declared anyway -- the data source returns a list while the resource
21
+ # takes a single object.
22
+ #
23
+ # `allow_forking` is also out. It only configures private forking of *organization*
24
+ # owned private repositories, and even there it depends on an organization-level
25
+ # policy; on a public repository it means nothing. The provider documents the correct
26
+ # use as leaving it unset, and only sends it when the value changes on a non-public
27
+ # repository -- declaring it made GitHub answer 422 on byjg/boletimdeurna, a private
28
+ # repository of a user account, where the setting does not exist at all.
29
+ REPOSITORY_SETTINGS_KEYS = (
30
+ "allow_auto_merge",
31
+ "allow_merge_commit",
32
+ "allow_rebase_merge",
33
+ "allow_squash_merge",
34
+ "allow_update_branch",
35
+ "archived",
36
+ "delete_branch_on_merge",
37
+ "description",
38
+ "has_discussions",
39
+ "has_issues",
40
+ "has_projects",
41
+ "has_wiki",
42
+ "homepage_url",
43
+ "is_template",
44
+ "merge_commit_message",
45
+ "merge_commit_title",
46
+ "squash_merge_commit_message",
47
+ "squash_merge_commit_title",
48
+ "topics",
49
+ "visibility",
50
+ )
51
+
52
+
53
+ # Keys accepted inside a `branch_protection` block. `rules`, `bypass_actors` and
54
+ # `ref_name` are handed to the provider as they are written, so every rule the
55
+ # provider supports is available from config.yml without any code here knowing it.
56
+ BRANCH_PROTECTION_KEYS = frozenset(
57
+ {
58
+ "enabled",
59
+ "enforcement",
60
+ "name",
61
+ "target",
62
+ "ref_name",
63
+ "rules",
64
+ "bypass_actors",
65
+ "bypass_actor_ids",
66
+ }
67
+ )
68
+
69
+ # The policy applied when a `branch_protection` block does not carry its own `rules`.
70
+ # It is a default, not a rule of the program: write `rules` in config.yml to replace it.
71
+ DEFAULT_BRANCH_PROTECTION_RULES = {
72
+ "pull_request": {
73
+ "required_approving_review_count": 1,
74
+ "dismiss_stale_reviews_on_push": True,
75
+ "require_code_owner_review": False,
76
+ "require_last_push_approval": False,
77
+ "required_review_thread_resolution": False,
78
+ "allowed_merge_methods": ["merge", "squash", "rebase"],
79
+ },
80
+ "deletion": True,
81
+ "non_fast_forward": True,
82
+ "required_linear_history": False,
83
+ "required_signatures": False,
84
+ }
85
+
86
+
87
+ def load_config(path):
88
+ """Load a configuration file in the format this package expects."""
89
+ with open(path) as file:
90
+ return yaml.load(file, Loader=yaml.FullLoader)
91
+
92
+
93
+ def resolve_list_references(list_items, lists_config):
94
+ """
95
+ Resolve list references to actual repository names.
96
+
97
+ Args:
98
+ list_items: List that can contain either repository names or list references
99
+ lists_config: Dictionary of named lists from the config
100
+
101
+ Returns:
102
+ List of actual repository names
103
+ """
104
+ resolved = []
105
+ for item in list_items:
106
+ # Check if this item is a reference to a named list
107
+ if item in lists_config:
108
+ # It's a list reference, expand it
109
+ resolved.extend(lists_config[item])
110
+ else:
111
+ # It's a direct repository name
112
+ resolved.append(item)
113
+ return resolved
114
+
115
+
116
+ def repositories_of(entry, config):
117
+ """Every repository name a `repository` entry covers, lists resolved."""
118
+ resolved_from_lists = resolve_list_references(entry.get("list", []), config.get("lists", {}))
119
+ return resolved_from_lists + entry.get("repositories", [])
120
+
121
+
122
+ def repository_settings_of(entry):
123
+ """The `repository_settings` block of an entry, rejecting keys GitHub would ignore."""
124
+ settings = entry.get("repository_settings", {})
125
+ unknown = sorted(set(settings) - set(REPOSITORY_SETTINGS_KEYS))
126
+ if unknown:
127
+ raise Exception(
128
+ f"Unknown repository_settings in '{entry.get('name', 'unnamed')}': "
129
+ f"{', '.join(unknown)}. Aborting."
130
+ )
131
+ return settings
132
+
133
+
134
+ def branch_protection_of(entry):
135
+ """The `branch_protection` block of an entry, rejecting keys the program ignores."""
136
+ config = entry.get("branch_protection", {})
137
+ unknown = sorted(set(config) - BRANCH_PROTECTION_KEYS)
138
+ if unknown:
139
+ raise Exception(
140
+ f"Unknown branch_protection in '{entry.get('name', 'unnamed')}': "
141
+ f"{', '.join(unknown)}. Aborting."
142
+ )
143
+ return config
144
+
145
+
146
+ def ruleset_name_of(branch_protection, default_branch):
147
+ """The ruleset name, which import_rulesets.py has to match to adopt an existing one."""
148
+ return branch_protection.get("name", f"Protect {default_branch}")
ghfleet/py.typed ADDED
File without changes
ghfleet/resources.py ADDED
@@ -0,0 +1,285 @@
1
+ """Turning a configuration into Pulumi resources."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ import pulumi
9
+ import pulumi_github as github
10
+
11
+ from .config import (
12
+ DEFAULT_BRANCH_PROTECTION_RULES,
13
+ REPOSITORY_SETTINGS_KEYS,
14
+ branch_protection_of,
15
+ repositories_of,
16
+ repository_settings_of,
17
+ ruleset_name_of,
18
+ )
19
+
20
+
21
+ def github_api_get(path, token):
22
+ """GET a GitHub REST API path and return the parsed JSON, or None on any HTTP error."""
23
+ request = urllib.request.Request(
24
+ f"https://api.github.com{path}",
25
+ headers={
26
+ "Authorization": f"Bearer {token}",
27
+ "Accept": "application/vnd.github+json",
28
+ "X-GitHub-Api-Version": "2022-11-28",
29
+ },
30
+ )
31
+ try:
32
+ with urllib.request.urlopen(request, timeout=10) as response:
33
+ return json.load(response)
34
+ except urllib.error.URLError:
35
+ return None
36
+
37
+
38
+ def get_owner_plan(owner, token):
39
+ """
40
+ Return the GitHub plan name for an owner ("free", "pro", "team", ...), or None
41
+ if it cannot be determined.
42
+
43
+ The plan lives in a different place depending on the owner type:
44
+ - Organization: GET /orgs/{owner} (token needs the admin:org scope)
45
+ - User account: GET /user (token needs the read:user scope)
46
+ """
47
+ account = github_api_get(f"/users/{owner}", token)
48
+ if account is None:
49
+ return None
50
+
51
+ if account.get("type") == "Organization":
52
+ data = github_api_get(f"/orgs/{owner}", token)
53
+ else:
54
+ # /user is the *authenticated* user, which is only the plan we want
55
+ # when the token actually belongs to this owner.
56
+ data = github_api_get("/user", token)
57
+ if data is not None and data.get("login") != owner:
58
+ return None
59
+
60
+ return ((data or {}).get("plan") or {}).get("name")
61
+
62
+
63
+ def token_from_environment(owner):
64
+ """The token for an owner, from GITHUB_ACCESS_TOKEN_<owner>."""
65
+ variable = f"GITHUB_ACCESS_TOKEN_{owner.replace('-', '_')}"
66
+ token = os.environ.get(variable)
67
+ if token is None:
68
+ raise Exception(f"{variable} is not set. Aborting.")
69
+ return token
70
+
71
+
72
+ def secret_value_from_environment(secret):
73
+ """Value from the configuration, else from the environment variable named after it."""
74
+ value = secret.get("value")
75
+ return value if value is not None else os.environ.get(secret["name"])
76
+
77
+
78
+ def apply(config, *, token_for=token_from_environment, secret_value=secret_value_from_environment):
79
+ """
80
+ Declare every resource the configuration describes.
81
+
82
+ Args:
83
+ config: The configuration, as `load_config` returns it.
84
+ token_for: Called with an owner, returns its GitHub token. Defaults to reading
85
+ GITHUB_ACCESS_TOKEN_<owner> from the environment, raising when it is unset.
86
+ secret_value: Called with a `secrets` entry, returns the value to write.
87
+ Defaults to the entry's own `value`, else the environment variable named
88
+ after the secret.
89
+ """
90
+ # Create GitHub providers once per unique owner
91
+ providers = {}
92
+ owner_plans = {}
93
+ for repository in config.get("repository", []):
94
+ owner = repository["owner"]
95
+ if owner not in providers:
96
+ access_token = token_for(owner)
97
+ providers[owner] = github.Provider(f"github-{owner}", token=access_token, owner=owner)
98
+ owner_plans[owner] = get_owner_plan(owner, access_token)
99
+
100
+ # Build a comprehensive map of all repositories and their configurations
101
+ # This prevents duplicate resource creation if a repo appears in multiple configs
102
+ repo_configs = {} # Key: "owner/repo_name", Value: merged config
103
+
104
+ for repository in config.get("repository", []):
105
+ owner = repository["owner"]
106
+
107
+ repo_list = repositories_of(repository, config)
108
+
109
+ # Get settings for this configuration
110
+ workflow_permissions = repository.get("workflow_permissions", {})
111
+ branch_protection_config = branch_protection_of(repository)
112
+ secrets_config = repository.get("secrets", [])
113
+ repository_settings_config = repository_settings_of(repository)
114
+
115
+ # Process each repository and merge configs if it appears multiple times
116
+ for repo_name_item in repo_list:
117
+ full_name = f"{owner}/{repo_name_item}"
118
+
119
+ if full_name not in repo_configs:
120
+ # First time seeing this repo
121
+ repo_configs[full_name] = {
122
+ "owner": owner,
123
+ "repo_name": repo_name_item,
124
+ "workflow_permissions": workflow_permissions,
125
+ "branch_protection": branch_protection_config,
126
+ "secrets": list(secrets_config), # Copy the list
127
+ "repository_settings": dict(repository_settings_config), # Copy the dict
128
+ }
129
+ else:
130
+ # Repo already seen, merge configurations
131
+ existing = repo_configs[full_name]
132
+
133
+ # Merge workflow permissions (use most permissive)
134
+ if workflow_permissions:
135
+ existing["workflow_permissions"] = workflow_permissions
136
+
137
+ # Merge branch protection (enable if any config enables it)
138
+ if branch_protection_config.get("enabled", False):
139
+ existing["branch_protection"] = branch_protection_config
140
+
141
+ # Merge repository settings (a later entry wins on the same key)
142
+ existing["repository_settings"].update(repository_settings_config)
143
+
144
+ # Merge secrets (add new ones, avoid duplicates)
145
+ existing_secret_names = {s["name"] for s in existing["secrets"]}
146
+ for secret in secrets_config:
147
+ if secret["name"] not in existing_secret_names:
148
+ existing["secrets"].append(secret)
149
+
150
+ # Abort before declaring any secret: one without a value would overwrite the
151
+ # real secret with an empty string in every repository that declares it.
152
+ missing_secrets = sorted(
153
+ {
154
+ secret["name"]
155
+ for repo_config in repo_configs.values()
156
+ for secret in repo_config["secrets"]
157
+ if not secret_value(secret)
158
+ }
159
+ )
160
+ if missing_secrets:
161
+ raise Exception(
162
+ f"Secrets without a value: {', '.join(missing_secrets)}. Load env.txt. Aborting."
163
+ )
164
+
165
+ # Now process each unique repository once with merged configuration
166
+ for full_name, repo_config in repo_configs.items():
167
+ owner = repo_config["owner"]
168
+ repo_name_item = repo_config["repo_name"]
169
+ workflow_permissions = repo_config["workflow_permissions"]
170
+ branch_protection_config = repo_config["branch_protection"]
171
+ secrets_config = repo_config["secrets"]
172
+ repository_settings = repo_config["repository_settings"]
173
+
174
+ provider = providers[owner]
175
+ resource_prefix = full_name.replace("/", "-").replace("_", "-")
176
+
177
+ # 1) Get the repository data source
178
+ repo_data = github.get_repository(
179
+ full_name=full_name, opts=pulumi.InvokeOptions(provider=provider)
180
+ )
181
+
182
+ # 2) Repository settings
183
+ # The provider only exposes these through the whole repository resource. The
184
+ # repositories already exist, so `import_` adopts each one on the run that first
185
+ # declares it -- no import script, and a repository added to config.yml later is
186
+ # adopted the same way. Pulumi skips the import once the resource is in the state.
187
+ # Every property config.yml does not declare is fed back from the data source: the
188
+ # desired state then equals reality and `pulumi preview` shows a diff only for what
189
+ # was actually declared, instead of resetting descriptions and topics to a default.
190
+ if repository_settings:
191
+ baseline = {key: getattr(repo_data, key) for key in REPOSITORY_SETTINGS_KEYS}
192
+ # The absence of `pages` disables GitHub Pages, which is what this program
193
+ # wants everywhere -- except on the owner's user site, the repository named
194
+ # <owner>.github.io, where GitHub answers 422 "Deactivating GitHub pages for
195
+ # this repository is not allowed". That restriction is not in the REST
196
+ # documentation; it is what the API does.
197
+ is_user_site = repo_name_item.lower() == f"{owner.lower()}.github.io"
198
+ github.Repository(
199
+ f"{resource_prefix}-repository",
200
+ name=repo_name_item,
201
+ **{**baseline, **repository_settings},
202
+ opts=pulumi.ResourceOptions(
203
+ provider=provider,
204
+ import_=repo_name_item,
205
+ ignore_changes=["pages"] if is_user_site else [],
206
+ # A repository is never ours to delete: `pulumi destroy` drops it from
207
+ # the state and leaves it on GitHub.
208
+ retain_on_delete=True,
209
+ ),
210
+ )
211
+
212
+ # 3) Set workflow permissions
213
+ if workflow_permissions:
214
+ default_permission = workflow_permissions.get("default", "read")
215
+ can_approve_pr = workflow_permissions.get("can_approve_pull_request_reviews", False)
216
+
217
+ # Set GITHUB_TOKEN default permissions
218
+ github.WorkflowRepositoryPermissions(
219
+ f"{resource_prefix}-workflow-permissions",
220
+ repository=repo_name_item,
221
+ default_workflow_permissions=default_permission,
222
+ can_approve_pull_request_reviews=can_approve_pr,
223
+ opts=pulumi.ResourceOptions(provider=provider),
224
+ )
225
+
226
+ # 4) Create/Update secrets
227
+ for secret in secrets_config:
228
+ secret_name = secret["name"]
229
+ github.ActionsSecret(
230
+ f"{resource_prefix}-secret-{secret_name.lower().replace('_', '-')}",
231
+ repository=repo_name_item,
232
+ secret_name=secret_name,
233
+ plaintext_value=secret_value(secret),
234
+ opts=pulumi.ResourceOptions(provider=provider),
235
+ )
236
+
237
+ # 5) Create/Update branch protection ruleset
238
+ # Rulesets on *private* repos require a paid plan; GitHub answers 403 on the free
239
+ # plan. Public repos support them on every plan.
240
+ owner_plan = owner_plans.get(owner)
241
+ rulesets_supported = repo_data.visibility != "private" or (
242
+ owner_plan is not None and owner_plan != "free"
243
+ )
244
+
245
+ if branch_protection_config.get("enabled", False) and not rulesets_supported:
246
+ if owner_plan is None:
247
+ reason = (
248
+ "could not determine the plan for this owner "
249
+ "(the token needs read:user for user accounts, admin:org for organizations)"
250
+ )
251
+ else:
252
+ reason = f"private repository on the '{owner_plan}' plan does not support rulesets"
253
+ pulumi.log.warn(f"Skipping branch protection ruleset for {full_name}: {reason}")
254
+ elif branch_protection_config.get("enabled", False):
255
+ # Get the default branch from the data source
256
+ default_branch = repo_data.default_branch
257
+
258
+ # `rules`, `ref_name` and `bypass_actors` go to the provider exactly as they are
259
+ # written in config.yml -- the resource accepts plain dictionaries -- so every
260
+ # rule the provider supports is reachable without this program knowing about it.
261
+ # `bypass_actor_ids` stays as the shorthand for repository roles:
262
+ # 2 = Triage, 3 = Write, 4 = Maintain, 5 = Admin.
263
+ bypass_actors = branch_protection_config.get("bypass_actors")
264
+ if bypass_actors is None:
265
+ bypass_actors = [
266
+ {"actor_id": actor_id, "actor_type": "RepositoryRole", "bypass_mode": "always"}
267
+ for actor_id in branch_protection_config.get("bypass_actor_ids", [5])
268
+ ]
269
+
270
+ github.RepositoryRuleset(
271
+ f"{resource_prefix}-branch-protection",
272
+ name=ruleset_name_of(branch_protection_config, default_branch),
273
+ repository=repo_name_item,
274
+ target=branch_protection_config.get("target", "branch"),
275
+ enforcement=branch_protection_config.get("enforcement", "active"),
276
+ bypass_actors=bypass_actors,
277
+ conditions={
278
+ "ref_name": branch_protection_config.get(
279
+ "ref_name",
280
+ {"includes": [f"refs/heads/{default_branch}"], "excludes": []},
281
+ )
282
+ },
283
+ rules=branch_protection_config.get("rules", DEFAULT_BRANCH_PROTECTION_RULES),
284
+ opts=pulumi.ResourceOptions(provider=provider),
285
+ )
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.5
2
+ Name: ghfleet
3
+ Version: 0.1.0
4
+ Summary: Declare a fleet of GitHub repositories -- settings, secrets, workflow permissions and branch protection rulesets -- from one configuration file, with Pulumi
5
+ Project-URL: Homepage, https://github.com/byjg/pulumi-github-fleet
6
+ Project-URL: Documentation, https://opensource.byjg.com/docs/devops/pulumi-github-fleet/
7
+ Project-URL: Source, https://github.com/byjg/pulumi-github-fleet
8
+ Author-email: Joao Gilberto Magalhaes <joao@byjg.com.br>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Joao Gilberto Magalhães
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: branch-protection,github,infrastructure-as-code,pulumi,repository-settings
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: Intended Audience :: System Administrators
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Software Development :: Version Control :: Git
41
+ Classifier: Topic :: System :: Systems Administration
42
+ Requires-Python: >=3.10
43
+ Requires-Dist: pulumi-github<7.0.0,>=6.0.0
44
+ Requires-Dist: pulumi<4.0.0,>=3.0.0
45
+ Requires-Dist: pyyaml>=6.0.0
46
+ Description-Content-Type: text/markdown
47
+
48
+ ---
49
+ sidebar_key: pulumi-github-fleet
50
+ tags: [python, devops]
51
+ ---
52
+
53
+ # ghfleet
54
+
55
+ [![Build](https://github.com/byjg/pulumi-github-fleet/actions/workflows/build.yml/badge.svg)](https://github.com/byjg/pulumi-github-fleet/actions/workflows/build.yml)
56
+ [![PyPI](https://img.shields.io/pypi/v/ghfleet.svg)](https://pypi.org/project/ghfleet/)
57
+
58
+ Declare a fleet of GitHub repositories from one configuration file, with Pulumi:
59
+ repository settings, Actions secrets, workflow permissions and branch protection
60
+ rulesets.
61
+
62
+ It exists because the interesting part of managing dozens of repositories is not the
63
+ API calls, it is answering "which repositories get this, and what is it exactly?" in a
64
+ file you can read, review and diff.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install ghfleet # or: uv add ghfleet
70
+ ```
71
+
72
+ ## Use
73
+
74
+ ```python
75
+ import pulumi
76
+
77
+ from ghfleet import apply, load_config
78
+
79
+ apply(load_config("config.yml"))
80
+
81
+ pulumi.export("summary", "GitHub repositories configured successfully")
82
+ ```
83
+
84
+ That is the whole Pulumi program. `apply` takes the configuration as a plain
85
+ dictionary, so it does not care whether it came from YAML, JSON or was built in code.
86
+
87
+ ### The two conventions are yours to change
88
+
89
+ `apply` would otherwise impose where a token and a secret value come from. Both are
90
+ keyword arguments, with the obvious behaviour as the default:
91
+
92
+ | Argument | Default |
93
+ |---|---|
94
+ | `token_for(owner)` | reads `GITHUB_ACCESS_TOKEN_<owner>` from the environment, raising when unset |
95
+ | `secret_value(secret)` | the entry's own `value`, else the environment variable named after the secret |
96
+
97
+ ```python
98
+ apply(
99
+ load_config("config.yml"),
100
+ token_for=lambda owner: vault.read(f"github/{owner}").token,
101
+ secret_value=lambda secret: vault.read(secret["name"]).value,
102
+ )
103
+ ```
104
+
105
+ ## The configuration
106
+
107
+ ```yaml
108
+ lists:
109
+ services:
110
+ - api-gateway
111
+ - billing
112
+ - notifications
113
+
114
+ repository:
115
+ - name: services
116
+ owner: acme
117
+ list:
118
+ - services
119
+ repositories:
120
+ - one-off-repo
121
+ repository_settings:
122
+ allow_update_branch: true
123
+ workflow_permissions:
124
+ default: write
125
+ can_approve_pull_request_reviews: false
126
+ branch_protection:
127
+ enabled: true
128
+ bypass_actor_ids: [5]
129
+ secrets:
130
+ - name: REGISTRY_USER
131
+ value: acme
132
+ - name: REGISTRY_TOKEN
133
+ ```
134
+
135
+ - **`lists`** — named lists of repository names, so a repository set is written once and
136
+ referenced by several entries.
137
+ - **`repository`** — the entries. Each one names an `owner`, the repositories it covers
138
+ (through `list`, `repositories`, or both), and what they get. A repository named by
139
+ more than one entry receives the union.
140
+
141
+ ### `repository_settings`
142
+
143
+ Declare **only** the settings that must be guaranteed. The accepted keys are in
144
+ `REPOSITORY_SETTINGS_KEYS`; an unknown one aborts the run.
145
+
146
+ A repository that declares this block becomes a `github.Repository` resource, adopted
147
+ into the state on the first run that declares it — no import step. The resource covers
148
+ the *whole* repository, so everything the block does not declare (description, topics,
149
+ visibility, the other merge options) is read back from the `github.get_repository` data
150
+ source and handed to the resource unchanged. Two consequences:
151
+
152
+ - `pulumi preview` shows a diff only for the keys you declared, which is what makes it
153
+ a drift report;
154
+ - an undeclared property is *not* enforced: change the description on GitHub and the
155
+ next run simply follows it.
156
+
157
+ The resource is declared with `retain_on_delete`, so neither `pulumi destroy` nor
158
+ removing a line from the configuration deletes a repository. Its secrets and ruleset
159
+ *are* removed — only the repository is retained.
160
+
161
+ `pages` is not an accepted key, and that absence is meaningful: the provider reads it
162
+ as "no Pages site" and disables Pages on any repository that still has one. The one
163
+ exception is the owner's user site, the repository named `<owner>.github.io`, which is
164
+ recognised by name and left alone (see "GitHub says no", below).
165
+
166
+ ### `branch_protection`
167
+
168
+ ```yaml
169
+ branch_protection:
170
+ enabled: true
171
+ enforcement: active # active | evaluate | disabled
172
+ bypass_actor_ids: [5] # 2 = Triage, 3 = Write, 4 = Maintain, 5 = Admin
173
+ rules:
174
+ pull_request:
175
+ required_approving_review_count: 2
176
+ require_code_owner_review: true
177
+ allowed_merge_methods: [squash]
178
+ required_status_checks:
179
+ strict_required_status_checks_policy: true
180
+ required_checks:
181
+ - context: "Build"
182
+ deletion: true
183
+ non_fast_forward: true
184
+ ```
185
+
186
+ `rules`, `ref_name` and `bypass_actors` are handed to the provider **exactly as
187
+ written** — the resource accepts plain dictionaries — so every rule
188
+ `github.RepositoryRuleset` supports is reachable from the configuration without this
189
+ package knowing about it. The keys are the provider's, in snake_case.
190
+
191
+ Left out, `rules` falls back to `DEFAULT_BRANCH_PROTECTION_RULES`: one approving
192
+ review, dismiss stale reviews on push, all three merge methods, no branch deletion and
193
+ no force push. It is a default, not a rule of the package — writing `rules` replaces it
194
+ entirely rather than merging into it.
195
+
196
+ Other keys: `name` (default `Protect <default branch>`), `target` (`branch` or `tag`),
197
+ `ref_name` (`{includes, excludes}`, default the default branch), and `bypass_actors`
198
+ for anything that is not a repository role. An unknown key aborts the run: on a branch
199
+ protection block, a silent typo means a rule that is not enforced.
200
+
201
+ Note that rulesets on **private** repositories require a paid plan. On the free plan
202
+ GitHub answers 403, so the ruleset is skipped with a warning instead of failing the
203
+ run.
204
+
205
+ ### `secrets`
206
+
207
+ ```yaml
208
+ secrets:
209
+ - name: REGISTRY_USER
210
+ value: acme # in the file, for values that are not secret
211
+ - name: REGISTRY_TOKEN # no value: taken from the environment
212
+ ```
213
+
214
+ The run aborts before declaring anything if a secret has no value anywhere. That check
215
+ is not politeness: an empty value would overwrite the real secret with an empty string
216
+ in every repository that declares it.
217
+
218
+ ### Sharing blocks between entries
219
+
220
+ Entries repeat themselves. Use YAML anchors — a feature of the file format, so nothing
221
+ here has to implement it:
222
+
223
+ ```yaml
224
+ x-defaults:
225
+ branch_protection: &protected
226
+ enabled: true
227
+ bypass_actor_ids: [5]
228
+
229
+ repository:
230
+ - name: services
231
+ branch_protection: *protected
232
+
233
+ - name: legacy
234
+ branch_protection:
235
+ <<: *protected # inherit, then override
236
+ enforcement: "disabled"
237
+ ```
238
+
239
+ Precedence stays local and explicit: a key written next to `<<:` wins.
240
+
241
+ ## GitHub says no
242
+
243
+ Two settings answer 422 whatever you send, and both cost a failed run to discover.
244
+
245
+ **`allow_forking` on a private repository of a user account.** The repository-level
246
+ forking policy only exists for private repositories owned by an *organization*, and
247
+ even there an organization owner has to allow forking of private repositories first.
248
+ On a public repository the setting means nothing. It is therefore not among the
249
+ accepted keys, which is also what the provider recommends — its schema says to "leave
250
+ unset for the default behaviour". The restriction is not in the REST reference; it is
251
+ in the [forking policy guide](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/managing-the-forking-policy-for-your-repository).
252
+
253
+ **Deactivating GitHub Pages on the owner's user site.** The repository named
254
+ `<owner>.github.io` cannot have its Pages site unpublished:
255
+
256
+ ```
257
+ DELETE /repos/<owner>/<owner>.github.io/pages: 422 Deactivating GitHub pages
258
+ for this repository is not allowed
259
+ ```
260
+
261
+ This one is documented nowhere — neither the REST reference nor the "Unpublishing a
262
+ GitHub Pages site" guide mentions it, and that guide draws no distinction between a
263
+ user site and a project site. It is only what the API does, so the package recognises
264
+ the repository by name and keeps `pages` out of its diff.
265
+
266
+ ## What this is not
267
+
268
+ An ordinary Python package, not a Pulumi component: it declares its resources into the
269
+ stack that calls it, and can only be consumed from Python.
270
+
271
+ ## License
272
+
273
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ ghfleet/__init__.py,sha256=7BEJL3arKLmnYHqkNo5wDA_dp5YTfDipxPm7ElxnpNQ,1260
2
+ ghfleet/config.py,sha256=uC_HhroQKP5R-s7fWJpMMWr1IgVspPzg-9ZK-FzQoQA,5542
3
+ ghfleet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ghfleet/resources.py,sha256=nPwL-Y9MDcVyInz40gy9Ce20Qad-PWdvn4s4GmjVXBE,12828
5
+ ghfleet-0.1.0.dist-info/METADATA,sha256=oU7cQOglSVNho7CDg0PBpqmlGPzHNd3jrdQHNHX3GP0,10670
6
+ ghfleet-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ ghfleet-0.1.0.dist-info/licenses/LICENSE,sha256=M-c6Pm-D5Pnem_B_AtqS4zAHtsU6EF6hl8ND18eyP2M,1081
8
+ ghfleet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joao Gilberto Magalhães
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.