1
0
mirror of synced 2026-10-23 07:08:55 +03:00
Files
pgadmin4/tools/refresh_pgbuild_lock.py
Dave Page 84a76baa8e Pin against pgbuild's immutable releases, now that they exist
The lock was written against the rolling tags because the per-version
releases did not exist when it was generated, and the refresh tool assumed
PostgreSQL was tagged by major, which it is not: the release that never
changes is postgresql-18.6, whilst only the rolling one says 18. The tool
now resolves a major to the version pgbuild's manifest names, and a tag that
does not exist is an error rather than something to fall back from, since a
lock recording an archive nobody can fetch is worse than no lock at all.
2026-09-22 16:17:05 +01:00

417 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Refresh pkg/pgbuild-deps.lock from the archives pgbuild publishes.
The install-pgbuild-deps action downloads the build dependencies from the
release tags this file records, and refuses to unpack anything whose
SHA-256 is not the one recorded alongside, so bumping a dependency means
running this, looking at the diff and committing it. That diff is the audit
trail: it is the only place a change in what pgAdmin signs and ships is
written down and reviewed by a person.
pgbuild tags an immutable release per upstream version, in the shape
<package>-<version>-<platform>-<arch>, and the version it built is recorded
in its manifest.json, which is where the tag to pin is worked out from. The
archive is then downloaded from that tag and hashed. pgbuild publishes a
.sha256 beside each archive, which this deliberately ignores: it is
regenerated by whoever regenerates the archive, so it is a convenience
rather than a source of trust, and the lock file here is the only thing
that pins anything.
Refreshing everything restates every digest, which buries a single genuine
bump in noise, so name the packages you meant to bump:
tools/refresh_pgbuild_lock.py openssl
tools/refresh_pgbuild_lock.py postgresql krb5
tools/refresh_pgbuild_lock.py # everything, on purpose
Entries for packages you do not name are carried through from the existing
lock file untouched. To pin something other than what pgbuild's manifest
currently names, give the tag outright:
tools/refresh_pgbuild_lock.py \
--tag openssl-macos-arm64=openssl-3.5.3-macos-arm64
"""
import argparse
import hashlib
import json
import re
import os
import sys
import urllib.error
import urllib.request
REPO = 'pgadmin-org/pgbuild'
BASE_URL = 'https://github.com/%s/releases/download' % REPO
MANIFEST_URL = 'https://raw.githubusercontent.com/%s/HEAD/manifest.json' \
% REPO
# What install-pgbuild-deps fetches on each platform. These lists have to
# track the action, because a package the action downloads but which is
# missing from here would have no lock entry and would fail every build.
MACOS_PACKAGES = ['openssl', 'krb5', 'zstd', 'lz4', 'postgresql']
MACOS_ARCHES = ['arm64', 'x86_64']
WINDOWS_PACKAGES = ['postgresql', 'krb5']
WINDOWS_ARCHES = ['x86_64']
# The PostgreSQL majors the workflows ask the action for. Only 18 is used
# today, both as the action's default and as the explicit value the Windows
# workflows pass, but a major is added here the moment a workflow names it,
# since an archive with no lock entry does not build.
POSTGRES_MAJORS = ['18']
HEADER = """\
# pgbuild dependency lock
#
# Which build of each dependency .github/actions/install-pgbuild-deps
# downloads from pgadmin-org/pgbuild, and what it must hash to. The action
# fetches the tag named here and refuses to unpack an archive whose SHA-256
# differs, so these lines decide which PostgreSQL, OpenSSL and Kerberos
# binaries end up inside an Authenticode signed installer and a notarised
# appbundle.
#
# It matters because pgbuild also publishes rolling tags ending in -latest,
# whose assets are replaced in place on every rebuild. Building from those
# means signing whatever they happened to point at that morning, write
# access to pgbuild is write access to what pgAdmin ships, and no two
# builds of the same commit contain the same binaries. Pinning the
# per-version tag instead gives a build that can be reproduced and a change
# that can be reviewed. pgbuild publishes a .sha256 beside each archive,
# which is a convenience for people rather than a source of trust, since
# whoever rebuilds the archive rebuilds that too; this file is the only
# thing that actually pins anything, which is why the action checks against
# it and not against the published checksum.
#
# Three whitespace separated columns, one dependency per line, with # for a
# comment. Kept dull on purpose so that bash, PowerShell and a reviewer can
# all read it.
#
# 1. the dependency, as the action names it: package, platform and
# architecture, with the PostgreSQL major where there is one
# 2. the pgbuild release tag to download from, which carries the upstream
# version, so the second column is where a bump shows up in the diff
# 3. the SHA-256 of the archive in that release, in lowercase hex
#
# A build that fails on a mismatch is telling you that the bytes behind a
# pinned tag are not the bytes that were reviewed. A build that merely
# warns is telling you pgbuild has published something newer, which is
# routine and is not an error: the build carries on with the pinned
# version. Either way the fix is to refresh, read the diff and commit it:
#
# tools/refresh_pgbuild_lock.py <package>
#
# This file is generated by that script, so edit it through the script
# rather than by hand.
"""
def rolling_key(package, platform, arch, major=None):
"""Return the name the action looks an entry up by.
This is the rolling tag without its -latest suffix, which is what the
action can work out for itself from the package, the platform and the
architecture it was asked for.
"""
name = package if major is None else '%s-%s' % (package, major)
return '%s-%s-%s' % (name, platform, arch)
def archive_name(tag, platform):
"""Return the asset name pgbuild publishes in a release."""
return tag + ('.tar.gz' if platform == 'macos' else '.zip')
def expected_entries():
"""Every dependency the action can fetch, grouped for the lock file.
Returns a list of (heading, [entry, ...]) in the order the sections are
written out, each entry a dict describing one dependency.
"""
sections = []
for platform, packages, arches in (
('macos', MACOS_PACKAGES, MACOS_ARCHES),
('windows', WINDOWS_PACKAGES, WINDOWS_ARCHES),
):
for arch in arches:
entries = []
for package in sorted(packages):
majors = POSTGRES_MAJORS if package == 'postgresql' \
else [None]
for major in majors:
entries.append({
'package': package,
'platform': platform,
'arch': arch,
'major': major,
'key': rolling_key(package, platform, arch, major),
})
heading = '%s, %s' % (
'macOS' if platform == 'macos' else 'Windows', arch)
sections.append((heading, entries))
return sections
def read_lock(path):
"""Read the dependency to (tag, digest) mapping out of a lock file."""
pins = {}
if not os.path.exists(path):
return pins
with open(path, encoding='utf-8') as lock:
for line in lock:
line = line.split('#', 1)[0].strip()
if not line:
continue
fields = line.split()
if len(fields) != 3:
raise SystemExit(
'%s: cannot parse %r; expected a dependency, a tag and '
'a digest' % (path, line))
pins[fields[0]] = (fields[1], fields[2])
return pins
def manifest_versions():
"""Return the upstream version pgbuild builds of each package.
manifest.json is what pgbuild builds from and what its release tags are
named after, so it answers the question of which version to pin without
anybody having to read a tag list and guess.
"""
try:
with urllib.request.urlopen(MANIFEST_URL) as response:
manifest = json.load(response)
except (urllib.error.URLError, ValueError) as exc:
raise SystemExit('Failed to read %s: %s' % (MANIFEST_URL, exc))
return {package['name']: package['version']
for package in manifest['packages']}
def postgres_version(versions, major):
"""The full PostgreSQL version pgbuild builds for one major.
The manifest lists every supported major's current version, and pgbuild
tags the release with the full one, so 18 has to be resolved to 18.6
rather than guessed at.
"""
# The manifest records PostgreSQL's supported versions as one string
# holding a list, which is what the workflows hand to fromJSON, so the
# versions are picked out of it rather than indexed.
listed = versions.get('postgresql') or ''
if isinstance(listed, str):
listed = re.findall(r'\d+(?:\.\d+)+', listed)
for version in listed:
if str(version).split('.')[0] == str(major):
return version
raise SystemExit(
'pgbuild\'s manifest lists no PostgreSQL %s, so there is no version '
'to pin to' % major)
def tag_for(entry, versions):
"""Return the release tag to pin one dependency to.
Everything, PostgreSQL included, is tagged with the full upstream
version pgbuild's manifest names, so a PostgreSQL major has to be
resolved to the version built for it: the rolling tag says 18, whilst
the release that never changes says 18.6.
"""
if entry['package'] == 'postgresql':
version = postgres_version(versions, entry['major'])
else:
version = versions.get(entry['package'])
if version is None:
raise SystemExit(
'%s is not in pgbuild\'s manifest, so there is no version '
'to pin it to' % entry['package'])
return '%s-%s-%s-%s' % (entry['package'], version, entry['platform'],
entry['arch'])
def download_digest(tag, platform):
"""Hash one archive, returning None where the release does not exist.
The archives run to tens of megabytes and nothing here needs their
contents, so they are hashed as they stream past rather than written
to a temporary file somebody would then have to clean up.
"""
archive = archive_name(tag, platform)
url = '%s/%s/%s' % (BASE_URL, tag, archive)
digest = hashlib.sha256()
try:
with urllib.request.urlopen(url) as response:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise SystemExit('Failed to fetch %s: %s' % (url, exc))
except urllib.error.URLError as exc:
raise SystemExit('Failed to fetch %s: %s' % (url, exc))
return digest.hexdigest()
def digest_for(entry, tag):
"""Hash the archive a dependency is pinned to.
A tag that does not exist is an error rather than something to work
around: the whole point of pinning is that the lock names an archive
which will still be there months from now, so a missing one means
pgbuild has not published that version and the lock would be recording
something nobody can fetch.
"""
digest = download_digest(tag, entry['platform'])
if digest is None:
raise SystemExit(
'%s does not exist in pgbuild, so there is nothing to pin. If '
'its build has not finished yet, wait for it and try again.'
% tag)
return digest
def write_lock(path, sections, pins):
"""Write the lock file out, aligned and in a stable order."""
keys = [entry['key'] for _, entries in sections for entry in entries]
tags = [pins[key][0] for key in keys]
key_width = max(len(key) for key in keys) + 2
tag_width = max(len(tag) for tag in tags) + 2
with open(path, 'w', encoding='utf-8') as lock:
lock.write(HEADER)
for heading, entries in sections:
lock.write('\n# %s\n' % heading)
for entry in entries:
tag, digest = pins[entry['key']]
lock.write('%s%s%s\n' % (
entry['key'].ljust(key_width), tag.ljust(tag_width),
digest))
def parse_pins(values):
"""Parse the --tag DEPENDENCY=TAG overrides into a mapping."""
overrides = {}
for value in values:
if '=' not in value:
raise SystemExit(
'Expected --tag DEPENDENCY=TAG, not %r' % value)
key, tag = value.split('=', 1)
overrides[key.strip()] = tag.strip()
return overrides
def main():
parser = argparse.ArgumentParser(
description='Refresh the pgbuild dependency lock file.')
parser.add_argument(
'packages', nargs='*', metavar='PACKAGE',
help='packages to refresh, e.g. openssl; all of them if omitted')
parser.add_argument(
'--tag', action='append', default=[], metavar='DEPENDENCY=TAG',
help='pin one dependency to a named tag rather than to whatever '
'version pgbuild\'s manifest currently names')
parser.add_argument(
'--lock', default=None,
help='path to the lock file (default: pkg/pgbuild-deps.lock)')
args = parser.parse_args()
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = args.lock or os.path.join(root, 'pkg', 'pgbuild-deps.lock')
sections = expected_entries()
known = sorted({entry['package'] for _, entries in sections
for entry in entries})
unknown = [name for name in args.packages if name not in known]
if unknown:
raise SystemExit('Unknown package(s): %s. Known packages are: %s'
% (', '.join(unknown), ', '.join(known)))
overrides = parse_pins(args.tag)
pins = read_lock(path)
todo = []
for _, entries in sections:
for entry in entries:
# A dependency with no pin yet has to be resolved whatever was
# asked for, since leaving it out would write a lock file the
# action then refuses to build against.
wanted = not args.packages or entry['package'] in args.packages
if wanted or entry['key'] not in pins:
todo.append(entry)
if not todo:
raise SystemExit('Nothing to do.')
versions = manifest_versions()
refreshed = 0
for entry in todo:
key = entry['key']
tag = overrides.get(key) or tag_for(entry, versions)
print('Hashing %s...' % archive_name(tag, entry['platform']))
digest = digest_for(entry, tag)
if pins.get(key) != (tag, digest):
refreshed += 1
pins[key] = (tag, digest)
write_lock(path, sections, pins)
print('\nWrote %s, %d entr%s changed.'
% (path, refreshed, 'y' if refreshed == 1 else 'ies'))
print('Review the diff before committing it: it is the record of a '
'change to what we sign.')
return 0
if __name__ == '__main__':
sys.exit(main())