|
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