Replaces the buildfarm's pgadmin4-windows-x64-snapshot job, and it is the one
workflow that cannot use a hosted runner: the certificate is on a Certum
hardware token, reached through the machine certificate store, with a PIN
handler answering the prompt from the interactive desktop session. None of that
can move to a runner GitHub provides.
Setting PGADMIN_WINDOWS_CSC is the whole of what makes Make.bat sign, so the
build fails immediately if the variable is absent rather than quietly producing
an unsigned installer that looks like a successful snapshot. It is a repository
variable rather than a value in the tree: it is a person's name, and a
certificate renewal should be a settings change rather than a commit.
Three things differ from the QA build beyond the signing.
Nothing is installed. The hosted build runs choco; this machine is provisioned
by the buildfarm's Ansible and a CI job has no business mutating it, so the
prerequisites are checked and the build stops with a useful message if one is
missing.
Visual Studio, the VC++ redistributable and signtool are discovered rather than
hardcoded. The buildfarm pins all three in its Jenkins node configuration,
which then needs editing every time Visual Studio or the Windows SDK updates,
and signtool in particular lives at a versioned path.
Concurrency is a fixed group with cancel-in-progress off. There is one hardware
token, Jenkins may also be using it, and two builds signing through it at once
is a good way to discover what proCertum does under contention.
The verification step checks the executable inside the installer as well as the
installer itself. Make.bat verifies only the latter, and the former is the
thing users actually run.
These all trigger on pushes to master, which means that on a working branch
they never run and the branch cannot be tested at all. So the branch is named
alongside master in the triggers, to be removed when it merges.
The snapshot build needs different treatment. workflow_dispatch only offers a
"Run workflow" button for workflows that are on the default branch, so whilst
it lives on a branch there is no way to trigger it by hand at all. A plain push
trigger would mean signing and notarising on every commit, which is both
wasteful and rude to Apple's notary service, so it fires only on changes to
itself or to pkg/mac, which is to say only when the thing under test changes.
Its own header said to delete it once the cause was understood, and it is: the
unmount cost scales with the bundle, hdiutil's 120 second DiskArbitration
timeout cannot be raised, and detach -force waits on DiskArbitration exactly as
the polite detach does. The build now creates the image with hdiutil create
-srcfolder and never mounts a volume at all.
What the spike established is recorded where it will actually be read, in the
comments on _create_dmg and in the commit that replaced create-dmg, rather than
in a workflow nobody will run again: create-dmg's flags, the volume size, the
file count, Gatekeeper, Spotlight and the choice of runner image were each
tested and each exonerated.
The first ad-hoc run failed on the very first binary:
Contents/MacOS/pgAdmin 4: replacing existing signature
Contents/MacOS/pgAdmin 4: code object is not signed at all
In subcomponent: Contents/LICENSE
The Developer ID path passes --deep on every codesign call and the ad-hoc one
did not. These are bundles with nested code, and without --deep codesign will
not replace the signature on one whose nested code is unsigned. Matching the
existing path is both the fix and the smaller surprise for anyone reading the
two branches side by side.
Replaces the buildfarm's pgadmin4-appbundle-snapshot job. Where the QA build
proves the bundle assembles, this produces something distributable: signed with
the Developer ID, notarised by Apple, stapled, and with a ZIP alongside the DMG
because the Electron auto-updater consumes the ZIP whilst the DMG is the
download people click.
The buildfarm keeps the Developer ID in the worker's login keychain and unlocks
it with devkeys/unlock-csc.sh. A hosted runner has no such keychain, so one is
created for the job and destroyed with it, under a generated password that
never outlives the run. set-key-partition-list matters more than it looks:
without it codesign asks permission to use the key, and on a headless runner
asking means hanging until the job times out.
The credentials sit on a "snapshot" environment rather than loose on the
repository, so only workflows that ought to be signing can reach them, and the
cleanup step runs even on failure, since neither a keychain holding a Developer
ID key nor a conf file holding an app-specific password should survive a failed
build.
Nothing is uploaded yet. This proves the signed build works; publishing is the
next piece. It is also workflow_dispatch only rather than nightly, so it cannot
start signing things before we intend it to.
Requires six secrets on the snapshot environment: MACOS_CERTIFICATE (the
Developer ID .p12, base64 encoded), MACOS_CERTIFICATE_PASSWORD,
MACOS_DEVELOPER_ID, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD and APPLE_TEAM_ID.
C:\build64 was the obvious place to put them and the wrong one. It is what the
buildfarm's winpgbuild-update job manages on the Windows worker, where it
deletes and re-extracts postgresql and krb5 inside that directory.
That does not matter whilst these builds only run on hosted runners, which are
ephemeral and have no Jenkins on them. It matters a great deal as soon as the
signing builds run on that same machine as a self-hosted runner, because the
two systems would then take turns overwriting each other's dependencies, and
the Jenkins-side failures would look nothing like their cause: a build that
worked yesterday, with a PostgreSQL that is subtly not the one it was given.
So the path is derived from RUNNER_TEMP and passed on through GITHUB_ENV rather
than being a fixed constant. Nothing else changes, and QA stays on the hosted
runner.
Without codesign.conf the build skipped signing entirely, which leaves a bundle
that cannot be run rather than merely one that cannot be distributed.
_fixup_imports rewrites install names across several hundred binaries, which
invalidates whatever signatures they arrived with, Electron's included. On
Apple Silicon the kernel refuses to execute a binary whose signature is broken
or absent, so an unsigned bundle will not launch at all there, even once the
quarantine attribute has been removed. On Intel it is less severe but no more
correct: every binary ends up carrying a signature that no longer matches its
contents.
So with no Developer ID we now sign ad-hoc. This is not a substitute for a real
signature and is not meant to be: the result is still refused by Gatekeeper and
still cannot be notarised, and the build says so. It just means an unsigned
build is something a developer can actually open after allowing it, which is
the entire point of producing one.
The signing arguments move into a helper so the two paths cannot drift, and the
dylib loop stops using find -exec so it can share them. Signing the disk image
ad-hoc would achieve nothing, since a signature there is about distribution, so
that one still does nothing without a Developer ID.
Worth noting this path had never run anywhere before: the buildfarm's macOS QA
job signs properly, because it runs on a machine with the certificate, so the
unsigned branch existed only in principle. The same was true of the Windows
installer's unsigned path until last week.
_create_python_env installs the whole of requirements.txt into the framework
that gets shipped, and then _build_docs created a fresh venv and installed all
hundred-odd packages again. Both installs show up in every build log as
identical "Successfully installed Authlib-1.8.0 Babel-2.18.0 ..." lines a few
hundred lines apart.
The second install was not gratuitous: the doc build imports pgAdmin itself,
since docs/en_US/build_code_snippet.py pulls in config, pgadmin.utils,
pgadmin.browser.utils and the driver abstractions, so the dependencies have to
be importable. But they already are, in the framework populated moments
earlier, so the venv can inherit them instead. Sphinx still goes into the venv
rather than the framework, which is the reason the venv exists at all: it must
not end up in the shipped bundle.
--system-site-packages is safe here in a way it is not in the Linux build,
which deliberately avoids it. There the parent is a shared system Python that
may carry other packages' stale .pth files, and one of those corrupted sys.path
badly enough to break pip's own bootstrap on the el-10 builder. Here the parent
is a framework we built ourselves in this run, containing only our
dependencies. The comment says so, to save someone importing the Linux
workaround into a situation that does not call for it.
Windows already avoided the duplication by adding sphinx to the same
environment, though without the separation that keeps it out of the shipped
files.
pgadmin-org/pgbuild supersedes dpage/winpgbuild, and now that a full
build-all-windows run has published the Windows artefacts, this points at them
instead. Both platforms then build against a single manifest of pinned
versions, which is the main reason for merging the two repos in the first
place: until now the macOS and Windows dependency versions were kept in step by
hand, in two places, with nothing enforcing it.
Verified against what Make.bat actually needs before switching. The krb5
archive unpacks to krb5/ and carries kinit.exe, krb5_64.dll, comerr64.dll,
k5sprt64.dll and gssapi64.dll; the PostgreSQL one unpacks to postgresql/ and
carries libpq.dll, zlib1.dll, the client binaries, libcrypto-3-x64.dll and
libssl-3-x64.dll, along with the optional liblz4.dll, libzstd.dll, libintl-8.dll
and libiconv-2.dll. So the directory layout the workflow already assumed is
unchanged and every file the build copies is present.
Downloads now go through gh rather than Invoke-WebRequest, which keeps the tag
and the asset name in one place and fails loudly if a release is missing rather
than writing an error page to disk and failing later at Expand-Archive.
Homebrew's PostgreSQL was only ever a stand-in for the build check, and a poor
one: it has no zstd or lz4 support, which is issue #9425, it links Homebrew's
OpenSSL rather than the one we ship, and it carries whatever minimum macOS
Homebrew happened to build for. So the QA build proved that the bundle
assembles, but not that the binaries in it are the ones we intend to ship.
The dependencies now come from pgadmin-org/pgbuild, which builds OpenSSL,
Kerberos, zstd, lz4 and PostgreSQL against each other with a 14.0 deployment
target, matching the support policy. That makes the QA build the same thing the
release build will produce, minus the signing, and it exercises those artefacts
continuously rather than discovering a problem with them at release time.
They are unpacked into /opt/pgbuild specifically, because the install names are
absolute: pg_dump asks for /opt/pgbuild/zstd/lib/libzstd.1.dylib and libpq for
/opt/pgbuild/krb5/lib/libgssapi_krb5.2.2.dylib, among others. _fixup_imports
copies from those paths into the bundle and rewrites the references, so they
have to resolve during the build.
Which is also why /opt/pgbuild joins the prefixes _verify_bundle_linkage
rejects. An unrewritten reference to it is precisely the defect that check
exists to catch, no different from a dangling Homebrew path, and it would
otherwise pass unnoticed: the shipped binary would look fine on the build
machine and fail on a user's.
OPENSSL_DIR now points at our OpenSSL too, so the Intel cryptography build,
which compiles from source because there is no x86_64 wheel, statically links
the same OpenSSL as everything else in the bundle.
The DMG was built by mounting a read/write volume, populating it, unmounting it
and converting the result. The unmount is the step that does not survive
contact with a slow machine: hdiutil waits on DiskArbitration with a fixed 120
second timeout that nothing exposes a way to raise, and the cost scales with the
bundle. Measured on GitHub's hosted runners with an identical payload it takes
around 20 seconds on arm64, 41 on macos-26-intel and 90 on macos-15-intel, and
with the real bundle it exceeds 120 on both Intel images every time. It has been
seen intermittently on developers' own machines for the same reason.
`hdiutil detach -force` is no help, which was worth finding out the hard way
since it is usually described as the escape hatch: it waits on DiskArbitration
as well, times out identically, and the conversion then fails with "Resource
temporarily unavailable" because the image is still attached.
`hdiutil create -srcfolder` builds the image directly from a directory and never
mounts a volume, so none of this can happen. Verified on both architectures
against a real Electron bundle: the image is created and verifies, and mounting
it read-only afterwards shows the volume named correctly with the .DS_Store,
the background directory, the volume icon and the Applications symlink all
present. hdiutil preserves that symlink rather than following it. Creation takes
107 seconds on arm64 and 312 on macos-26-intel, slower than before, but with no
fixed wall anywhere in it.
That also disposes of create-dmg, which was cloned unpinned from its default
branch on every build and run against the signed bundle. Since we passed
--skip-jenkins, none of its Finder automation ran and everything it did for us
was staging, all of which is now done directly. Its EULA template is vendored
into pkg/mac/ rather than cloned for one XML file, and the licence is attached
with udifrez exactly as create-dmg did.
Ruled out before arriving here: create-dmg's flags, the volume size, the file
count, Gatekeeper, Spotlight and the choice of runner image. A 400 MB image of
30,000 text files and one holding Electron and a Python framework unmount in
comparable time, so only the speed of the machine and the size of the bundle
move the number.
One thing CI cannot check is whether the volume icon and background actually
render, since that needs Finder. The DMG is kept as a build artefact so it can
be opened and looked at.
The recovery added earlier does not work, and the run proved it plainly:
Forcing detach of /dev/disk22...
hdiutil: detach: timeout for DiskArbitration expired
hdiutil: detach: drive not detached
hdiutil: convert failed - Resource temporarily unavailable
So hdiutil detach -force waits on DiskArbitration exactly as the polite detach
does, same 120 seconds and same message, contrary to how it is usually
described, and the convert then fails because the image is still attached. That
was the one assumption I could not verify beforehand, the spike having never
reproduced the failure and its diagnostics therefore never having run.
hdiutil create -srcfolder builds the image directly from a directory and never
mounts a volume, so DiskArbitration is never involved and there is nothing to
detach. Since we pass --skip-jenkins, create-dmg does no Finder automation for
us anyway: what it actually provides is the volume name, an Applications
symlink, the background directory, the pre-built .DS_Store and a hidden
extension, all of which are staging. The two that are not are the volume icon,
which needs the custom-icon flag on the volume root, and the EULA, which is
attached to the finished image rather than placed inside it.
Rather than learn which of those survive through forty minute Intel builds,
test F stages a directory exactly as the replacement would and creates the
image from it, then mounts the result read-only to confirm the styling is
really there. Mounting a finished compressed image briefly is cheap; it is
unmounting a large populated read/write volume that is not.
The dead recovery code in _create_dmg stays put until this proves out, so it is
replaced in one change rather than reverted and then rewritten.
create-dmg builds a read/write image, mounts it, populates it, unmounts it, and
only then converts it to the compressed image we ship. The unmount is the
fragile step: hdiutil waits on DiskArbitration with a fixed 120 second timeout
that nothing exposes a way to raise, and the cost of unmounting scales with the
bundle.
Measured on GitHub's hosted runners with an identical payload, unmount to "Disk
image done" takes about 20 seconds on arm64, 41 on macos-26-intel and 90 on
macos-15-intel; with the real bundle it exceeds 120 on both Intel images, every
time rather than occasionally. It has also been seen intermittently on
developers' own machines, which is the same problem with a smaller multiplier.
Retrying create-dmg would achieve nothing, since a deterministic failure just
recurs. What matters is that by the time the unmount fails the working image is
fully populated, and the only step create-dmg did not reach is the conversion.
So on failure we force the volume off, which does not wait on DiskArbitration,
and perform that last step ourselves. The result is verified rather than
assumed, because this path only runs when something has already gone wrong.
The successful path is untouched: when create-dmg returns 0 we return
immediately and none of this executes.
A fair amount was ruled out before landing here. create-dmg's flags, the volume
size, the file count, Gatekeeper, Spotlight and the choice of runner image were
each tested and each exonerated: a 400 MB image of 30,000 text files and one of
the same size holding Electron and a Python framework unmount in comparable
time, and only the speed of the machine and the size of the bundle move the
number.
The previous commit message said this was going and then didn't remove it.
Spotlight was ruled out two runs ago: the step demonstrably disabled indexing
and the detach failed exactly as before, so the comment above it claimed a
cause that had already been disproved.
Tests C and D pinned the cost to something about a real bundle and, more
usefully, showed how differently the three runners handle it. Unmount to "Disk
image done", identical payloads: 19s on arm64, 41s on macos-26-intel, 82s on
macos-15-intel, against hdiutil's 120 second timeout, and that was with a
lighter stand-in than the real thing.
So the Intel QA job moves to macos-26-intel, which has roughly double the
headroom and is the newer image we would end up on anyway, macOS 15 being the
first to retire. The mdutil step goes at the same time: Spotlight was ruled out
two runs ago and leaving a step behind whose comment claims it fixes something
is worse than not having it.
What C and D could not separate is content type from file count, and the second
looks the more likely of the two. Test B held ~409 MB in 201 files and test D
~415 MB in a few thousand; the real bundle holds a comparable amount of data
across far more files, since web/ alone is 4,626 of them, 2,043 being .sql
templates, with docs/ adding 773 and site-packages and the Python stdlib on top.
Thirty thousand is a conservative estimate.
Test E therefore puts the same ~400 MB into 30,000 small text files with no
Mach-O content at all. If the unmount climbs, the driver is the number of files,
not Gatekeeper or LaunchServices inspecting binaries, and no amount of signing
or scanning policy would have helped. It also tells us whether macos-26-intel
survives a realistic file count, which decides whether the runner change above
is a fix or merely a stay of execution.
The first pass of the spike came back clean on all three runners, which is more
informative than it sounds. Bare hdiutil detached a 100 MB image in six seconds
and a 1.5 GB one in under a second, and create-dmg with pgAdmin's exact flags
and real assets over a 409 MB payload exited 0 in forty-four seconds, on the
very runner label where the real build has now failed three times out of three.
So it is not the runner image, not hdiutil, not create-dmg's invocation and not
the size of the volume. What is left is the bundle. Test B's stand-in was a
random blob in Contents/MacOS with no Info.plist and no Mach-O, which the system
does not regard as an application at all, whereas a genuine bundle landing on a
freshly mounted volume gets picked up by LaunchServices and Gatekeeper in ways a
folder of noise does not.
Test C is therefore a real Electron.app, which is precisely what the real build
starts from, and test D adds a relocatable Python framework on top, that being
the other symlink-heavy thing the build layers on and the reason
_prune_dangling_symlinks exists. Neither installs anything through pip, so this
stays in the region of ten minutes rather than the forty the real build needs on
Intel to compile cryptography.
If C fails we have it narrowed to real app bundles; if C passes and D fails, to
the Python framework.
Three Intel appbundle runs have now failed identically, always exactly 120
seconds after create-dmg starts unmounting, with the bundle otherwise complete
and verified; arm64 has passed four times through the same code, and disabling
Spotlight changed nothing. Reproducing it through `make appbundle` costs forty
minutes a go, nearly all of it compiling cryptography on Intel, which is a
miserable way to test a hypothesis.
So this reproduces the failing step directly, in a couple of minutes, and
answers three questions at once: whether plain hdiutil fails or only create-dmg
does, whether it depends on image size, and whether it is specific to the
macos-15-intel image. arm64 is included as the control that is known to work.
When something fails it captures evidence rather than leaving us guessing:
hdiutil's own view of what is attached, lsof against the volume, the processes
that typically hold one, the diskarbitrationd and hdiutil unified log for the
preceding few minutes, and whether a forced detach succeeds where the polite
one timed out. That last one should be the most telling, since it distinguishes
something holding a reference from DiskArbitration itself not answering.
The mdutil step added to check-macos-build.yml stays for now so the two runs
remain comparable; it should come out once we know the real cause.
The Intel appbundle build fails at the very last step, every time rather than
intermittently: the bundle is assembled, _verify_bundle_linkage passes, the SBOM
is written, the disk image is created and populated, and then create-dmg cannot
unmount it.
Unmounting disk image...
hdiutil: detach: timeout for DiskArbitration expired
hdiutil: detach: drive not detached
Two runs, identical failure, and the gap between the unmount starting and the
timeout is exactly 120 seconds, so something is waiting out a timeout rather
than racing. Spotlight indexing the freshly mounted volume is the usual cause,
holding a DiskArbitration reservation that the detach then sits behind, and
nothing on a build runner wants a Spotlight index anyway.
arm64 has never shown this, so the step is harmless there rather than needed.
If this doesn't settle it, the next thing to try is macos-26-intel, and after
that a retry around the detach, which would mean changing pkg/mac rather than
the workflow.
Dropped by accident when the compiler packages were added, and pkg/redhat's
build.sh shells out to yumdownloader at line 229, near the very end. So the
container built the docs, ran webpack, produced the RPMs, and only then died
with a bare exit 127 about twelve minutes in, which is a slow and unhelpful way
to learn that a package is missing.
Also check for yumdownloader explicitly once the packages are installed, so the
same class of mistake fails immediately and says what is wrong. That matters
beyond this one slip: the dnf5 distributions (EL 10, Fedora 41 and later) get
yumdownloader via yum-utils' compatibility shims rather than from yum itself,
which is the sort of thing that changes under us without warning.
Two problems, one of them in the packaging rather than the workflow.
installer.iss.in has always carried an unconditional `SignTool=pgAdminSigntool`,
but Make.bat only defines that SignTool when PGADMIN_WINDOWS_CSC is set. So the
unsigned path, which Make.bat explicitly supports and announces ("Skipping code
signing"), cannot actually produce an installer: ISCC aborts with "Value of
[Setup] section directive SignTool is invalid". It has presumably never been
exercised, because the Jenkins Windows job runs on the box with the token
plugged in, where the variable is always set.
Both signing directives are now inside `#ifdef SIGNED`, and the signing branch
of Make.bat passes /DSIGNED next to the existing /SpgAdminSigntool=. The signed
build emits exactly what it emitted before; the unsigned build now works.
Second, and more mundane, syft isn't on the hosted image. That one was silent:
:GENERATE_SBOM redirects syft's output into sbom.json and returns 0 whatever
happens, so a machine without syft ships an empty SBOM rather than failing. The
buildfarm's Windows worker installs it via Chocolatey, so this does too.
cryptography publishes arm64-only macOS wheels (checked against 50.0.1), so on
macos-15-intel pip builds it from the sdist and its openssl-sys crate links
whatever OpenSSL it finds on the build host, which on a hosted runner is
Homebrew's. _fixup_imports deliberately skips _rust.abi3.so, so the reference
survives into the bundle and _verify_bundle_linkage stops the build:
ERROR: .../cryptography/hazmat/bindings/_rust.abi3.so links against
build-host libraries:
/usr/local/opt/openssl@3/lib/libssl.3.dylib
/usr/local/opt/openssl@3/lib/libcrypto.3.dylib
That's issue #10123 reproducing exactly, and the release build already guards
against it by setting OPENSSL_DIR and OPENSSL_STATIC against its own OpenSSL.
This does the same against Homebrew's, which leaves no external reference at
all. The arm64 job was unaffected throughout, since it gets a wheel.
Worth noting for scheduling: the Rust compile makes the Intel build roughly
twice the length of the arm64 one, 50 minutes against 27.
wget isn't on the hosted Windows image, and Make.bat's Electron download is an
unbounded retry loop (Make.bat:316), so the build doesn't fail, it just spins on
error 9009 until the job hits its timeout. The buildfarm's Windows worker has
wget from the same Chocolatey line in pkg/win32/README.md, which is where this
one now gets it too.
Two faults from the first run, neither of them deep.
All nine jobs would have hit the git ownership check: the container runs as
root whilst the workspace is owned by the runner user, so git refuses to touch
it, and `yarn run bundle` shells out to git for the commit hash and dies with
exit 128. The DEB builds got all the way through webpack before falling over on
that, which at least confirms the rest of the environment is sound. Marking the
workspace as a safe directory after checkout is the usual remedy.
The RPM builds failed earlier and more prosaically, with no compiler in the
container: psycopg-c and gssapi are both built from source, since
pkg/linux/build-functions.sh passes --no-binary psycopg, and rpmbuild wants
redhat-rpm-config. The buildfarm picks these up from the @development-tools
group; they're listed individually here to keep the container small. The DEB
side already had build-essential, which is why it got so much further.
The runner has started warning about Node.js 20 actions being deprecated, which
covers every first-party action we use, since v4 of checkout and upload-artifact
still run on the Node 20 runtime.
So: checkout v4 to v7, upload-artifact v4 to v7, setup-node v4 to v7,
setup-python v5 to v7 and docker/build-push-action v6 to v7. Nothing behavioural
changes for us. The intervening majors are almost entirely the Node 24 runtime
move, with checkout v6 persisting credentials to a separate file, checkout v7
refusing to check out a fork's head for pull_request_target and workflow_run
(neither of which we use), and upload-artifact v7 adding an opt-in unzipped
single-file upload.
One thing to be aware of before we stand up a self-hosted runner for the signing
builds: checkout v5 and later need runner 2.327.1 or newer, so that host will
have to be reasonably current.
The SonarQube action is left pinned to its commit hash, which is deliberate.
These are the five QA builds that the Jenkins buildfarm still owns, and between
them they're the last thing standing in the way of retiring the QA half of it:
the container, tarball, wheel and documentation checks already run here.
The DEB and RPM builds run in a container per distribution on a hosted runner
rather than on a dedicated EC2 VM per distribution, which is the same build in
a reproducible environment and nine fewer machines to look after. The package
lists come from the buildfarm's own Ansible packages_build role. PGDG is
mandatory for RPM, because pkg/redhat/build.sh opens by globbing /usr/pgsql-1*
under `set -e` and dies without it; the DEB build is happy with the distro's
libpq-dev.
macOS builds on both architectures and is already proven: an earlier spike ran
it on macos-15 in 22 minutes with webpack peaking at 2991 MB against its 4096 MB
ceiling and no swap used at all, so the 7 GB arm64 runners have adequate
headroom. That spike workflow is removed here, superseded by the real one.
Windows runs on a hosted runner too, which wasn't obvious: signing in Make.bat
is conditional on PGADMIN_WINDOWS_CSC, so an unsigned build check needs neither
the Certum hardware token nor the machine it's plugged into, and only the
signing release builds need a self-hosted runner. Its dependencies come from
dpage/winpgbuild, the same place the buildfarm's winpgbuild-update job gets
them. This one hasn't had a successful run yet and should be expected to need a
few passes.
All five trigger on master and by hand, not on pull requests, matching what the
Jenkins jobs do today; whether the full matrix should also gate PRs is a
separate question.
We're assessing a move of the buildfarm to GitHub Actions, and the one thing
that isn't already proven by the existing workflows is whether the appbundle
build fits inside a hosted macOS runner. The arm64 images have 3 vCPU and 7 GB
of RAM against the 8 cores and 16 GiB of the mac2.metal worker they'd replace,
and pkg/mac/build-functions.sh:320 records webpack being OS-OOM-killed inside
TerserPlugin at a 3 GB old-space ceiling, with 6 GB then starving the earlier
steps. Nothing we currently run on macOS builds a production bundle, so that
history is untested on hosted hardware.
The workflow is workflow_dispatch only, builds unsigned (build.sh skips signing
and notarisation when the two conf files are absent, so it needs no secrets),
and samples resident set sizes and swap every ten seconds so the logs show
whether the machine actually ran out of headroom rather than just whether the
build happened to pass. The runner image, the Python version and the webpack
memory ceiling are all inputs, so the ceiling can be bisected across images.
To be deleted once the question is answered.
Backup/restore now pass the target database via PGDATABASE instead of
a CLI/positional argument, so the XSS regression test's check against
the displayed command string could never find the db name (escaped or
not) and always failed. Point Backup's check at the message field,
where the db name is still interpolated; for Restore, where the db
name no longer surfaces anywhere (command shows an always-empty
--dbname, message omits it entirely), assert its absence instead.
Also drop the fuzzy marker from the Korean catalog header. Babel skips a
catalog whose header is fuzzy, and ko's header still carried the
unfilled template placeholders that make babel mark it so, with the
result that ko/LC_MESSAGES/messages.mo had not been regenerated since
November 2025 -- 83KB against the 263KB the .po compiles to, so most of
the 89% of Korean strings that are translated never reached users.
The file mixed issue-backed entries with commit-id ones. Make every
entry issue-backed:
- The LLM API redirect hardening had no issue, and its entry was the
only record of Ziya Abdullayev's report outside the commit message.
It gets #10420, written up with the mechanism, the affected range
(>= 9.15, since ALLOWED_LLM_API_URLS did not exist before then) and
the fix commit, and noting that it is hardening rather than an
exploitable flaw, so no CVE was requested.
- The Table dialog fix closed two issues, #10179 and #10180, so it
becomes two entries. That also covers the Definition-tab type
restriction, which the single commit-id entry had omitted.
- The remaining commit-id entries are dropped.
The four-line comment explaining why an empty database must be rejected
was pasted twice above the guard in create_maintenance_job(). Comment
only; no behaviour change.
The PGDATABASE fix only set the env var when the database value was
truthy, so an empty string left both PGDATABASE and --dbname unset.
libpq then falls back down its chain to a database named after the
login role, silently restoring into / vacuuming the wrong database
instead of failing. Reject an empty or missing database with a 400
before any args are built, in both tools, and add regression tests
covering the empty-value path.
- Scan every argv token for the forbidden value (forbidden_arg_substr)
instead of list-membership only, so a value smuggled into a single
token (e.g. "--dbname=host=...") is caught.
- Assert pg_restore keeps the required "--dbname=" flag, guarding against
a regression that drops it (pg_restore errors without -d/--dbname).
- Add regression scenarios asserting the database value is absent from
argv and carried in PGDATABASE (maintenance: 1, restore: 2). They fail
if the fix is reverted.
- Use the attached form "--dbname=" (a single argv token) for pg_restore
instead of ['--dbname', ''] so the process-details command renders
correctly -- an empty standalone token was dropped from the display.
The restore and maintenance tools passed the user-controlled database
value to --dbname. libpq expands a --dbname value containing "=" (e.g.
"host=evil port=5433 dbname=postgres") into a connection string, letting
a user redirect the connection -- and the exported PGPASSWORD credential
-- to an arbitrary server, regardless of the --host/--port pgAdmin sets.
Pass the target database via the PGDATABASE environment variable instead
(libpq treats it as a literal name and never expands it):
- maintenance (psql) and restore plain/SQL (psql): drop --dbname; the
tool connects using PGDATABASE.
- restore custom/tar/directory (pg_restore): pg_restore requires
-d/--dbname, so pass an empty --dbname '' (no "=", not expanded) while
the real database name comes from PGDATABASE.
The macOS x64 packaging job fails while installing requirements.txt:
Preparing metadata (pyproject.toml): finished with status 'error'
error: subprocess-exited-with-error
exit code: -10
Exit code -10 is SIGBUS. The only output before the crash is
setuptools' own `[tool.setuptools.ext-modules] in pyproject.toml is
still experimental` warning, so it dies while setuptools reads the
project config, well before pg_config is ever invoked.
psycopg-c ships no wheels, so every platform compiles it from the
sdist. The only build-tooling change between 3.3.4 and 3.3.5 is the
strictly pinned build requirement:
3.3.0 - 3.3.4 setuptools == 80.3.1
3.3.5 setuptools == 83.0.0
Because that pin is an equality, PIP_CONSTRAINT cannot override the
version inside the isolated build environment - the only ways to keep
3.3.5 are --no-build-isolation with a pre-seeded setuptools (which
would fork the macOS build procedure from Linux's) or an upstream
fix. Linux builds 3.3.5 fine, so this looks specific to the
relocatable-python framework the macOS bundle is built against.
Pin to 3.3.4 to unblock the build. The encoding.py rewrite that
shipped alongside the 3.3.5 bump stays untouched: it reads the
derived py_codecs/pg_codecs dicts, which are plain dicts in both
versions.
One test expectation was 3.3.5-specific. psycopg 3.3.5 added an
ISO88591 alias to its encoding table, so inverting py_codecs resolves
iso8859-1 to ISO88591; on 3.3.4 only LATIN1 exists, so
get_encoding('LATIN1') returns ['LATIN1', 'iso8859-1'] instead of
['ISO88591', 'iso8859-1']. Both are valid PostgreSQL names for the
same encoding, so the scenario now accepts either and the test passes
on 3.3.4 and 3.3.5 alike.
Set npmMinimalAgeGate to 3d in web/.yarnrc.yml and runtime/.yarnrc.yml to prevent immediately installing freshly-published packages, providing a grace period against npm supply-chain compromises.
Revert checksumBehavior from update back to Yarn's default (throw) in web/.yarnrc.yml and runtime/.yarnrc.yml to restore tamper detection and ensure builds fail if downloaded package checksums do not match lockfiles.
Revert global enableScripts in web/.yarnrc.yml to Yarn's secure default (false) to prevent arbitrary lifecycle scripts from running on install. Allowlist only ttf2woff2 and unrs-resolver via dependenciesMeta.built in web/package.json, keeping promotional/unneeded scripts blocked. Narrow approvedGitRepositories to pgadmin-org/react-data-grid.
Fill in the identifiers allocated by the PostgreSQL CNA for the three
security entries that carried a placeholder:
#10383 webserver Remote-User auth bypass CVE-2026-86863
#10384 backup argument/conninfo injection CVE-2026-86864
#10385 File Manager save_file symlink follow CVE-2026-86861
Add the missing entry for #10388, connection-string injection in the
Restore and Maintenance tools (CVE-2026-86862). The fix was written but
never got a release-note line.
Move the release date out to 2026-09-17.
Backend properties fetch marks a column already inherited from a
parent table with inheritedfromtable, while a column fetched
interactively via 'Inherited from table(s)' carries inheritedfrom
instead and has no attnum yet. inSchemaWithColumnCheck only checked
inheritedfrom, and did so after an isNew() short-circuit that treated
the attnum-less interactive rows as new, so inherited columns ended up
editable and deletable in both cases. Check both fields, and check
them before the isNew() short-circuit, and extend
canEditDeleteRowColumns the same way so the row's edit/delete buttons
are disabled too.
Fix: restrict Definition-tab Data type options to edit_types (#10180)
The expanded row's Definition tab and the inline grid-cell editor each
defined their own copy of the edit_types filter for the 'cltype'
field, but the tab's version received the whole table's data as
'state' rather than the row, since MappedControl resolves a field's
'type' callback against the top-level schema, not the field's own row
(unlike 'cell', which already gets the full row). That made isNew()
and edit_types resolve against the wrong object, so the filter always
no-opped and the tab showed every type instead of the restricted set.
Have MappedControl also resolve a field's declared 'deps' against its
own row (listenDepChanges already does this correctly for 'cell') and
forward them as a 2nd argument to 'type', mirroring what 'cell'
already receives. column.ui.js declares 'edit_types'/'attnum' as deps
on 'cltype' and factors the filter into one shared editTypesFilter()
used by both 'cell' and 'type', so the two stay in sync by
construction.
Fx: honour inheritedfromtable in attlen/attprecision editable checks
The Length/Precision and Scale grid-cell editors only checked
inheritedfrom when deciding whether to allow editing, so a column
loaded with only inheritedfromtable set (i.e. already inherited when
the table was opened) could be left incorrectly editable, unlike the
other inherited-column checks in this schema. Add a shared
isInheritedColumn() helper covering both fields and reuse it in both
editable callbacks and in inSchemaWithColumnCheck.
The Windows installer and the pip wheel were both shipping web/yarn.lock,
web/package.json and the rest of the JavaScript build configuration into
the installed tree, even though none of it is used at runtime: only the
webpack output under pgadmin/static/js/generated is. Because node_modules
is stripped, the lockfile that remains describes packages that are not
present, so vulnerability scanners run against an installation report the
whole build-time dependency tree, devDependencies included, as findings
that cannot be acted on and will recur on every release no matter how
current the versions are.
The Linux, macOS and Docker builds already removed these files, so this
brings Windows and pip into line with them, and adds webpack.* to the
Linux and macOS lists so that all of the packagers now strip the same
set. For pip the cleanup runs before syft, so the SBOM describes what
actually ships.
Verified by assembling a web tree with the cleanup applied and starting
pgAdmin from it: /misc/ping and /browser/ both return 200 and every
generated bundle is served.
The whitespace-only difference that CREATE OR REPLACE left in procedure
bodies was fixed in abf926428, so proc1 now settles once the generated
script has been applied and the test fails on the stale entry, exactly
as it is designed to: the list fails when something on it starts working
so that it cannot quietly rot. Take the entry off.
boto3, azure.*, googleapiclient and google_auth_oauthlib were imported at
the top of the AWS RDS, Azure and Google Cloud modules, so every pgAdmin
start paid for all three SDKs whether or not anyone went near the cloud
deployment wizards. Move those imports into the functions that need them,
leaving the blueprints, their menu entries and the exposed endpoint map
eagerly registered, which is the property that makes this viable where
the earlier lazy-blueprint idea was not; repeat calls cost nothing, since
sys.modules caches each module after the first import.
The modules with several call sites get a small _azure_sdk() or
_google_sdk() helper handing back the symbols in a namespace, whilst the
one and two site cases carry a plain inline import with a comment saying
why it sits there. The oauth2client sentinel stays at the top of the
Google module, where it still runs before anything can import
googleapiclient, and _google_sdk() now carries a note pointing back at it
so that a later tidy-up does not move it and quietly reintroduce #10110.
Deferring the imports moves the failure mode for a broken or missing SDK
from startup, where it is obvious, to the first click in a wizard, so
each affected path now fails cleanly rather than leaking a traceback into
the UI: the Google and Azure lookups return their usual (data, error)
tuple, or an empty list with the reason logged; Google.callback() reports
a missing google_auth_oauthlib as a verification error; and the RDS
client helper logs the ImportError and lets its callers answer with empty
metadata instead of a 500.
Also fixes a pre-existing bug in the Google instance_types endpoint,
which assigned the (instance_types, error) tuple returned by
Google.get_instance_types() to a single name and then called .get() on
it, raising AttributeError for anyone who got that far in the wizard.
Measured on Linux with a warm page cache, timing create_app() in desktop
mode over five runs and taking the median, this takes application startup
from around 1567 ms to around 1354 ms; on Windows the saving was reported
as larger, at around 2131 ms down to around 1515 ms.
Partially addresses #10221.
* fix(pkg): stop the Windows build overwriting the venv's pip
CREATE_VIRTUAL_ENV creates a virtualenv and then copies the whole of the
system Python's Lib over it, so that the relocated interpreter has a
standard library to use. That copy includes Lib\site-packages, and XCOPY
/Y overwrites the files that exist on both sides without removing the
ones that only exist in the venv, so the venv's freshly seeded pip ends
up spliced together with whatever pip C:\Python313 happens to have.
That was harmless while the two layouts agreed. pip 26.2 turned
pip/_internal/build_env from a module into a package, so on a builder
whose system pip predates 26.2 the new build_env/ directory survives the
copy whilst utils/misc.py is overwritten by the older one, and every
subsequent pip invocation dies before it does anything:
File "...\pip\_internal\build_env\installer.py", line 22, in <module>
from pip._internal.utils.misc import get_runnable_pip
ImportError: cannot import name 'get_runnable_pip' from
'pip._internal.utils.misc'
It cannot recover on its own either, since the next line of the script
is the pip invocation that upgrades pip.
Copy the standard library with ROBOCOPY and exclude site-packages, which
the venv is entitled to keep to itself. ROBOCOPY rather than XCOPY
because /XD takes a directory to skip and the existing
:CHECK_ROBOCOPY_ERROR helper already handles its exit codes; /R:3 /W:5
so a locked file fails the build rather than retrying for a million
attempts.
* fix(pkg): make the Windows build's ROBOCOPY failures diagnosable
Follow-up review fixes to the site-packages exclusion.
Exclude site-packages by name rather than by full path. The full path is
built from PGADMIN_PYTHON_DIR, so if that is ever set with a trailing
backslash the source and the /XD argument both gain a doubled separator;
should the match then fail, site-packages is copied over the venv again
and the build fails with the same ImportError this was meant to remove.
No directory inside the standard library is legitimately called
site-packages, so matching by name is both simpler and unconditional.
Drop "> nul" from both ROBOCOPY calls in favour of /NFL /NDL /NP.
ROBOCOPY writes its errors to stdout, so with the redirect in place a
copy that returns 8 or more aborted the build with nothing in the log to
say which file or share was at fault; the switches suppress the per-file
noise whilst keeping the header, the summary and any errors.
Guard the copy of the web directory with "|| EXIT /B 1" as well, since
EXIT /B inside :CHECK_ROBOCOPY_ERROR returns from the label rather than
from the caller and a failed copy was therefore ignored, leaving the
build to package an incomplete tree.
Guard virtualenv.exe likewise: with the standard library now copied by
ROBOCOPY, which creates its destination, a failure there would otherwise
surface later at the activate step instead of where it happened.
---------
Co-authored-by: Dave Page <dpage@pgadmin.org>
Covers the ten commits merged from master: the CSP per-request nonce,
the Object Explorer keyboard shortcut (folded into the existing #9631
entry, since it is the same feature reached another way), the Schema
Diff dependency ordering, function-body whitespace, foreign table and
sequence fixes surfaced by the now-asserting regression test, the
server-cursor prepare TypeError, the partitioned index TABLESPACE
clause, the UDF argument grid, the Helm existingSecret path and the
Validate binary path dialog.
Entries within each section are now ordered by issue number, with the
SHA-keyed ones last.
The endpoint returned a pre-built <b>/<br/> HTML string, and the frontend
switched to the escaping alertText() during the XSS hardening pass
(9e370d3cb), so the modal showed literal tags instead of formatted text.
Return structured {utility, version} data from the backend and compose
the modal content as React elements on the frontend, keeping the
untrusted binary version output routed through SafeMessage.
- Fall back to the not-found message on empty version output too
|| instead of ?? — an empty-string version from an unexpected
'--version' output shape should still show the fallback message.
Harden the default Content-Security-Policy so inline scripts run only via a
per-request nonce instead of a blanket 'unsafe-inline', and drop 'unsafe-eval'.
- Generate a per-request nonce (secrets.token_urlsafe) cached on flask.g so the
exact same value is emitted in templates and the CSP response header.
- Substitute a {nonce} placeholder in CONTENT_SECURITY_POLICY at runtime.
- Tag inline <script>/<style> tags, and set window.__webpack_nonce__ so webpack's
dynamically injected assets carry the nonce too.
- New default: script-src 'self' 'nonce-{nonce}' (no 'unsafe-inline'/'unsafe-eval').
- style-src keeps 'unsafe-inline': MUI/React inject un-nonced runtime styles and
inline style="" attributes that cannot be nonced.
- 'unsafe-eval' is not needed by production bundles; the dev ('eval' devtool)
bundles add it via config_local.py (documented in config.py).
Setting window.__webpack_nonce__ in HTML is a no-op: webpack only substitutes the __webpack_nonce__ identifier inside compiled modules, and MUI/emotion does not read it either (it requires an explicit createCache nonce). Same-origin webpack chunks are already allowed by script-src 'self', so the block did nothing. Remove it; the resourceBasePath inline script keeps its nonce.
Development bundles are built with webpack's 'eval' devtool, which the strict nonce policy blocks, forcing developers to manually add 'unsafe-eval' via config_local.py. Handle it automatically in get_content_security_policy() (which runs per request and therefore sees a DEBUG value overridden in config_local): when config.DEBUG is set and the policy uses a nonce, append 'unsafe-eval' to the script-src directive, without duplicating it. Production is unaffected and custom (non-nonce) policies pass through untouched.
Adds unit tests covering nonce generation/caching/per-request freshness, {nonce} substitution, pass-through of custom/None/empty policies, header emission, and the dev-mode behaviour (including the no-script-src edge case, exact script-src name matching, and no-duplication).
The dev-mode 'unsafe-eval' behaviour is now implemented, so these are ordinary regression tests. Remove the 'PLANNED'/'EXPECTED TO FAIL until impl' banners (which incorrectly told maintainers not to make the source pass) and an unused MagicMock import.