gitops-replacer 0.1.1__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.
- gitops_replacer/__init__.py +9 -0
- gitops_replacer/__main__.py +336 -0
- gitops_replacer-0.1.1.dist-info/METADATA +345 -0
- gitops_replacer-0.1.1.dist-info/RECORD +8 -0
- gitops_replacer-0.1.1.dist-info/WHEEL +5 -0
- gitops_replacer-0.1.1.dist-info/entry_points.txt +2 -0
- gitops_replacer-0.1.1.dist-info/licenses/LICENSE +21 -0
- gitops_replacer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gitops-replacer
|
|
3
|
+
#
|
|
4
|
+
# Accepts a value as argument and updates marked values in YAML files
|
|
5
|
+
# in one or more GitHub repositories using a marker-based approach.
|
|
6
|
+
#
|
|
7
|
+
# Author: Simon Lauger <simon@lauger.de>
|
|
8
|
+
#
|
|
9
|
+
# Notes:
|
|
10
|
+
# - Uses marker comments (# gitops-replacer: <name>) to locate values.
|
|
11
|
+
# - Replaces the value on the line immediately following the marker.
|
|
12
|
+
# - Preserves comments, quotes, and formatting (no YAML parsing/serialization).
|
|
13
|
+
# - Uses requests.Session with retry & timeout.
|
|
14
|
+
#
|
|
15
|
+
import requests
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import re
|
|
20
|
+
import base64
|
|
21
|
+
import argparse
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import yaml
|
|
24
|
+
from yaml.loader import SafeLoader
|
|
25
|
+
from requests.adapters import HTTPAdapter
|
|
26
|
+
from urllib3.util.retry import Retry
|
|
27
|
+
|
|
28
|
+
DEFAULT_CONFIG = "gitops-replacer.json"
|
|
29
|
+
|
|
30
|
+
# Pattern to match marker comment: # gitops-replacer: <name>
|
|
31
|
+
MARKER_PATTERN = r'#\s*gitops-replacer:\s*(\S+)'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_session():
|
|
35
|
+
sess = requests.Session()
|
|
36
|
+
retries = Retry(
|
|
37
|
+
total=5,
|
|
38
|
+
read=5,
|
|
39
|
+
connect=5,
|
|
40
|
+
backoff_factor=0.5,
|
|
41
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
42
|
+
allowed_methods=frozenset(["GET", "PUT", "HEAD"]),
|
|
43
|
+
raise_on_status=False
|
|
44
|
+
)
|
|
45
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
46
|
+
sess.mount("https://", adapter)
|
|
47
|
+
sess.mount("http://", adapter)
|
|
48
|
+
return sess
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def replace_marked_value(content: str, dep_name: str, new_value: str) -> tuple[str, str | None, bool]:
|
|
52
|
+
"""
|
|
53
|
+
Replace value on line after marker with matching name.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
tuple: (new_content, old_value, changed)
|
|
57
|
+
"""
|
|
58
|
+
lines = content.split('\n')
|
|
59
|
+
result = []
|
|
60
|
+
replace_next = False
|
|
61
|
+
old_value = None
|
|
62
|
+
changed = False
|
|
63
|
+
|
|
64
|
+
for line in lines:
|
|
65
|
+
# Check if this line is a marker for our depName
|
|
66
|
+
match = re.search(MARKER_PATTERN, line)
|
|
67
|
+
if match and match.group(1) == dep_name:
|
|
68
|
+
replace_next = True
|
|
69
|
+
result.append(line)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if replace_next:
|
|
73
|
+
replace_next = False
|
|
74
|
+
# Replace value on this line, preserve key and quotes
|
|
75
|
+
# Pattern: key: value or key: "value" or key: 'value'
|
|
76
|
+
# Also handles YAML list items: - key: value
|
|
77
|
+
# Also handles inline comments after the value
|
|
78
|
+
value_pattern = r'^(\s*(?:-\s+)?[\w-]+:\s*)(["\']?)([^"\'#\n]*)(["\']?)(\s*#.*)?$'
|
|
79
|
+
value_match = re.match(value_pattern, line)
|
|
80
|
+
if value_match:
|
|
81
|
+
prefix = value_match.group(1) # " version: " or " - name: "
|
|
82
|
+
quote_open = value_match.group(2) # " or ' or empty
|
|
83
|
+
old_value = value_match.group(3).rstrip() # the actual value (stripped)
|
|
84
|
+
quote_close = value_match.group(4) # " or ' or empty
|
|
85
|
+
suffix = value_match.group(5) or '' # inline comment if any
|
|
86
|
+
|
|
87
|
+
if old_value != new_value:
|
|
88
|
+
line = f'{prefix}{quote_open}{new_value}{quote_close}{suffix}'
|
|
89
|
+
changed = True
|
|
90
|
+
|
|
91
|
+
result.append(line)
|
|
92
|
+
|
|
93
|
+
return '\n'.join(result), old_value, changed
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main():
|
|
97
|
+
parser = argparse.ArgumentParser(description='Marker-based value replacer for GitOps repositories.')
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
'--config',
|
|
100
|
+
metavar='<file>',
|
|
101
|
+
type=str,
|
|
102
|
+
help=f'configuration file (defaults to "{DEFAULT_CONFIG}")',
|
|
103
|
+
default=DEFAULT_CONFIG,
|
|
104
|
+
required=False,
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
'--apply',
|
|
108
|
+
action='store_true',
|
|
109
|
+
help='if set the changes will be applied to the repository, otherwise the script runs in dry-run mode',
|
|
110
|
+
required=False,
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
'--ci',
|
|
114
|
+
action='store_true',
|
|
115
|
+
help='enable the CI mode, which validates the environment variable GITHUB_REF against patterns in the config file',
|
|
116
|
+
required=False,
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
'value',
|
|
120
|
+
metavar='<string>',
|
|
121
|
+
help='value to set at the marked location',
|
|
122
|
+
type=str,
|
|
123
|
+
default=None,
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument('--name',
|
|
126
|
+
metavar='<string>',
|
|
127
|
+
help='author name which is used during the commit of the changes (env: GIT_COMMIT_NAME)',
|
|
128
|
+
type=str,
|
|
129
|
+
default=os.getenv('GIT_COMMIT_NAME', 'Replacer Bot'),
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument('--email',
|
|
132
|
+
metavar='<string>',
|
|
133
|
+
help='email which is used during the commit of the changes (env: GIT_COMMIT_EMAIL)',
|
|
134
|
+
type=str,
|
|
135
|
+
default=os.getenv('GIT_COMMIT_EMAIL', 'replacer-bot@localhost.localdomain'),
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument('--message',
|
|
138
|
+
metavar='<string>',
|
|
139
|
+
help='commit message template (default to "fix: update {} to {}")',
|
|
140
|
+
type=str,
|
|
141
|
+
default='fix: update {} to {}',
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument('--api',
|
|
144
|
+
metavar='<string>',
|
|
145
|
+
help='URL to the GitHub API (default: "https://api.github.com"; env: GITHUB_API_URL)',
|
|
146
|
+
type=str,
|
|
147
|
+
default=os.getenv('GITHUB_API_URL', 'https://api.github.com'),
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument('--verbose',
|
|
150
|
+
action='store_true',
|
|
151
|
+
help='enable verbose logging (prints file contents and desired state)',
|
|
152
|
+
required=False,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
|
|
157
|
+
# get variables from environment
|
|
158
|
+
git_ref = os.getenv('GITHUB_REF', os.getenv('GIT_REF', None))
|
|
159
|
+
github_token = os.getenv('GITHUB_TOKEN', None)
|
|
160
|
+
|
|
161
|
+
# validate variables
|
|
162
|
+
if not github_token:
|
|
163
|
+
print("error: GITHUB_TOKEN is not set")
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
if args.ci and not git_ref:
|
|
167
|
+
print("error: GITHUB_REF is not set (required in --ci mode)")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
# load config file
|
|
171
|
+
if not os.path.exists(args.config):
|
|
172
|
+
print(f"error: config file {args.config} does not exist")
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
with open(args.config, 'r') as f:
|
|
176
|
+
if args.config.endswith('.json'):
|
|
177
|
+
config = json.load(f)
|
|
178
|
+
else:
|
|
179
|
+
config = yaml.load(f, Loader=SafeLoader)
|
|
180
|
+
|
|
181
|
+
if 'gitops-replacer' not in config:
|
|
182
|
+
print("info: no gitops-replacer entry found in config, exiting")
|
|
183
|
+
sys.exit(0)
|
|
184
|
+
|
|
185
|
+
print(f"info: run replacer with value '{args.value}'")
|
|
186
|
+
|
|
187
|
+
# default to exit code 0
|
|
188
|
+
exit_code = 0
|
|
189
|
+
|
|
190
|
+
if not args.apply:
|
|
191
|
+
print("info: running in dry-run, no changes will be applied")
|
|
192
|
+
|
|
193
|
+
session = make_session()
|
|
194
|
+
headers = {
|
|
195
|
+
'Authorization': f'token {github_token}',
|
|
196
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
197
|
+
}
|
|
198
|
+
timeout = 30
|
|
199
|
+
|
|
200
|
+
# precheck block (cache responses for later reuse)
|
|
201
|
+
cache = {}
|
|
202
|
+
for repo in config['gitops-replacer']:
|
|
203
|
+
repo_path = repo['repository']
|
|
204
|
+
branch = repo['branch']
|
|
205
|
+
file_path = repo['file']
|
|
206
|
+
dep_name = repo['depName']
|
|
207
|
+
cache_key = f"{repo_path}:{branch}:{file_path}"
|
|
208
|
+
|
|
209
|
+
print(f"info: validate if {file_path} from repository {repo_path} in branch {branch} exists")
|
|
210
|
+
|
|
211
|
+
# Use GET for reliability; do not decode content here
|
|
212
|
+
url = f"{args.api}/repos/{repo_path}/contents/{file_path}?ref={urllib.parse.quote(branch)}"
|
|
213
|
+
if args.verbose:
|
|
214
|
+
print(url)
|
|
215
|
+
precheck = session.get(url, headers=headers, timeout=timeout)
|
|
216
|
+
if precheck.status_code == 401:
|
|
217
|
+
print("error: 401 unauthorized - maybe your token does not have access to the defined repository")
|
|
218
|
+
exit_code = 1
|
|
219
|
+
elif precheck.status_code == 404:
|
|
220
|
+
print("error: 404 not found - make sure that the file exists in the defined target")
|
|
221
|
+
exit_code = 1
|
|
222
|
+
elif precheck.status_code != 200:
|
|
223
|
+
print(f"error: unknown error with HTTP code {precheck.status_code}")
|
|
224
|
+
exit_code = 1
|
|
225
|
+
else:
|
|
226
|
+
# Cache successful response for later reuse
|
|
227
|
+
cache[cache_key] = precheck.json()
|
|
228
|
+
|
|
229
|
+
if exit_code != 0:
|
|
230
|
+
sys.exit(exit_code)
|
|
231
|
+
|
|
232
|
+
# replace block
|
|
233
|
+
for repo in config['gitops-replacer']:
|
|
234
|
+
repo_path = repo['repository']
|
|
235
|
+
branch = repo['branch']
|
|
236
|
+
file_path = repo['file']
|
|
237
|
+
dep_name = repo['depName']
|
|
238
|
+
|
|
239
|
+
if args.ci:
|
|
240
|
+
if 'when' in repo:
|
|
241
|
+
if not re.match(repo['when'], git_ref or ""):
|
|
242
|
+
print(f"info: git-ref {git_ref} does not match when pattern ('{repo['when']}')")
|
|
243
|
+
continue
|
|
244
|
+
else:
|
|
245
|
+
print(f"info: git-ref {git_ref} matches when pattern ('{repo['when']}')")
|
|
246
|
+
if 'except' in repo:
|
|
247
|
+
if re.match(repo['except'], git_ref or ""):
|
|
248
|
+
print(f"info: git-ref {git_ref} matches except pattern ('{repo['except']}')")
|
|
249
|
+
continue
|
|
250
|
+
else:
|
|
251
|
+
print(f"info: git-ref {git_ref} does not match except pattern ('{repo['except']}')")
|
|
252
|
+
|
|
253
|
+
# get file (reuse cached data from precheck if available)
|
|
254
|
+
cache_key = f"{repo_path}:{branch}:{file_path}"
|
|
255
|
+
if cache_key in cache:
|
|
256
|
+
print(f"info: using cached data for {file_path} from repository {repo_path}")
|
|
257
|
+
fetch_json = cache[cache_key]
|
|
258
|
+
else:
|
|
259
|
+
print(f"info: fetch {file_path} from repository {repo_path} in branch {branch}")
|
|
260
|
+
fetch_url = f"{args.api}/repos/{repo_path}/contents/{file_path}?ref={urllib.parse.quote(branch)}"
|
|
261
|
+
fetch = session.get(fetch_url, headers=headers, timeout=timeout)
|
|
262
|
+
if fetch.status_code != 200:
|
|
263
|
+
try:
|
|
264
|
+
fetch_json = fetch.json()
|
|
265
|
+
msg = fetch_json.get('message', 'unknown error')
|
|
266
|
+
except Exception:
|
|
267
|
+
msg = 'unknown error'
|
|
268
|
+
print(f"error: {msg}")
|
|
269
|
+
exit_code = 1
|
|
270
|
+
continue
|
|
271
|
+
fetch_json = fetch.json()
|
|
272
|
+
|
|
273
|
+
content_original = base64.b64decode(fetch_json['content']).decode('utf-8')
|
|
274
|
+
|
|
275
|
+
if args.verbose:
|
|
276
|
+
print(f"info: original content of {file_path}:")
|
|
277
|
+
print(f"#### BEGIN OF SOURCE FILE {file_path} ####")
|
|
278
|
+
print(content_original)
|
|
279
|
+
print(f"#### END OF SOURCE FILE {file_path} ####")
|
|
280
|
+
|
|
281
|
+
# find marker and replace value
|
|
282
|
+
content, old_value, changed = replace_marked_value(content_original, dep_name, args.value)
|
|
283
|
+
|
|
284
|
+
if old_value is None:
|
|
285
|
+
print(f"warn: no marker found for depName '{dep_name}' in {file_path}")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
print(f"info: depName '{dep_name}' - current value: {old_value}")
|
|
289
|
+
|
|
290
|
+
if not changed:
|
|
291
|
+
print(f"info: no outstanding changes for depName '{dep_name}' in file {file_path}")
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
print(f"info: depName '{dep_name}' - new value: {args.value}")
|
|
295
|
+
|
|
296
|
+
if args.verbose:
|
|
297
|
+
print(f"info: desired content of {file_path}:")
|
|
298
|
+
print(f"#### BEGIN OF DESIRED FILE {file_path} ####")
|
|
299
|
+
print(content)
|
|
300
|
+
print(f"#### END OF DESIRED FILE {file_path} ####")
|
|
301
|
+
|
|
302
|
+
if not args.apply:
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# update file in repository
|
|
306
|
+
print(f"info: update {file_path} from repository {repo_path} in branch {branch}")
|
|
307
|
+
put_url = f"{args.api}/repos/{repo_path}/contents/{file_path}"
|
|
308
|
+
update = session.put(
|
|
309
|
+
put_url,
|
|
310
|
+
headers={**headers, 'Content-Type': 'application/json'},
|
|
311
|
+
data=json.dumps({
|
|
312
|
+
'committer': {
|
|
313
|
+
'name': args.name,
|
|
314
|
+
'email': args.email,
|
|
315
|
+
},
|
|
316
|
+
'message': args.message.format(dep_name, args.value),
|
|
317
|
+
'branch': branch,
|
|
318
|
+
'content': base64.b64encode(content.encode('utf-8')).decode(),
|
|
319
|
+
'sha': fetch_json['sha']
|
|
320
|
+
}),
|
|
321
|
+
timeout=timeout
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
update_json = update.json()
|
|
326
|
+
print(json.dumps(update_json, indent=4))
|
|
327
|
+
except Exception:
|
|
328
|
+
print("warn: could not decode update response as JSON")
|
|
329
|
+
|
|
330
|
+
if update.status_code not in (200, 201):
|
|
331
|
+
exit_code = 1
|
|
332
|
+
|
|
333
|
+
sys.exit(exit_code)
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitops-replacer
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Automated YAML/JSON value updates for GitOps repositories via GitHub API
|
|
5
|
+
Author-email: Simon Lauger <simon@lauger.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/slauger/gitops-replacer
|
|
8
|
+
Project-URL: Documentation, https://github.com/slauger/gitops-replacer#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/slauger/gitops-replacer
|
|
10
|
+
Project-URL: Issues, https://github.com/slauger/gitops-replacer/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/slauger/gitops-replacer/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: gitops,yaml,json,kubernetes,helm,automation,github,ci-cd,deployment
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: requests>=2.25.0
|
|
30
|
+
Requires-Dist: PyYAML>=5.4.0
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# gitops-replacer
|
|
34
|
+
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://pypi.org/project/gitops-replacer/)
|
|
38
|
+
[](https://pypi.org/project/gitops-replacer/)
|
|
39
|
+
|
|
40
|
+
A lightweight CLI tool that automates value updates in GitOps repositories using marker comments. Replace values across multiple GitHub repositories with a single command, enabling automated deployment workflows.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Marker-based Approach**: Uses `# gitops-replacer: <name>` comments to locate values
|
|
45
|
+
- **Format Preservation**: No YAML parsing - comments, quotes, and formatting are preserved
|
|
46
|
+
- **Multiple Dependencies**: Update different values in the same file via unique markers
|
|
47
|
+
- **Flexible Modes**: Dry-run for validation, apply mode for commits
|
|
48
|
+
- **CI/CD Integration**: Built-in CI mode with `GITHUB_REF` pattern matching
|
|
49
|
+
- **Multiple Repositories**: Update values across any number of repos and files
|
|
50
|
+
- **Configuration Formats**: JSON (default) and YAML support
|
|
51
|
+
- **Performance Optimized**: Response caching eliminates duplicate API calls
|
|
52
|
+
- **Robust HTTP**: Automatic retries, timeouts, and error handling
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Python 3.10+
|
|
57
|
+
- A GitHub/GitHub Enterprise token with content read/write access
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
### Via pip (recommended)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Install from PyPI
|
|
65
|
+
pip install gitops-replacer
|
|
66
|
+
|
|
67
|
+
# Verify installation
|
|
68
|
+
gitops-replacer --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### From source
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Clone repository
|
|
75
|
+
git clone https://github.com/slauger/gitops-replacer.git
|
|
76
|
+
cd gitops-replacer
|
|
77
|
+
|
|
78
|
+
# Install in development mode
|
|
79
|
+
pip install -e .
|
|
80
|
+
|
|
81
|
+
# Or run directly
|
|
82
|
+
python -m gitops_replacer --help
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Quick Start
|
|
86
|
+
|
|
87
|
+
1. Add marker comments to your target files (see [Marker Format](#marker-format))
|
|
88
|
+
2. Create a configuration file (default: `gitops-replacer.json`)
|
|
89
|
+
3. Run a dry-run:
|
|
90
|
+
```bash
|
|
91
|
+
gitops-replacer "1.2.3"
|
|
92
|
+
```
|
|
93
|
+
4. Apply changes (commit to target repos):
|
|
94
|
+
```bash
|
|
95
|
+
gitops-replacer --apply "1.2.3"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Marker Format
|
|
99
|
+
|
|
100
|
+
Add a comment **above** the line you want to update:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
dependencies:
|
|
104
|
+
# gitops-replacer: my-app
|
|
105
|
+
- name: my-app
|
|
106
|
+
version: "0.0.0-e0f72bb"
|
|
107
|
+
repository: oci://registry.example.com/charts
|
|
108
|
+
|
|
109
|
+
# gitops-replacer: another-chart
|
|
110
|
+
- name: another-chart
|
|
111
|
+
version: "1.0.0"
|
|
112
|
+
repository: oci://registry.example.com/charts
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The tool will:
|
|
116
|
+
1. Find the line with `# gitops-replacer: <depName>`
|
|
117
|
+
2. Replace the value on the **next line** (preserving key, quotes, and formatting)
|
|
118
|
+
|
|
119
|
+
### Examples
|
|
120
|
+
|
|
121
|
+
**Chart.yaml (Helm dependency version):**
|
|
122
|
+
```yaml
|
|
123
|
+
dependencies:
|
|
124
|
+
# gitops-replacer: my-app
|
|
125
|
+
- name: my-app
|
|
126
|
+
version: "0.0.0-e0f72bb"
|
|
127
|
+
repository: oci://registry.example.com/charts
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**values.yaml (image tag):**
|
|
131
|
+
```yaml
|
|
132
|
+
# gitops-replacer: my-app-image
|
|
133
|
+
image: registry.example.com/myorg/my-app:1.2.3
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Note:** Only YAML files are supported (JSON has no comments). GitOps manifests are typically YAML.
|
|
137
|
+
|
|
138
|
+
## CLI
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
usage: gitops-replacer [-h] [--config <file>] [--apply] [--ci]
|
|
142
|
+
[--name <string>] [--email <string>]
|
|
143
|
+
[--message <string>] [--api <string>]
|
|
144
|
+
[--verbose]
|
|
145
|
+
<string>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- `--config` Path to the configuration file (default: `gitops-replacer.json`). JSON recommended.
|
|
149
|
+
- `--apply` Apply changes (commit). Without this flag the tool runs in dry-run.
|
|
150
|
+
- `--ci` CI mode: validates `GITHUB_REF` against `when`/`except` regex patterns from config.
|
|
151
|
+
- `--name` Commit author name (default: env `GIT_COMMIT_NAME` or `Replacer Bot`).
|
|
152
|
+
- `--email` Commit author email (default: env `GIT_COMMIT_EMAIL` or `replacer-bot@localhost.localdomain`).
|
|
153
|
+
- `--message` Commit message template (default: `fix: update {} to {}`). First `{}` is depName, second is value.
|
|
154
|
+
- `--api` GitHub API URL (default: env `GITHUB_API_URL` or `https://api.github.com`).
|
|
155
|
+
- `--verbose` Print file contents and desired state (use with care in CI logs).
|
|
156
|
+
- Positional: `value` - the new value to set at the marked location.
|
|
157
|
+
|
|
158
|
+
### Environment
|
|
159
|
+
|
|
160
|
+
- `GITHUB_TOKEN` **(required)** – token with access to read/write repository contents.
|
|
161
|
+
- `GITHUB_REF` *(required when `--ci`)* – the current ref string, e.g., `refs/heads/main`. Falls back to `GIT_REF` for backwards compatibility.
|
|
162
|
+
|
|
163
|
+
Recommended token scopes:
|
|
164
|
+
- Public repos only: `public_repo`
|
|
165
|
+
- Private repos: `repo`
|
|
166
|
+
- GitHub Enterprise: equivalent content permissions
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
Default format is **JSON**. YAML (`.yaml`/`.yml`) is supported as well.
|
|
171
|
+
|
|
172
|
+
### JSON schema (per entry)
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"gitops-replacer": [
|
|
177
|
+
{
|
|
178
|
+
"repository": "acme/gitops",
|
|
179
|
+
"branch": "main",
|
|
180
|
+
"file": "apps/my-app/Chart.yaml",
|
|
181
|
+
"depName": "my-app",
|
|
182
|
+
"when": "^refs/heads/main$"
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Fields**
|
|
189
|
+
|
|
190
|
+
| Field | Description |
|
|
191
|
+
|-------|-------------|
|
|
192
|
+
| `repository` | Target repo on GitHub (`ORG/REPO` format) |
|
|
193
|
+
| `branch` | Target branch |
|
|
194
|
+
| `file` | Target file path relative to repo root |
|
|
195
|
+
| `depName` | Dependency name (must match marker in file) |
|
|
196
|
+
| `when` | Regex that must match `GITHUB_REF` when `--ci` is enabled (optional) |
|
|
197
|
+
| `except` | Regex that must **not** match `GITHUB_REF` when `--ci` is enabled (optional) |
|
|
198
|
+
|
|
199
|
+
> The tool uses `re.match` (anchored at the string start). Use `^...$` in your patterns if you require a full match.
|
|
200
|
+
|
|
201
|
+
### Examples
|
|
202
|
+
|
|
203
|
+
**JSON (default)**
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"gitops-replacer": [
|
|
208
|
+
{
|
|
209
|
+
"repository": "acme/gitops",
|
|
210
|
+
"branch": "main",
|
|
211
|
+
"file": "apps/my-app/Chart.yaml",
|
|
212
|
+
"depName": "my-app",
|
|
213
|
+
"when": "^refs/heads/(main|release/.*)$"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"repository": "acme/gitops",
|
|
217
|
+
"branch": "develop",
|
|
218
|
+
"file": "apps/my-app-dev/Chart.yaml",
|
|
219
|
+
"depName": "my-app",
|
|
220
|
+
"except": "^refs/heads/legacy/"
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**YAML (alternative)**
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
gitops-replacer:
|
|
230
|
+
- repository: acme/gitops
|
|
231
|
+
branch: main
|
|
232
|
+
file: apps/my-app/Chart.yaml
|
|
233
|
+
depName: my-app
|
|
234
|
+
when: '^refs/heads/(main|release/.*)$'
|
|
235
|
+
- repository: acme/gitops
|
|
236
|
+
branch: develop
|
|
237
|
+
file: apps/my-app-dev/Chart.yaml
|
|
238
|
+
depName: my-app
|
|
239
|
+
except: '^refs/heads/legacy/'
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## How it works
|
|
243
|
+
|
|
244
|
+
1. **Validation**: Checks CLI arguments, environment variables, and configuration file
|
|
245
|
+
2. **Precheck Phase**: Validates access to all target repositories/files (caches responses)
|
|
246
|
+
3. **Replace Phase**: Downloads files (reuses cached data), finds marker comments, replaces values
|
|
247
|
+
4. **Commit Phase**: If `--apply` is set and changes detected, commits via GitHub Contents API
|
|
248
|
+
5. **Exit Codes**: Returns `0` on success, non-zero on failures
|
|
249
|
+
|
|
250
|
+
### Why Marker-based?
|
|
251
|
+
|
|
252
|
+
Traditional approaches parse YAML, modify the data structure, and serialize back. This often breaks:
|
|
253
|
+
- Comments are lost
|
|
254
|
+
- Quote styles change (`"1.0"` becomes `'1.0'` or `1.0`)
|
|
255
|
+
- Key ordering may change
|
|
256
|
+
- Multi-line strings get reformatted
|
|
257
|
+
|
|
258
|
+
The marker-based approach works on raw text:
|
|
259
|
+
- **Explicit**: Only marked lines are modified
|
|
260
|
+
- **Safe**: No risk of unintended changes
|
|
261
|
+
- **Preserving**: Comments, quotes, and formatting stay intact
|
|
262
|
+
|
|
263
|
+
## Exit Codes
|
|
264
|
+
|
|
265
|
+
- `0` success (no changes or committed changes)
|
|
266
|
+
- `1` validation or API error
|
|
267
|
+
|
|
268
|
+
## Use Cases
|
|
269
|
+
|
|
270
|
+
### Automated Deployment Pipeline
|
|
271
|
+
|
|
272
|
+
Update chart version when a new release is built:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
# In your CI/CD pipeline after publishing a chart
|
|
276
|
+
gitops-replacer --ci --apply "0.1.0-abc123"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Multi-Environment Updates
|
|
280
|
+
|
|
281
|
+
Use CI mode to update different environments based on branch:
|
|
282
|
+
|
|
283
|
+
```json
|
|
284
|
+
{
|
|
285
|
+
"gitops-replacer": [
|
|
286
|
+
{
|
|
287
|
+
"repository": "myorg/gitops",
|
|
288
|
+
"branch": "main",
|
|
289
|
+
"file": "apps/production/Chart.yaml",
|
|
290
|
+
"depName": "myapp",
|
|
291
|
+
"when": "^refs/heads/main$"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"repository": "myorg/gitops",
|
|
295
|
+
"branch": "main",
|
|
296
|
+
"file": "apps/staging/Chart.yaml",
|
|
297
|
+
"depName": "myapp",
|
|
298
|
+
"when": "^refs/heads/(main|develop)$"
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Troubleshooting
|
|
305
|
+
|
|
306
|
+
### Common Issues
|
|
307
|
+
|
|
308
|
+
**401 Unauthorized**
|
|
309
|
+
- Verify `GITHUB_TOKEN` is set correctly
|
|
310
|
+
- Check token has `repo` or `public_repo` scope
|
|
311
|
+
- For GitHub Enterprise, confirm token has access to the organization
|
|
312
|
+
|
|
313
|
+
**404 Not Found**
|
|
314
|
+
- Verify `repository`, `branch`, and `file` paths in config
|
|
315
|
+
- Check branch name spelling (case-sensitive)
|
|
316
|
+
- Ensure file exists at the specified path
|
|
317
|
+
|
|
318
|
+
**No marker found**
|
|
319
|
+
- Confirm the marker comment exists in the target file
|
|
320
|
+
- Check `depName` in config matches the marker exactly
|
|
321
|
+
- Marker format: `# gitops-replacer: <depName>`
|
|
322
|
+
|
|
323
|
+
**No changes detected**
|
|
324
|
+
- The current value already matches the new value
|
|
325
|
+
- Use `--verbose` to see file contents
|
|
326
|
+
|
|
327
|
+
### Debug Mode
|
|
328
|
+
|
|
329
|
+
Run with `--verbose` to see:
|
|
330
|
+
- Full API URLs being called
|
|
331
|
+
- Complete file contents before replacement
|
|
332
|
+
- Desired file contents after replacement
|
|
333
|
+
|
|
334
|
+
**Warning**: Verbose mode may expose sensitive data in logs.
|
|
335
|
+
|
|
336
|
+
## Contributing
|
|
337
|
+
|
|
338
|
+
Contributions are welcome! Please ensure:
|
|
339
|
+
- Code follows existing style and patterns
|
|
340
|
+
- Changes are tested with both dry-run and apply modes
|
|
341
|
+
- Documentation is updated for new features
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
gitops_replacer/__init__.py,sha256=oF2KabVV2hNZ0jPyd1EI9N8rmGNFOsbPCZHT9twGxPc,180
|
|
2
|
+
gitops_replacer/__main__.py,sha256=u4W1NpcVgbkOzdgSWbkWFYH1hVpbhfAkBrAZtlXGvGw,11943
|
|
3
|
+
gitops_replacer-0.1.1.dist-info/licenses/LICENSE,sha256=PWKv48zlqsEbvFv-3oXpPI8yO_I11cnIldtq87mZQrI,1069
|
|
4
|
+
gitops_replacer-0.1.1.dist-info/METADATA,sha256=-2_hKR2drzMKWqPv0OrCwtlw2ct464upAOnMBTZav1o,10665
|
|
5
|
+
gitops_replacer-0.1.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
6
|
+
gitops_replacer-0.1.1.dist-info/entry_points.txt,sha256=mMZg5P8Fj2eGCDKOKkPCtFJ74X-Hts2-ZSODROuQDZM,66
|
|
7
|
+
gitops_replacer-0.1.1.dist-info/top_level.txt,sha256=gs-aypUM82Iw-hZg-4plm6t7cb7IPDPjZmJ0mHDkGQY,16
|
|
8
|
+
gitops_replacer-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Simon Lauger
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitops_replacer
|