|
1 | 1 | #!/usr/bin/env python3
|
2 |
| -import argparse |
3 |
| -import logging |
4 |
| -import os |
5 |
| -import re |
6 |
| -import subprocess |
7 |
| -from contextlib import contextmanager |
8 |
| -from datetime import datetime, timedelta |
9 |
| -from pathlib import Path |
10 | 2 |
|
11 |
| -try: |
12 |
| - from specfile import Specfile |
13 |
| -except ImportError: |
14 |
| - print("error: specfile module can't be imported. Please install it with 'pip install --user specfile'.") |
15 |
| - exit(1) |
| 3 | +import sys |
16 | 4 |
|
17 |
| -TIME_FORMAT = '%Y-%m-%d-%H-%M-%S' |
| 5 | +from koji_utils.koji_build import main |
18 | 6 |
|
19 |
| -# target -> required branch |
20 |
| -PROTECTED_TARGETS = { |
21 |
| - "v8.2-ci": "8.2", |
22 |
| - "v8.2-fasttrack": "8.2", |
23 |
| - "v8.2-incoming": "8.2", |
24 |
| - "v8.3-ci": "master", |
25 |
| - "v8.3-fasttrack": "master", |
26 |
| - "v8.3-incoming": "master", |
27 |
| -} |
28 |
| - |
29 |
| -@contextmanager |
30 |
| -def cd(dir): |
31 |
| - """Change to a directory temporarily. To be used in a with statement.""" |
32 |
| - prevdir = os.getcwd() |
33 |
| - os.chdir(dir) |
34 |
| - try: |
35 |
| - yield os.path.realpath(dir) |
36 |
| - finally: |
37 |
| - os.chdir(prevdir) |
38 |
| - |
39 |
| -def check_dir(dirpath): |
40 |
| - if not os.path.isdir(dirpath): |
41 |
| - raise Exception("Directory %s doesn't exist" % dirpath) |
42 |
| - return dirpath |
43 |
| - |
44 |
| -def check_git_repo(dirpath): |
45 |
| - """check that the working copy is a working directory and is clean.""" |
46 |
| - with cd(dirpath): |
47 |
| - return subprocess.run(['git', 'diff-index', '--quiet', 'HEAD', '--']).returncode == 0 |
48 |
| - |
49 |
| -def check_commit_is_available_remotely(dirpath, hash, target, warn): |
50 |
| - with cd(dirpath): |
51 |
| - if not subprocess.check_output(['git', 'branch', '-r', '--contains', hash]): |
52 |
| - raise Exception("The current commit is not available in the remote repository") |
53 |
| - try: |
54 |
| - expected_branch = PROTECTED_TARGETS.get(target) |
55 |
| - if ( |
56 |
| - expected_branch is not None |
57 |
| - and not is_remote_branch_commit(dirpath, hash, expected_branch) |
58 |
| - ): |
59 |
| - raise Exception(f"The current commit is not the last commit in the remote branch {expected_branch}.\n" |
60 |
| - f"This is required when using the protected target {target}.\n") |
61 |
| - except Exception as e: |
62 |
| - if warn: |
63 |
| - print(f"warning: {e}", flush=True) |
64 |
| - else: |
65 |
| - raise e |
66 |
| - |
67 |
| -def get_repo_and_commit_info(dirpath): |
68 |
| - with cd(dirpath): |
69 |
| - remote = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode().strip() |
70 |
| - # We want the exact hash for accurate build history |
71 |
| - hash = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
72 |
| - return remote, hash |
73 |
| - |
74 |
| -def koji_url(remote, hash): |
75 |
| - if remote.startswith('git@'): |
76 |
| - remote = re.sub(r'git@(.+):', r'git+https://\1/', remote) |
77 |
| - elif remote.startswith('https://'): |
78 |
| - remote = 'git+' + remote |
79 |
| - else: |
80 |
| - raise Exception("Unrecognized remote URL") |
81 |
| - return remote + "?#" + hash |
82 |
| - |
83 |
| -@contextmanager |
84 |
| -def local_branch(branch): |
85 |
| - prev_branch = subprocess.check_output(['git', 'branch', '--show-current']).strip() |
86 |
| - commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
87 |
| - subprocess.check_call(['git', 'checkout', '--quiet', commit]) |
88 |
| - try: |
89 |
| - yield branch |
90 |
| - finally: |
91 |
| - subprocess.check_call(['git', 'checkout', prev_branch]) |
92 |
| - |
93 |
| -def is_old_branch(b): |
94 |
| - branch_time = datetime.strptime(b.split('/')[-1], TIME_FORMAT) |
95 |
| - return branch_time < datetime.now() - timedelta(hours=3) |
96 |
| - |
97 |
| -def clean_old_branches(git_repo): |
98 |
| - with cd(git_repo): |
99 |
| - remote_branches = [ |
100 |
| - line.split()[-1] for line in subprocess.check_output(['git', 'ls-remote']).decode().splitlines() |
101 |
| - ] |
102 |
| - remote_branches = [b for b in remote_branches if b.startswith('refs/heads/koji/test/')] |
103 |
| - old_branches = [b for b in remote_branches if is_old_branch(b)] |
104 |
| - if old_branches: |
105 |
| - print("removing outdated remote branch(es)", flush=True) |
106 |
| - subprocess.check_call(['git', 'push', '--delete', 'origin'] + old_branches) |
107 |
| - |
108 |
| -def xcpng_version(target): |
109 |
| - xcpng_version_match = re.match(r'^v(\d+\.\d+)-u-\S+$', target) |
110 |
| - if xcpng_version_match is None: |
111 |
| - raise Exception(f"Can't find XCP-ng version in {target}") |
112 |
| - return xcpng_version_match.group(1) |
113 |
| - |
114 |
| -def find_next_release(package, spec, target, test_build_id, pre_build_id): |
115 |
| - assert test_build_id is not None or pre_build_id is not None |
116 |
| - builds = subprocess.check_output(['koji', 'list-builds', '--quiet', '--package', package]).decode().splitlines() |
117 |
| - if test_build_id: |
118 |
| - base_nvr = f'{package}-{spec.version}-{spec.release}.0.{test_build_id}.' |
119 |
| - else: |
120 |
| - base_nvr = f'{package}-{spec.version}-{spec.release}~{pre_build_id}.' |
121 |
| - # use a regex to match %{macro} without actually expanding the macros |
122 |
| - base_nvr_re = ( |
123 |
| - re.escape(re.sub('%{.+}', "@@@", base_nvr)).replace('@@@', '.*') |
124 |
| - + r'(\d+)' |
125 |
| - + re.escape(f'.xcpng{xcpng_version(target)}') |
126 |
| - ) |
127 |
| - build_matches = [re.match(base_nvr_re, b) for b in builds] |
128 |
| - build_nbs = [int(m.group(1)) for m in build_matches if m] |
129 |
| - build_nb = sorted(build_nbs)[-1] + 1 if build_nbs else 1 |
130 |
| - if test_build_id: |
131 |
| - return f'{spec.release}.0.{test_build_id}.{build_nb}' |
132 |
| - else: |
133 |
| - return f'{spec.release}~{pre_build_id}.{build_nb}' |
134 |
| - |
135 |
| -def push_bumped_release(git_repo, target, test_build_id, pre_build_id): |
136 |
| - t = datetime.now().strftime(TIME_FORMAT) |
137 |
| - branch = f'koji/test/{test_build_id or pre_build_id}/{t}' |
138 |
| - with cd(git_repo), local_branch(branch): |
139 |
| - spec_paths = subprocess.check_output(['git', 'ls-files', 'SPECS/*.spec']).decode().splitlines() |
140 |
| - assert len(spec_paths) == 1 |
141 |
| - spec_path = spec_paths[0] |
142 |
| - with Specfile(spec_path) as spec: |
143 |
| - # find the next build number |
144 |
| - package = Path(spec_path).stem |
145 |
| - spec.release = find_next_release(package, spec, target, test_build_id, pre_build_id) |
146 |
| - subprocess.check_call(['git', 'commit', '--quiet', '-m', "bump release for test build", spec_path]) |
147 |
| - subprocess.check_call(['git', 'push', 'origin', f'HEAD:refs/heads/{branch}']) |
148 |
| - commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() |
149 |
| - return commit |
150 |
| - |
151 |
| -def is_remote_branch_commit(git_repo, sha, branch): |
152 |
| - with cd(git_repo): |
153 |
| - remote_sha = ( |
154 |
| - subprocess.check_output(['git', 'ls-remote', 'origin', f'refs/heads/{branch}']).decode().strip().split()[0] |
155 |
| - ) |
156 |
| - return sha == remote_sha |
157 |
| - |
158 |
| -def main(): |
159 |
| - parser = argparse.ArgumentParser( |
160 |
| - description='Build a package or chain-build several from local git repos for RPM sources' |
161 |
| - ) |
162 |
| - parser.add_argument('target', help='Koji target for the build') |
163 |
| - parser.add_argument('git_repos', nargs='+', |
164 |
| - help='local path to one or more git repositories. If several are provided, ' |
165 |
| - 'a chained build will be started in the order of the arguments') |
166 |
| - parser.add_argument('--scratch', action="store_true", help='Perform scratch build') |
167 |
| - parser.add_argument('--nowait', action="store_true", help='Do not wait for the build to end') |
168 |
| - parser.add_argument('--force', action="store_true", help='Bypass sanity checks') |
169 |
| - parser.add_argument( |
170 |
| - '--test-build', |
171 |
| - metavar="ID", |
172 |
| - help='Run a test build. The provided ID will be used to build a unique release tag.', |
173 |
| - ) |
174 |
| - parser.add_argument( |
175 |
| - '--pre-build', |
176 |
| - metavar="ID", |
177 |
| - help='Run a pre build. The provided ID will be used to build a unique release tag.', |
178 |
| - ) |
179 |
| - args = parser.parse_args() |
180 |
| - |
181 |
| - target = args.target |
182 |
| - git_repos = [os.path.abspath(check_dir(d)) for d in args.git_repos] |
183 |
| - is_scratch = args.scratch |
184 |
| - is_nowait = args.nowait |
185 |
| - test_build = args.test_build |
186 |
| - pre_build = args.pre_build |
187 |
| - if test_build and pre_build: |
188 |
| - logging.error("--pre-build and --test-build can't be used together") |
189 |
| - exit(1) |
190 |
| - if test_build is not None and re.match('^[a-zA-Z0-9]{1,16}$', test_build) is None: |
191 |
| - logging.error("The test build id must be 16 characters long maximum and only contain letters and digits") |
192 |
| - exit(1) |
193 |
| - if pre_build is not None and re.match('^[a-zA-Z0-9]{1,16}$', pre_build) is None: |
194 |
| - logging.error("The pre build id must be 16 characters long maximum and only contain letters and digits") |
195 |
| - exit(1) |
196 |
| - |
197 |
| - if len(git_repos) > 1 and is_scratch: |
198 |
| - parser.error("--scratch is not compatible with chained builds.") |
199 |
| - |
200 |
| - for d in git_repos: |
201 |
| - if not check_git_repo(d): |
202 |
| - parser.error("%s is not in a clean state (or is not a git repository)." % d) |
203 |
| - |
204 |
| - if len(git_repos) == 1: |
205 |
| - clean_old_branches(git_repos[0]) |
206 |
| - remote, hash = get_repo_and_commit_info(git_repos[0]) |
207 |
| - if test_build or pre_build: |
208 |
| - hash = push_bumped_release(git_repos[0], target, test_build, pre_build) |
209 |
| - else: |
210 |
| - check_commit_is_available_remotely(git_repos[0], hash, None if is_scratch else target, args.force) |
211 |
| - url = koji_url(remote, hash) |
212 |
| - command = ( |
213 |
| - ['koji', 'build'] |
214 |
| - + (['--scratch'] if is_scratch else []) |
215 |
| - + [target, url] |
216 |
| - + (['--nowait'] if is_nowait else []) |
217 |
| - ) |
218 |
| - print(' '.join(command), flush=True) |
219 |
| - subprocess.check_call(command) |
220 |
| - else: |
221 |
| - urls = [] |
222 |
| - for d in git_repos: |
223 |
| - clean_old_branches(d) |
224 |
| - remote, hash = get_repo_and_commit_info(d) |
225 |
| - if test_build or pre_build: |
226 |
| - hash = push_bumped_release(d, target, test_build, pre_build) |
227 |
| - else: |
228 |
| - check_commit_is_available_remotely(d, hash, None if is_scratch else target, args.force) |
229 |
| - urls.append(koji_url(remote, hash)) |
230 |
| - command = ['koji', 'chain-build', target] + (' : '.join(urls)).split(' ') + (['--nowait'] if is_nowait else []) |
231 |
| - print(' '.join(command), flush=True) |
232 |
| - subprocess.check_call(command) |
233 |
| - |
234 |
| -if __name__ == "__main__": |
235 |
| - main() |
| 7 | +print("\033[33mwarning: koji_build.py as moved to koji_utils/koji_build.py. " |
| 8 | + "Please update your configuration to use that file.\033[0m", file=sys.stderr) |
| 9 | +main() |
0 commit comments