ghfleet 0.1.0__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.
- ghfleet-0.1.0/.github/workflows/build.yml +75 -0
- ghfleet-0.1.0/.gitignore +11 -0
- ghfleet-0.1.0/LICENSE +21 -0
- ghfleet-0.1.0/PKG-INFO +273 -0
- ghfleet-0.1.0/README.md +226 -0
- ghfleet-0.1.0/ghfleet/__init__.py +47 -0
- ghfleet-0.1.0/ghfleet/config.py +148 -0
- ghfleet-0.1.0/ghfleet/py.typed +0 -0
- ghfleet-0.1.0/ghfleet/resources.py +285 -0
- ghfleet-0.1.0/pyproject.toml +79 -0
- ghfleet-0.1.0/uv.lock +892 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: Build
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ master ]
|
|
6
|
+
tags: [ '*.*.*' ]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [ master ]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
Test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v7
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
27
|
+
uv sync --group dev
|
|
28
|
+
|
|
29
|
+
- name: Check formatting
|
|
30
|
+
run: |
|
|
31
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
32
|
+
uv run ruff format --check .
|
|
33
|
+
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: |
|
|
36
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
37
|
+
uv run ruff check .
|
|
38
|
+
|
|
39
|
+
- name: Type check
|
|
40
|
+
run: |
|
|
41
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
42
|
+
uv run mypy ghfleet
|
|
43
|
+
|
|
44
|
+
Publish-PyPI:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
needs: [Test]
|
|
47
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
48
|
+
permissions:
|
|
49
|
+
contents: read
|
|
50
|
+
id-token: write # Required for OIDC trusted publisher
|
|
51
|
+
|
|
52
|
+
steps:
|
|
53
|
+
- name: Checkout repository
|
|
54
|
+
uses: actions/checkout@v7
|
|
55
|
+
|
|
56
|
+
- name: Install uv
|
|
57
|
+
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
58
|
+
|
|
59
|
+
- name: Build package
|
|
60
|
+
run: |
|
|
61
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
62
|
+
uv build
|
|
63
|
+
|
|
64
|
+
- name: Publish to PyPI
|
|
65
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
66
|
+
|
|
67
|
+
Documentation:
|
|
68
|
+
if: github.ref == 'refs/heads/master'
|
|
69
|
+
needs: [Test]
|
|
70
|
+
uses: byjg/byjg.github.io/.github/workflows/add-doc.yaml@master
|
|
71
|
+
with:
|
|
72
|
+
folder: devops
|
|
73
|
+
project: ${{ github.event.repository.name }}
|
|
74
|
+
secrets:
|
|
75
|
+
DOC_TOKEN: ${{ secrets.DOC_TOKEN }}
|
ghfleet-0.1.0/.gitignore
ADDED
ghfleet-0.1.0/LICENSE
ADDED
|
@@ -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.
|
ghfleet-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
ghfleet-0.1.0/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_key: pulumi-github-fleet
|
|
3
|
+
tags: [python, devops]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ghfleet
|
|
7
|
+
|
|
8
|
+
[](https://github.com/byjg/pulumi-github-fleet/actions/workflows/build.yml)
|
|
9
|
+
[](https://pypi.org/project/ghfleet/)
|
|
10
|
+
|
|
11
|
+
Declare a fleet of GitHub repositories from one configuration file, with Pulumi:
|
|
12
|
+
repository settings, Actions secrets, workflow permissions and branch protection
|
|
13
|
+
rulesets.
|
|
14
|
+
|
|
15
|
+
It exists because the interesting part of managing dozens of repositories is not the
|
|
16
|
+
API calls, it is answering "which repositories get this, and what is it exactly?" in a
|
|
17
|
+
file you can read, review and diff.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install ghfleet # or: uv add ghfleet
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import pulumi
|
|
29
|
+
|
|
30
|
+
from ghfleet import apply, load_config
|
|
31
|
+
|
|
32
|
+
apply(load_config("config.yml"))
|
|
33
|
+
|
|
34
|
+
pulumi.export("summary", "GitHub repositories configured successfully")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That is the whole Pulumi program. `apply` takes the configuration as a plain
|
|
38
|
+
dictionary, so it does not care whether it came from YAML, JSON or was built in code.
|
|
39
|
+
|
|
40
|
+
### The two conventions are yours to change
|
|
41
|
+
|
|
42
|
+
`apply` would otherwise impose where a token and a secret value come from. Both are
|
|
43
|
+
keyword arguments, with the obvious behaviour as the default:
|
|
44
|
+
|
|
45
|
+
| Argument | Default |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `token_for(owner)` | reads `GITHUB_ACCESS_TOKEN_<owner>` from the environment, raising when unset |
|
|
48
|
+
| `secret_value(secret)` | the entry's own `value`, else the environment variable named after the secret |
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
apply(
|
|
52
|
+
load_config("config.yml"),
|
|
53
|
+
token_for=lambda owner: vault.read(f"github/{owner}").token,
|
|
54
|
+
secret_value=lambda secret: vault.read(secret["name"]).value,
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## The configuration
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
lists:
|
|
62
|
+
services:
|
|
63
|
+
- api-gateway
|
|
64
|
+
- billing
|
|
65
|
+
- notifications
|
|
66
|
+
|
|
67
|
+
repository:
|
|
68
|
+
- name: services
|
|
69
|
+
owner: acme
|
|
70
|
+
list:
|
|
71
|
+
- services
|
|
72
|
+
repositories:
|
|
73
|
+
- one-off-repo
|
|
74
|
+
repository_settings:
|
|
75
|
+
allow_update_branch: true
|
|
76
|
+
workflow_permissions:
|
|
77
|
+
default: write
|
|
78
|
+
can_approve_pull_request_reviews: false
|
|
79
|
+
branch_protection:
|
|
80
|
+
enabled: true
|
|
81
|
+
bypass_actor_ids: [5]
|
|
82
|
+
secrets:
|
|
83
|
+
- name: REGISTRY_USER
|
|
84
|
+
value: acme
|
|
85
|
+
- name: REGISTRY_TOKEN
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **`lists`** — named lists of repository names, so a repository set is written once and
|
|
89
|
+
referenced by several entries.
|
|
90
|
+
- **`repository`** — the entries. Each one names an `owner`, the repositories it covers
|
|
91
|
+
(through `list`, `repositories`, or both), and what they get. A repository named by
|
|
92
|
+
more than one entry receives the union.
|
|
93
|
+
|
|
94
|
+
### `repository_settings`
|
|
95
|
+
|
|
96
|
+
Declare **only** the settings that must be guaranteed. The accepted keys are in
|
|
97
|
+
`REPOSITORY_SETTINGS_KEYS`; an unknown one aborts the run.
|
|
98
|
+
|
|
99
|
+
A repository that declares this block becomes a `github.Repository` resource, adopted
|
|
100
|
+
into the state on the first run that declares it — no import step. The resource covers
|
|
101
|
+
the *whole* repository, so everything the block does not declare (description, topics,
|
|
102
|
+
visibility, the other merge options) is read back from the `github.get_repository` data
|
|
103
|
+
source and handed to the resource unchanged. Two consequences:
|
|
104
|
+
|
|
105
|
+
- `pulumi preview` shows a diff only for the keys you declared, which is what makes it
|
|
106
|
+
a drift report;
|
|
107
|
+
- an undeclared property is *not* enforced: change the description on GitHub and the
|
|
108
|
+
next run simply follows it.
|
|
109
|
+
|
|
110
|
+
The resource is declared with `retain_on_delete`, so neither `pulumi destroy` nor
|
|
111
|
+
removing a line from the configuration deletes a repository. Its secrets and ruleset
|
|
112
|
+
*are* removed — only the repository is retained.
|
|
113
|
+
|
|
114
|
+
`pages` is not an accepted key, and that absence is meaningful: the provider reads it
|
|
115
|
+
as "no Pages site" and disables Pages on any repository that still has one. The one
|
|
116
|
+
exception is the owner's user site, the repository named `<owner>.github.io`, which is
|
|
117
|
+
recognised by name and left alone (see "GitHub says no", below).
|
|
118
|
+
|
|
119
|
+
### `branch_protection`
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
branch_protection:
|
|
123
|
+
enabled: true
|
|
124
|
+
enforcement: active # active | evaluate | disabled
|
|
125
|
+
bypass_actor_ids: [5] # 2 = Triage, 3 = Write, 4 = Maintain, 5 = Admin
|
|
126
|
+
rules:
|
|
127
|
+
pull_request:
|
|
128
|
+
required_approving_review_count: 2
|
|
129
|
+
require_code_owner_review: true
|
|
130
|
+
allowed_merge_methods: [squash]
|
|
131
|
+
required_status_checks:
|
|
132
|
+
strict_required_status_checks_policy: true
|
|
133
|
+
required_checks:
|
|
134
|
+
- context: "Build"
|
|
135
|
+
deletion: true
|
|
136
|
+
non_fast_forward: true
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`rules`, `ref_name` and `bypass_actors` are handed to the provider **exactly as
|
|
140
|
+
written** — the resource accepts plain dictionaries — so every rule
|
|
141
|
+
`github.RepositoryRuleset` supports is reachable from the configuration without this
|
|
142
|
+
package knowing about it. The keys are the provider's, in snake_case.
|
|
143
|
+
|
|
144
|
+
Left out, `rules` falls back to `DEFAULT_BRANCH_PROTECTION_RULES`: one approving
|
|
145
|
+
review, dismiss stale reviews on push, all three merge methods, no branch deletion and
|
|
146
|
+
no force push. It is a default, not a rule of the package — writing `rules` replaces it
|
|
147
|
+
entirely rather than merging into it.
|
|
148
|
+
|
|
149
|
+
Other keys: `name` (default `Protect <default branch>`), `target` (`branch` or `tag`),
|
|
150
|
+
`ref_name` (`{includes, excludes}`, default the default branch), and `bypass_actors`
|
|
151
|
+
for anything that is not a repository role. An unknown key aborts the run: on a branch
|
|
152
|
+
protection block, a silent typo means a rule that is not enforced.
|
|
153
|
+
|
|
154
|
+
Note that rulesets on **private** repositories require a paid plan. On the free plan
|
|
155
|
+
GitHub answers 403, so the ruleset is skipped with a warning instead of failing the
|
|
156
|
+
run.
|
|
157
|
+
|
|
158
|
+
### `secrets`
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
secrets:
|
|
162
|
+
- name: REGISTRY_USER
|
|
163
|
+
value: acme # in the file, for values that are not secret
|
|
164
|
+
- name: REGISTRY_TOKEN # no value: taken from the environment
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The run aborts before declaring anything if a secret has no value anywhere. That check
|
|
168
|
+
is not politeness: an empty value would overwrite the real secret with an empty string
|
|
169
|
+
in every repository that declares it.
|
|
170
|
+
|
|
171
|
+
### Sharing blocks between entries
|
|
172
|
+
|
|
173
|
+
Entries repeat themselves. Use YAML anchors — a feature of the file format, so nothing
|
|
174
|
+
here has to implement it:
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
x-defaults:
|
|
178
|
+
branch_protection: &protected
|
|
179
|
+
enabled: true
|
|
180
|
+
bypass_actor_ids: [5]
|
|
181
|
+
|
|
182
|
+
repository:
|
|
183
|
+
- name: services
|
|
184
|
+
branch_protection: *protected
|
|
185
|
+
|
|
186
|
+
- name: legacy
|
|
187
|
+
branch_protection:
|
|
188
|
+
<<: *protected # inherit, then override
|
|
189
|
+
enforcement: "disabled"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Precedence stays local and explicit: a key written next to `<<:` wins.
|
|
193
|
+
|
|
194
|
+
## GitHub says no
|
|
195
|
+
|
|
196
|
+
Two settings answer 422 whatever you send, and both cost a failed run to discover.
|
|
197
|
+
|
|
198
|
+
**`allow_forking` on a private repository of a user account.** The repository-level
|
|
199
|
+
forking policy only exists for private repositories owned by an *organization*, and
|
|
200
|
+
even there an organization owner has to allow forking of private repositories first.
|
|
201
|
+
On a public repository the setting means nothing. It is therefore not among the
|
|
202
|
+
accepted keys, which is also what the provider recommends — its schema says to "leave
|
|
203
|
+
unset for the default behaviour". The restriction is not in the REST reference; it is
|
|
204
|
+
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).
|
|
205
|
+
|
|
206
|
+
**Deactivating GitHub Pages on the owner's user site.** The repository named
|
|
207
|
+
`<owner>.github.io` cannot have its Pages site unpublished:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
DELETE /repos/<owner>/<owner>.github.io/pages: 422 Deactivating GitHub pages
|
|
211
|
+
for this repository is not allowed
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This one is documented nowhere — neither the REST reference nor the "Unpublishing a
|
|
215
|
+
GitHub Pages site" guide mentions it, and that guide draws no distinction between a
|
|
216
|
+
user site and a project site. It is only what the API does, so the package recognises
|
|
217
|
+
the repository by name and keeps `pages` out of its diff.
|
|
218
|
+
|
|
219
|
+
## What this is not
|
|
220
|
+
|
|
221
|
+
An ordinary Python package, not a Pulumi component: it declares its resources into the
|
|
222
|
+
stack that calls it, and can only be consumed from Python.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -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
|
+
]
|