[projects/pld-builder.new] Yet another php rebuild script.

arekm arekm at pld-linux.org
Mon Apr 6 14:50:31 CEST 2026


commit f4b716215ca529169fd5405da8f834f2a67a7e98
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date:   Mon Apr 6 14:50:06 2026 +0200

    Yet another php rebuild script.

 client/php-rebuild.py | 467 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 467 insertions(+)
---
diff --git a/client/php-rebuild.py b/client/php-rebuild.py
new file mode 100755
index 0000000..9def1dd
--- /dev/null
+++ b/client/php-rebuild.py
@@ -0,0 +1,467 @@
+#!/usr/bin/python3
+
+"""Send PLD Linux build requests for PHP extensions across all supported PHP versions.
+
+Reads the spec from git (via git show) to determine php-devel version constraints,
+then generates make-request.sh commands with the appropriate php_suffix define.
+
+Default ref: auto-tag at master tip if it exists, otherwise master.
+Default is dry-run (prints commands). Use --run to execute.
+
+Examples:
+    php-rebuild.py php-pecl-apcu                         # from auto-tag or master
+    php-rebuild.py --run -t php-pecl-xdebug              # execute test builds
+    php-rebuild.py php-pecl-redis:auto/th/php-pecl-redis-6.0.2-2  # specific tag
+    php-rebuild.py --php 8.0-8.4 php-pecl-redis          # override version range
+    php-rebuild.py --autotag php-pecl-apcu               # only first suffix
+    php-rebuild.py --autotag-continue php-pecl-apcu      # remaining suffixes
+"""
+
+import os
+import re
+import shlex
+import subprocess
+import sys
+import time
+
+# Two-digit suffixes mapping to PHP versions: '52' = PHP 5.2, '85' = PHP 8.5
+ALL_SUFFIXES = [
+    '52', '53', '54', '55', '56',
+    '70', '71', '72', '73', '74',
+    '80', '81', '82', '83', '84', '85',
+]
+DEFAULT_MIN_PHP = (8, 4)  # assumed when spec has no php-devel BuildRequires
+DIST = 'th'
+TAG_POLL_TIMEOUT = 240  # seconds to wait for builder to create auto-tag
+TAG_POLL_INTERVAL = 5   # seconds between polls
+
+# Our own CLI flags — defined once, opts defaults and parsing derived from these
+OUR_BOOL_FLAGS = {
+    '--run': 'run', '-t': 'test', '--test': 'test',
+    '-v': 'verbose', '--verbose': 'verbose',
+    '--autotag': 'autotag', '--autotag-continue': 'autotag_continue',
+}
+OUR_VALUE_FLAGS = {
+    '--php': 'php',
+    '--autotag-wait': 'autotag_wait',
+}
+
+# make-request.sh build mode flags — used to avoid doubling if user passes one
+BUILD_MODE_FLAGS = {'-t', '--test-build', '-r', '--ready-build'}
+
+# make-request.sh flags that consume the next argument — needed so the arg parser
+# doesn't mistake their values (e.g. "-D 'php_suffix 80'") for spec names
+MR_VALUE_FLAGS = {
+    '-b', '--builder', '-D', '--define', '-d', '--dist', '--distro',
+    '-c', '--command', '-C', '--post-command', '-p', '--priority',
+    '-w', '-s', '--skip', '--branch', '--with', '--without',
+    '--kernel', '--target', '-j', '--jobs', '-m', '-g', '--gpg-opts',
+    '-f', '--flag', '-cf', '--command-flags', '--config-file',
+    '-q', '--test-remove-pkg', '--remove-pkg', '--upgrade-pkg', '-Uhv',
+    '--requester',
+}
+
+# poldek globs match against full name-version-release, so php*-common-* is needed
+# to match e.g. php82-common-8.2.28-1
+PRE_COMMAND = 'poldek --cmd "uninstall php*-common-*" --noask; :'
+
+
+def suffix_to_version(s):
+    return (int(s[0]), int(s[1:]))
+
+
+def version_to_suffix(major, minor):
+    return f'{major}{minor}'
+
+
+def fmt_ver(v):
+    """Format version tuple as 'M.N' for display."""
+    return f'{v[0]}.{v[1]}'
+
+
+def run_cmd(cmd):
+    """Run command, return (ok, output). Output is stdout on success, stderr on failure."""
+    proc = subprocess.run(cmd, capture_output=True, text=True)
+    if proc.returncode == 0:
+        return True, proc.stdout.strip()
+    return False, proc.stderr.strip()
+
+
+def clean_pkgname(pkg):
+    """Split 'name[:ref]' into (name, spec, ref). ref is None if not specified."""
+    parts = pkg.split(':', 1)
+    spec = parts[0]
+    ref = parts[1] if len(parts) > 1 else None
+    if not spec.endswith('.spec'):
+        spec += '.spec'
+    name = spec[:-5].rsplit('/', 1)[-1]
+    return name, spec, ref
+
+
+def _match_remote_autotag(gitdir, head_hash):
+    """Check remote for an auto-tag pointing at the given commit hash."""
+    tag_prefix = f'refs/tags/auto/{DIST}/'
+    ok, output = run_cmd(['git', '-C', gitdir, 'ls-remote', '--tags', 'origin', f'{tag_prefix}*'])
+    if not ok or not output:
+        return None
+    for line in output.splitlines():
+        if '\t' not in line:
+            continue
+        commit, ref = line.split('\t', 1)
+        if commit == head_hash and ref.startswith(tag_prefix):
+            return ref.removeprefix('refs/tags/')
+    return None
+
+
+def find_autotag_remote(gitdir, branch):
+    """Find auto-tag at branch tip by checking the remote (source of truth).
+
+    Auto-tags (auto/th/*) are created exclusively by builders after a successful
+    build. Always checks remote — local tags may be stale or missing.
+    """
+    ok, head_hash = run_cmd(['git', '-C', gitdir, 'rev-parse', branch])
+    if not ok:
+        return None
+    return _match_remote_autotag(gitdir, head_hash)
+
+
+def wait_for_tag(gitdir, branch, timeout=TAG_POLL_TIMEOUT):
+    """Poll remote for auto-tag on branch's commit, return tag name or None.
+
+    After the first suffix build succeeds, the builder creates an auto-tag.
+    We poll git ls-remote (no fetch) to detect it. Prints a dot every poll.
+    Returns None on timeout — caller decides whether to abort or fall back.
+    """
+    ok, head_hash = run_cmd(['git', '-C', gitdir, 'rev-parse', branch])
+    if not ok:
+        return None
+
+    deadline = time.time() + timeout
+    while time.time() < deadline:
+        time.sleep(TAG_POLL_INTERVAL)
+        print('.', end='', flush=True)
+        tag = _match_remote_autotag(gitdir, head_hash)
+        if tag:
+            return tag
+    return None
+
+
+def git_show_file(gitdir, ref, filename):
+    """Read file content at a git ref without touching the working tree."""
+    ok, output = run_cmd(['git', '-C', gitdir, 'show', f'{ref}:{filename}'])
+    if not ok:
+        print(f'Error: cannot read {filename} at {ref}: {output}', file=sys.stderr)
+        return None
+    return output
+
+
+def parse_php_range_from_text(text):
+    """Parse PHP version range from raw spec text using regex.
+
+    Works on unexpanded spec text (straight from git), matching both literal
+    'php-devel' and macro '%{php_name}-devel' forms in BuildRequires lines.
+    Strips epoch prefix (e.g. 4:8.0.0 -> 8.0).
+    """
+    min_php = None
+    max_php_excl = None
+    for line in text.splitlines():
+        if 'BuildRequires' not in line or '-devel' not in line:
+            continue
+        if 'php' not in line and 'php_name' not in line:
+            continue
+        m = re.search(r'(>=|<)\s*(?:\d+:)?(\d+)\.(\d+)', line)
+        if m:
+            ver = (int(m.group(2)), int(m.group(3)))
+            if m.group(1) == '>=':
+                min_php = ver
+            else:
+                max_php_excl = ver
+    return min_php, max_php_excl
+
+
+def parse_version(s):
+    """Parse 'M.N' string into (major, minor) tuple. Dies on bad input."""
+    parts = s.strip().split('.')
+    if len(parts) < 2:
+        print(f'Error: invalid PHP version {s!r}, expected M.N format', file=sys.stderr)
+        sys.exit(1)
+    return (int(parts[0]), int(parts[1]))
+
+
+def get_suffixes(min_php, max_php_excl, php_override=None):
+    """Return list of suffixes to build."""
+    if php_override:
+        if '-' in php_override:
+            lo_s, hi_s = php_override.split('-', 1)
+            lo, hi = parse_version(lo_s), parse_version(hi_s)
+            suffixes = [s for s in ALL_SUFFIXES if lo <= suffix_to_version(s) <= hi]
+        else:
+            # comma-separated list: "7.0,8.0,8.5" (also handles single "8.1")
+            suffixes = []
+            for v in php_override.split(','):
+                s = version_to_suffix(*parse_version(v))
+                if s in ALL_SUFFIXES:
+                    suffixes.append(s)
+                else:
+                    print(f'Warning: unknown PHP version {v.strip()}', file=sys.stderr)
+        # warn about versions outside spec's declared range
+        for s in suffixes:
+            v = suffix_to_version(s)
+            if v < min_php or (max_php_excl and v >= max_php_excl):
+                print(f'Warning: PHP {fmt_ver(v)} is outside spec declared range', file=sys.stderr)
+        return suffixes
+
+    return [s for s in ALL_SUFFIXES
+            if suffix_to_version(s) >= min_php
+            and (not max_php_excl or suffix_to_version(s) < max_php_excl)]
+
+
+def print_help():
+    print("""\
+Usage: php-rebuild.py [OPTIONS] [MAKE-REQUEST-OPTS] SPEC[:REF] [SPEC[:REF]...]
+
+Send build requests for PHP extensions across all supported PHP versions.
+Reads the spec from git (via git show) to determine which PHP versions the
+extension supports, then generates make-request.sh commands with the
+appropriate php_suffix define.
+
+Default is dry-run — commands are printed but not executed.
+
+Options:
+  --run                 Execute commands (default: dry-run, just print)
+  -t, --test            Send test builds (pass -t to make-request.sh)
+  --php RANGE           Override PHP version range
+                          range:  --php 8.0-8.5
+                          list:   --php 8.0,8.2,8.4
+                          single: --php 8.4
+  --autotag             Only send the first suffix build (to trigger auto-tag
+                        creation). Skips if auto-tag already exists.
+  --autotag-continue    Send builds for all suffixes except the first, using
+                        the existing auto-tag. Use after --autotag succeeded.
+  --autotag-wait SECS   Seconds to wait for auto-tag to appear (default: 240)
+  -v, --verbose         Print extra info (ref used, PHP versions)
+  -h, --help            Show this help
+
+Spec arguments:
+  SPEC                  Package name, e.g. php-pecl-apcu or php-pecl-apcu.spec
+                        Default ref: auto-tag at master tip (remote check),
+                        or master itself if no tag at tip.
+  SPEC:TAG              Build from a specific auto-tag
+  SPEC:BRANCH           Build from a branch tip
+
+Any unrecognized options are passed through to make-request.sh, e.g.:
+  -D 'with_foo 1'       Define RPM macro
+  -b 'th-x86_64'        Select specific builders
+
+Workflow:
+  1. Full rebuild (auto-tag exists at branch tip):
+       php-rebuild.py --run php-pecl-apcu
+
+  2. Full rebuild (no auto-tag, waits for builder to create one):
+       php-rebuild.py --run php-pecl-apcu
+
+  3. Two-step rebuild (send first build, then continue after tag appears):
+       php-rebuild.py --run --autotag php-pecl-apcu
+       # ... wait for build to succeed ...
+       php-rebuild.py --run --autotag-continue php-pecl-apcu
+
+  4. Resend from a specific tag:
+       php-rebuild.py --run php-pecl-redis:auto/th/php-pecl-redis-6.0.2-2
+
+  5. Dry-run to preview commands:
+       php-rebuild.py php-pecl-xdebug
+
+  6. Test build for specific PHP versions:
+       php-rebuild.py --run -t --php 8.2-8.4 php-pecl-redis""")
+
+
+def parse_args(argv):
+    """Two-pass arg parser. Returns (opts_dict, specs_list, passthrough_list).
+
+    Can't use argparse because we need to transparently pass unknown flags
+    (and their values) through to make-request.sh. We track which make-request.sh
+    flags take a value argument (MR_VALUE_FLAGS) so we don't mistake those values
+    for spec names.
+
+    Specs:      non-flag args not consumed as values (e.g. 'php-pecl-apcu:master')
+    Passthrough: everything else -> forwarded to make-request.sh
+    """
+    opts = {v: False for v in OUR_BOOL_FLAGS.values()}
+    opts.update({v: None for v in OUR_VALUE_FLAGS.values()})
+    specs = []
+    passthrough = []
+
+    if '--help' in argv or '-h' in argv:
+        print_help()
+        sys.exit(0)
+
+    i = 0
+    while i < len(argv):
+        arg = argv[i]
+        if arg in OUR_BOOL_FLAGS:
+            opts[OUR_BOOL_FLAGS[arg]] = True
+        elif arg in OUR_VALUE_FLAGS and i + 1 < len(argv):
+            i += 1
+            opts[OUR_VALUE_FLAGS[arg]] = argv[i]
+        elif arg.startswith('-'):
+            passthrough.append(arg)
+            if arg in MR_VALUE_FLAGS and i + 1 < len(argv):
+                i += 1
+                passthrough.append(argv[i])
+        else:
+            specs.append(arg)
+        i += 1
+
+    opts['autotag_wait'] = int(opts['autotag_wait'] or TAG_POLL_TIMEOUT)
+
+    return opts, specs, passthrough
+
+
+def process_spec(spec_arg, opts, passthrough):
+    """Process one spec: find ref, read spec from git, determine PHP versions, send builds.
+
+    Ref resolution (when not specified by user):
+      1. Auto-tag at master tip (remote check) — current version already built
+      2. master itself — new version, needs first+wait+rest flow
+
+    Modes:
+      default             all suffixes from resolved ref (wait for tag if needed)
+      --autotag           first suffix only, to trigger auto-tag creation
+      --autotag-continue  remaining suffixes from existing auto-tag
+    """
+    name, spec, ref = clean_pkgname(spec_arg)
+    gitdir = os.path.join(opts['rpmdir'], name)
+
+    if not os.path.isdir(gitdir):
+        print(f'Error: {gitdir} not found', file=sys.stderr)
+        return
+
+    # Determine what ref to build from.
+    # Auto-tag at branch tip = already built, rebuild all from it.
+    # No tag at tip = new build, first+wait+rest flow.
+    branch = ref or 'master'
+    tip_tag = None
+    if not ref:
+        tip_tag = find_autotag_remote(gitdir, branch)
+
+    if opts['verbose']:
+        print(f'{name}: branch {branch}, tip_tag {tip_tag or "none"}')
+
+    # Read spec from branch/ref (local) — remote auto-tags may not be fetched locally
+    spec_text = git_show_file(gitdir, branch, spec)
+    if not spec_text:
+        return
+
+    min_php, max_php_excl = parse_php_range_from_text(spec_text)
+    if min_php is None:
+        print(f'Warning: no php-devel dep in {name}, assuming >= {fmt_ver(DEFAULT_MIN_PHP)}',
+              file=sys.stderr)
+        min_php = DEFAULT_MIN_PHP
+
+    suffixes = get_suffixes(min_php, max_php_excl, opts['php'])
+    if not suffixes:
+        print(f'Error: no PHP versions to build for {name}', file=sys.stderr)
+        return
+
+    # Summary of what we're about to do
+    ver_list = ' '.join(fmt_ver(suffix_to_version(s)) for s in suffixes)
+    mode = 'test' if opts['test'] else 'ready'
+    if not opts['run']:
+        mode += ', dry-run'
+
+    if opts['autotag']:
+        if tip_tag:
+            plan = f'skip (auto-tag {tip_tag} already exists)'
+        else:
+            plan = f'autotag only: PHP {fmt_ver(suffix_to_version(suffixes[0]))} from {branch}'
+    elif opts['autotag_continue']:
+        plan = f'continue: PHP {ver_list} (skip first) from auto-tag'
+    elif tip_tag:
+        plan = f'rebuild all: PHP {ver_list} from {tip_tag}'
+    else:
+        plan = f'new build: PHP {ver_list} from {branch}, first+wait+rest'
+
+    print(f'{name}: {plan} [{mode}]')
+
+    # Avoid doubling build mode if user already passed -t/-r in passthrough
+    if any(a in BUILD_MODE_FLAGS for a in passthrough):
+        build_mode = []
+    else:
+        build_mode = ['-t'] if opts['test'] else ['-r']
+
+    def send(suffixes_to_send, send_ref):
+        """Build and show/execute make-request.sh commands for given suffixes."""
+        for suffix in suffixes_to_send:
+            cmd = ['make-request.sh'] + build_mode + [
+                '-D', f'php_suffix {suffix}',
+                '-c', PRE_COMMAND,
+            ] + passthrough + [f'{spec}:{send_ref}']
+            print(f'\nBuilding {name} PHP {fmt_ver(suffix_to_version(suffix))} from {send_ref}:')
+            print(shlex.join(cmd))
+            if opts['run']:
+                ok, out = run_cmd(cmd)
+                if not ok:
+                    print(f'Error: build failed', file=sys.stderr)
+                    if out:
+                        print(out, file=sys.stderr)
+
+    if opts['autotag']:
+        if tip_tag:
+            return  # plan summary already printed "skip"
+        else:
+            send(suffixes[:1], branch)
+    elif opts['autotag_continue']:
+        # re-check remote only if we didn't already find a tip tag during ref resolution
+        if not tip_tag:
+            tip_tag = find_autotag_remote(gitdir, branch)
+        if not tip_tag:
+            print(f'Error: no auto-tag for {name}, run --autotag first', file=sys.stderr)
+            return
+        send(suffixes[1:], tip_tag)
+    elif tip_tag:
+        # Auto-tag at branch tip — all suffixes build from the same tagged commit
+        send(suffixes, tip_tag)
+    else:
+        # No auto-tag at tip — new build: send first suffix from branch,
+        # wait for builder to create auto-tag, then send the rest from it
+        send(suffixes[:1], branch)
+
+        if len(suffixes) > 1:
+            new_tag = None
+            if opts['run']:
+                timeout = opts['autotag_wait']
+                print(f'Waiting up to {timeout}s for auto-tag: ', end='', flush=True)
+                t0 = time.time()
+                new_tag = wait_for_tag(gitdir, branch, timeout)
+                if new_tag:
+                    print(f' Got it in {time.time() - t0:.1f}s.')
+                else:
+                    print()  # newline after dots
+                    print(f'Error: no auto-tag after {timeout}s, aborting',
+                          file=sys.stderr)
+                    return
+            else:
+                print(f'# Would wait up to {opts["autotag_wait"]}s for auto-tag, then send remaining from it:')
+                send(suffixes[1:], '<auto-tag>')
+                return
+
+            send(suffixes[1:], new_tag)
+
+
+def main():
+    opts, specs, passthrough = parse_args(sys.argv[1:])
+    if not specs:
+        print('Usage: php-rebuild.py [--run] [-t] [--autotag|--autotag-continue] [--php RANGE] [make-request opts...] SPEC[:REF]...',
+              file=sys.stderr)
+        sys.exit(1)
+    ok, rpmdir = run_cmd(['rpm', '--eval', '%{_topdir}'])
+    if not ok:
+        print('Error: cannot determine RPM topdir', file=sys.stderr)
+        sys.exit(1)
+    opts['rpmdir'] = rpmdir
+    for spec_arg in specs:
+        process_spec(spec_arg, opts, passthrough)
+
+
+if __name__ == '__main__':
+    main()
================================================================

---- gitweb:

http://git.pld-linux.org/gitweb.cgi/projects/pld-builder.new.git/commitdiff/f4b716215ca529169fd5405da8f834f2a67a7e98



More information about the pld-cvs-commit mailing list