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 +47 -0
- ghfleet/config.py +148 -0
- ghfleet/py.typed +0 -0
- ghfleet/resources.py +285 -0
- ghfleet-0.1.0.dist-info/METADATA +273 -0
- ghfleet-0.1.0.dist-info/RECORD +8 -0
- ghfleet-0.1.0.dist-info/WHEEL +4 -0
- ghfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://github.com/byjg/pulumi-github-fleet/actions/workflows/build.yml)
|
|
56
|
+
[](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,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.
|