pgBackRest 2.69 commit log

v2.60.0: Page Checksum Errors and Object Store Prefetch

commit   : 2e1c4f2ba727c45a0a23d4b326905088dc15b1a1    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 18 Oct 2026 10:31:33 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 18 Oct 2026 10:31:33 +0200    

Click here for diff

IMPORTANT NOTE: The restore command will now error and stop if any restored file contains an invalid page checksum, which leaves the cluster incomplete and unable to start, so the restore must be rerun. The expire command will now refuse to expire if the oldest retained backup would contain checksum errors, which also prevents archive from being expired in that repository. Set checksum-page-error=n to warn instead of erroring.  
  
Features:  
  
* Add checksum-page-error option to error on invalid page checksums. (Contributed by Andrew Pogrebnoi. Reviewed by Douglas J Hunley, David Steele, Greg Sabino Mullane, Stefan Fercot. Suggested by Greg Sabino Mullane.)  
* Support for GCS Workload Identity Federation. (Contributed by Adol Rodriguez. Reviewed by Douglas J Hunley, David Steele.)  
* Prefetch multi-file/range reads from object stores. (Reviewed by Andrew Pogrebnoi, Douglas J Hunley.)  
  
Improvements:  
  
* Reduce keep-alive requests to remote processes. (Reviewed by Douglas J Hunley.)  
* Retry Azure batch sub-requests missing from the response. (Reviewed by Douglas J Hunley.)  
* Reduce the manifest checks made when reconstructing backup.info. (Reviewed by Douglas J Hunley. Suggested by todeb.)  
* Use original size to determine if a file should be bundled. (Reviewed by Douglas J Hunley.)  
* Add context to block map read errors during backup. (Reviewed by Stefan Fercot, Douglas J Hunley.)  
* Cast current_timestamp to text in the backup start label. (Contributed by Tiago Corcelli de Oliveira. Reviewed by David Steele.)  
* Support building on illumos. (Contributed by Kalevi Kolttonen. Reviewed by Douglas J Hunley, David Steele.)  
* Support OpenSSL 4. (Reviewed by Douglas J Hunley.)  
* Fix clang builds. (Reviewed by Douglas J Hunley.)  
* Migrate code generation from C to Python.  
  
Documentation Improvements:  
  
* Modernize the website. Special thanks to Jan for the insightful (and hilarious) logo. (Contributed by David Steele, Jan Wieremjewicz. Reviewed by Steve Atkins, Kai Wagner, Douglas J Hunley, Nikolaos Stamatopoulos, Matt Hudson, Stefan Fercot.)  
* Document the release schedule.  
* Migrate the documentation tools from C and Perl to Python.  
  
Test Suite Improvements:  
  
* Migrate the test harness from C and Perl to Python.  
* Add a Fil-C job to CI.  
* Add fuzz testing for the JSON, Ini, and Pack readers.  
* Allow test user names that adduser on Debian rejects. (Contributed by Stefan Fercot. Reviewed by David Steele.)  

M CONTRIBUTING.md
M README.md
M doc/resource/exe.cache
M doc/xml/auto/metric-coverage-report.auto.xml
M doc/xml/index.xml
M doc/xml/news.xml
M doc/xml/release/2020s/2026/2.59.0.xml
M doc/xml/release/2020s/2026/2.59.2.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M meson.build
M src/version.h

End the release note history at upstream integration in CI.

commit   : 1911f5f1744ff4bfee528d95e37a0a8d9b77d6b3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 18 Oct 2026 09:36:51 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 18 Oct 2026 09:36:51 +0200    

Click here for diff

The release notes are checked against git history that ends at the newest commit of HEAD that is also in integration, which leaves out commits made only on a release branch. CI has no local integration branch so the history ended at HEAD and included release branch commits. A release branch commit with the same subject as an integration commit then made the subject ambiguous.  
  
CI now fetches integration from the project repository into refs/remotes/upstream/integration. The history ends at the merge-base with the local integration branch or else with the upstream branch. The origin/integration branch of a fork is not used since it may be stale.  
  
The check that every commit is claimed still runs only when the local integration branch is related to HEAD.  

M .github/workflows/test-dist.yml
M .github/workflows/test-doc.yml
M doc/lib/command/render/release.py
M test/src/module/doc/command/render/release_test.py

Adjust keyword styling in light mode.

commit   : 28a1e3d6278992e0a347aab19248abdcddefd491    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 17 Oct 2026 23:10:17 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 17 Oct 2026 23:10:17 +0200    

Click here for diff

Inline keywords such as options, paths, and ids are now .9em rather than .8125em. In light mode their box uses a lighter background and border, which brings it closer to the contrast of dark mode.  
  
Use weight 500 for names and commands in light mode since 600 looked too heavy. Dark mode stays at 600.  

M doc/resource/html/default.css

Add release note details for v2.33 through v2.60.

commit   : bd8f99be00cd4b03a1b234bf7fbaa39f406c0c44    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 17 Oct 2026 20:53:47 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 17 Oct 2026 20:53:47 +0200    

Click here for diff

Every item in v2.33 through v2.60.0 other than development items now has a summary, and every commit of each release is claimed by an item. Commits that were not mentioned are grouped into development items. Missing and incorrect pull request and issue references were fixed by comparing against GitHub.  
  
When the local integration branch is related to HEAD, a release with details must claim every commit. A commit whose subject is not unique in a release is named with <commit id="..." subject="...">. Commits back-patched to a patch release are listed in <release-backpatch-list> and shown only on a development build. The commits of a patch release are read from its release/x.y.z tag.  
  
A development item can specify the release note of a later release with <release-note version="..." subject="...">. The release note lists the development commits in its details, and on a development build the development item links to the release note. A release in development can be specified by its final version, e.g. 2.61.0 for 2.61.0dev.  
  
Add the tester contributor role, which replaces the comments that recorded testers as reviewers.  
  
Disable scroll anchoring on the page so opening details does not scroll the page in Firefox.  

M doc/lib/command/doc.py
M doc/lib/command/render/release.py
M doc/resource/html/default.css
M doc/xml/release/2020s/2020/2.26.xml
M doc/xml/release/2020s/2020/2.28.xml
M doc/xml/release/2020s/2020/2.29.xml
M doc/xml/release/2020s/2020/2.31.xml
M doc/xml/release/2020s/2021/2.32.xml
M doc/xml/release/2020s/2021/2.33.xml
M doc/xml/release/2020s/2021/2.34.xml
M doc/xml/release/2020s/2021/2.35.xml
M doc/xml/release/2020s/2021/2.36.xml
M doc/xml/release/2020s/2022/2.37.xml
M doc/xml/release/2020s/2022/2.38.xml
M doc/xml/release/2020s/2022/2.39.xml
M doc/xml/release/2020s/2022/2.40.xml
M doc/xml/release/2020s/2022/2.41.xml
M doc/xml/release/2020s/2022/2.42.xml
M doc/xml/release/2020s/2022/2.43.xml
M doc/xml/release/2020s/2023/2.44.xml
M doc/xml/release/2020s/2023/2.45.xml
M doc/xml/release/2020s/2023/2.46.xml
M doc/xml/release/2020s/2023/2.47.xml
M doc/xml/release/2020s/2023/2.48.xml
M doc/xml/release/2020s/2023/2.49.xml
M doc/xml/release/2020s/2024/2.50.xml
M doc/xml/release/2020s/2024/2.51.xml
M doc/xml/release/2020s/2024/2.52.1.xml
M doc/xml/release/2020s/2024/2.52.xml
M doc/xml/release/2020s/2024/2.53.1.xml
M doc/xml/release/2020s/2024/2.53.xml
M doc/xml/release/2020s/2024/2.54.0.xml
M doc/xml/release/2020s/2024/2.54.1.xml
M doc/xml/release/2020s/2025/2.54.2.xml
M doc/xml/release/2020s/2025/2.55.0.xml
M doc/xml/release/2020s/2025/2.55.1.xml
M doc/xml/release/2020s/2025/2.56.0.xml
M doc/xml/release/2020s/2025/2.57.0.xml
M doc/xml/release/2020s/2026/2.58.0.xml
M doc/xml/release/2020s/2026/2.59.0.xml
M doc/xml/release/2020s/2026/2.59.1.xml
M doc/xml/release/2020s/2026/2.59.2.xml
M doc/xml/release/2020s/2026/2.59.3.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M test/src/module/doc/command/render/release_test.py

Bump CI container for PostgreSQL 19rc1.

commit   : e96d03cb380cda5b85f9daa1e801d8c7a1609b9c    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 16 Oct 2026 08:19:02 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 16 Oct 2026 08:19:02 +0200    

Click here for diff

M test/container.yaml

Fix incorrect file reference in comment.

commit   : 3ae42b9080df37caab5bd30cb0e5d530d3305ce1    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 16 Oct 2026 08:18:14 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 16 Oct 2026 08:18:14 +0200    

Click here for diff

M src/postgres/interface/version.vendor.h

Add expandable details to the release notes.

commit   : 050ca9e354421530ba4282d95dead90e31d6dc2a    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 18:41:57 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 18:41:57 +0200    

Click here for diff

Release items from 2.60 on put the paragraphs after the first and the links to their issues, pull requests, and commits in a details element that is closed by default. The toggle is drawn in place of the bullet: a plus when the details have text and a chevron when they have only links. An item with more than one commit has a line for each commit with its subject. A button on each release opens or closes all of its details. Older releases still join extra paragraphs into one line.  
  
The version pattern for bug fix releases also matched x.y.0, so the commits of a release reached back to v2.53, and 2.54.0 through 2.59.0 were only checked while each was the most recent release. A bug fix release is now x.y.z with z > 0, so a release keeps its commit links after a newer release is added. The 2.55.0 PostgreSQL 18 item now has a commit element because the note was reworded after the commit.  
  
The GCS Workload Identity Federation item now has a commit element because its text does not match the commit subject.  

M doc/lib/command/render/release.py
M doc/lib/command/render/render.py
M doc/resource/html/default.css
M doc/resource/html/default.js
M doc/xml/release/2020s/2025/2.55.0.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M test/src/module/doc/command/render/release_test.py

Generate git-history.cache from git instead of committing it.

commit   : a8c1a58116f783637cd33285688c2a568bd7ee0e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 11:48:34 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 11:48:34 +0200    

Click here for diff

The doc build now writes doc/resource/git-history.cache from git log when HEAD is not the newest commit in it. The file is ignored rather than committed, and release.py no longer updates it.  
  
The committed cache had subject fixes that release items matched against. Those items now have a commit element with the subject git reports, and the rendered release notes are unchanged.  
  
The doc and dist workflows fetch the full history since the release notes are checked against it.  

M .github/workflows/test-dist.yml
M .github/workflows/test-doc.yml
M doc/.gitignore
M doc/RELEASE.md
M doc/lib/command/doc.py
M doc/lib/command/render/release.py
M doc/release.py
D doc/resource/git-history.cache
M doc/xml/release/2010s/2018/2.01.xml
M doc/xml/release/2010s/2018/2.02.xml
M doc/xml/release/2010s/2018/2.05.xml
M doc/xml/release/2010s/2018/2.06.xml
M doc/xml/release/2010s/2019/2.08.xml
M doc/xml/release/2010s/2019/2.09.xml
M doc/xml/release/2010s/2019/2.12.xml
M doc/xml/release/2010s/2019/2.17.xml
M doc/xml/release/2020s/2021/2.35.xml
M doc/xml/release/2020s/2023/2.46.xml
M doc/xml/release/2020s/2024/2.50.xml
M doc/xml/release/2020s/2024/2.52.xml
M test/lib/command/lint/lint.py
M test/src/module/doc/command/doc_test.py
M test/src/module/doc/command/render/release_test.py

Add fuzz testing for the JSON, ini, and pack readers.

commit   : 4c65010e7d28ea68571732fb275d91266a97ad80    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 11:26:52 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 13 Oct 2026 11:26:52 +0200    

Click here for diff

Add libFuzzer targets for the JSON, ini, and pack readers in test/fuzz. Each target runs an input through the reader and discards the errors thrown for invalid input, while a failed assertion or a sanitizer report aborts. The targets are linked with the objects of the pgbackrest binary when the fuzz-engine meson option is set.  
  
test/fuzz/build.sh builds the targets and seed corpora in the environment that OSS-Fuzz defines, so an OSS-Fuzz project can call it if pgBackRest is accepted. Until then the fuzz workflow builds the targets with clang, AddressSanitizer, and UndefinedBehaviorSanitizer and runs each one for a fixed number of runs. The seed is derived from the commit hash and ASLR and corpus reloading are disabled, so a failed run can be reproduced.  
  
Fuzzing found or led to the bugs fixed in these commits:  
  
81704daaf Check the mem context stack bounds in production builds.  
4b8e3f438 Limit the depth of nested containers when reading JSON.  
b623071a2 Fix reads past the end of JSON strings ending in an escape.  
2b919843a Reject unexpected commas when reading JSON.  
1808295cc Reject \u0000 when reading JSON strings.  
b63357ee8 Accept hex digits in JSON \u escapes when skipping.  
6963016c7 Error on invalid field ids when reading packs.  
0966ebbb3 Check pack field sizes against the bytes remaining in a buffer.  
950c1e149 Error on zero-size pack fields.  

A .github/workflows/test-fuzz.yml
M .github/workflows/test.yml
M meson_options.txt
A test/fuzz/build.sh
A test/fuzz/corpus/ini/config.ini
A test/fuzz/corpus/ini/info.ini
A test/fuzz/corpus/json/object.json
A test/fuzz/corpus/json/value.json
A test/fuzz/fuzz.c
A test/fuzz/fuzz.h
A test/fuzz/ini.c
A test/fuzz/json.c
A test/fuzz/pack.c
M test/lib/command/code/format.py
M test/lib/command/lint/lint.py
M test/meson.build

Error on zero-size pack fields.

commit   : 950c1e149f0b54dd4bcbd49cbf9a529d750b24d9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 11:32:44 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 11:32:44 +0200    

Click here for diff

A pack written by pckWrite always ends with an end tag, so a nested pack is at least one byte. A zero-size pack field from corrupt data was read into an empty buffer with no allocation, which failed the buffer != NULL assertion in pckReadNewC() in debug builds. Production builds failed with unexpected EOF on the first read from the empty pack.  
  
Error with invalid pack size in pckReadPack() and pckReadPackReadConst() when the size of a pack field is zero.  

M src/common/type/pack.c
M test/src/module/common/type/packTest.c

Check pack field sizes against the bytes remaining in a buffer.

commit   : 0966ebbb3e59e17078157e501796b365c18824c3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 11:16:00 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 11:16:00 +0200    

Click here for diff

pckReadBin() and pckReadPack() allocate a buffer of the size stored for a field before reading the field. A pack read from a buffer with a corrupt size could allocate far more memory than the pack holds before failing with unexpected EOF.  
  
Error when the size of a field is larger than the bytes remaining in the buffer. A pack read through an IoRead still allocates the size stored for the field because the total size is not known.  

M src/common/type/pack.c
M test/src/module/common/type/packTest.c

Error on invalid field ids when reading packs.

commit   : 6963016c777004e2168c764e48ce08923c371aed    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:36:16 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:36:16 +0200    

Click here for diff

pckReadTagNext() cut the high-order bits of the field id delta down to an unsigned int and added it to the last id without checking the result. A large delta could produce UINT_MAX, the id that marks the end of a container, or wrap to an id at or below the last id. Either one left the read out of step with the tags, which asserted in debug builds and misread fields in production builds.  
  
Error when the id is not greater than the last id or is UINT_MAX.  

M src/common/type/pack.c
M test/src/module/common/type/packTest.c

Accept hex digits in JSON \u escapes when skipping.

commit   : b63357ee89d196c1f070f8124faca6c5338a7354    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:31:13 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:31:13 +0200    

Click here for diff

jsonReadSkipStr() required four decimal digits after \u, so a valid escape with hex digits such as \u003c failed validation even though jsonReadStr() decodes it. Accept hex digits so skipping agrees with reading.  
  
pgBackRest does not write \u escapes, so this only affects JSON from other sources. jsonReadStr() still decodes only \u00XX, as a single byte.  

M src/common/type/json.c
M test/src/module/common/type/jsonTest.c

Reject \u0000 when reading JSON strings.

commit   : 1808295ccdea8b9823886f7f721793239755ed9e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:27:52 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:27:52 +0200    

Click here for diff

jsonReadStr() decoded a \u0000 escape and appended a null character to the string. This asserted in debug builds and created a string with an embedded null in production builds, which is cut short anywhere the string is used as a zero-terminated string.  
  
Error on \u0000. A string cannot store a null character.  

M src/common/type/json.c
M test/src/module/common/type/jsonTest.c

Reject unexpected commas when reading JSON.

commit   : 2b919843ad2bc2a01b159f154ee0f2c3a78f59f7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:25:11 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:25:11 +0200    

Click here for diff

jsonReadTypeNextIgnoreComma() looks past a comma to find the type of the next value, but jsonReadPush() only consumed a comma between values in a container. A comma before a value outside a container, before the first value in a container, or before an object value was left in place. The string readers asserted on it in debug builds and read it as the opening quote of a string in production builds.  
  
Error on a comma in these positions.  

M src/common/type/json.c
M test/src/module/common/type/jsonTest.c

Fix reads past the end of JSON strings ending in an escape.

commit   : b623071a2a98ef52bb5d273fc3afa9fd3ddee3ca    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:10:20 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 10:10:20 +0200    

Click here for diff

jsonReadSkipStr() stepped past the terminating null when a backslash was the last character. It also advanced one character too far after a \uXXXX escape, which skipped the closing quote when the escape was the last character of the string and stepped past the terminating null when the escape was at the end of the input. jsonReadStr() did not check that both digits of a \u00XX escape were present, so a truncated escape at the end of the input was read past the terminating null.  
  
The skip path validates the JSON values in info files and manifests. The read path reads object store responses.  

M src/common/type/json.c
M test/src/module/common/type/jsonTest.c

Limit the depth of nested containers when reading JSON.

commit   : 4b8e3f4386e49899240e6cc8b2bd16fe251a26f3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:50:17 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:50:17 +0200    

Click here for diff

Each level of a nested array switches to a mem context in jsonReadVar() and each level of any container recurses, so deeply nested JSON could fill the mem context stack or exhaust the C stack. JSON is read from repository info files, manifests, lock files, and object store responses, which pgBackRest does not always control.  
  
Error when the depth exceeds 32. The JSON pgBackRest reads does not nest more than a few levels.  

M src/common/type/json.c
M test/src/module/common/type/jsonTest.c

Check the mem context stack bounds in production builds.

commit   : 81704daaf41882104b7545ece6905258c1bc23a7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:32:10 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:32:10 +0200    

Click here for diff

memContextNew() added to the mem context stack without checking for space. memContextSwitch() asserted on the current stack index, which can be lower than the index being written, and the assert only existed in debug builds. Filling the stack overwrote the memory after it. Nested JSON arrays in a repository info file or an object store response can fill the stack because each level of an array switches to a mem context.  
  
Check the max stack index in both functions with CHECK() so the error is also reported in production builds.  

M src/common/memContext.c
M test/src/module/common/memContextTest.c

Add UNLIKELY() macro for error checks.

commit   : 80d46a0bef893b6dc782a7ec73250c01c90febd6    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:20:47 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 12 Oct 2026 09:20:47 +0200    

Click here for diff

Add UNLIKELY(), based on __builtin_expect() when the compiler supports it, and use it in CHECK(), ASSERT(), and the THROW*_ON_SYS_ERROR*() macros. The condition in these macros is an error, so the compiler can lay out the code for the path where the condition is false.  
  
The ASSERT() redefinition in the test harness uses UNLIKELY() as well so modules that include the same code compile it with the same branches.  

M meson.build
M src/common/assert.h
M src/common/error/error.h
M src/common/macro.h
M test/src/harness/test.h

Cast current_timestamp to text in the backup start label.

commit   : 86936e1fa60b10b7592eaac09283c4bb5991265d    
  
author   : Tiago Corcelli de Oliveira <34511305+corcelli@users.noreply.github.com>    
date     : Sun, 11 Oct 2026 09:18:23 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 11 Oct 2026 09:18:23 +0200    

Click here for diff

A user-defined implicit cast from timestamptz to text makes the || operator in the backup label ambiguous and the backup fails with "operator is not unique: unknown || timestamp with time zone". Casting current_timestamp to text resolves the operator to text || text. The label text is unchanged.  

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/db/db.c
M test/src/harness/pq.h

Support building on illumos.

commit   : ad72e5815f3cf9c0c6170dcbe04ef9c41762d3d9    
  
author   : retrodood <kalevi@kolttonen.fi>    
date     : Fri, 9 Oct 2026 18:31:57 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 9 Oct 2026 18:31:57 +0300    

Click here for diff

The illumos headers hide POSIX and extension functions when compiling with -std=c11 unless a feature test macro is defined, which left kill(), sigaction(), gmtime_r(), localtime_r(), nanosleep(), and flock() undeclared.  
  
Define _POSIX_C_SOURCE=200809L and __EXTENSIONS__ in meson when the host system is sunos, as is done for Linux and MacOS. _POSIX_C_SOURCE alone does not expose flock(). Also link libsocket, which is separate from libc on illumos. Meson reports sunos for Solaris as well, but Solaris has not been tested.  
  
Add an OmniOS job to CI that runs selected unit tests and the smoke test against the oldest and newest available PostgreSQL versions.

A .github/workflows/test-illumos.yml
M .github/workflows/test.yml
M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M meson.build
M src/meson.build
M test/lib/command/test/build.py
M test/smoke.py
M test/src/module/test/command/test/build_test.py

Update release numbering in the release notes intro.

commit   : c179f3f7d6d6b1963d9d3b98b981786ef9546022    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 8 Oct 2026 19:02:09 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 8 Oct 2026 19:02:09 +0200    

Click here for diff

Release numbers have three parts: major, minor, and patch. Patch releases contain bug fixes for the most recent minor release.  

M doc/xml/release.xml

Document the release schedule.

commit   : 9bca69a591abcee0623ff910d0db3e27008cfc25    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 8 Oct 2026 18:51:30 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 8 Oct 2026 18:51:30 +0200    

Click here for diff

Feature releases are on the third Monday of Jan, Apr, Jul, and Oct. Patch releases are on the third Monday of any other month as required.  

M doc/xml/release.xml

Restore the archive.info save to backup.

commit   : 8429046135346c707f10a79573a5c201d324ac96    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 7 Oct 2026 15:27:00 +0200    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 7 Oct 2026 15:27:00 +0200    

Click here for diff

Archive key rotation moved the archive.info save from backup to expire, which changed when the archive.info timestamps are refreshed for object store lifecycle rules. With expire-auto disabled, or when expire errors before the save, the timestamps were no longer refreshed after a backup.  
  
Backup now saves archive.info as before. Expire loads archive.info only when repo-cipher-rotate is set and saves it only after a rotation.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/complete.c.inc
M src/command/expire/expire.c
M test/src/module/command/backupTest.c
M test/src/module/command/expireTest.c

Skip deploying pages that differ from the site only in the release date.

commit   : 5a78374f54b6e2721cdc1ec52e67df41a7e69441    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 7 Oct 2026 15:07:30 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 7 Oct 2026 15:07:30 +0200    

Click here for diff

A deploy previously replaced every page in the site and in the prior version path, so pages with no changes other than the date in the footer showed as changed. Pages that differ from the copy on the site only in the release date and year are now left as they are. This applies to the site and to the prior version path, so a text-only change deployed after a release updates only the affected pages in both.  

M doc/release.py

Make the repo-format and repo-cipher-rotate options internal.

commit   : 269764c5b783583c8b8591f7a863e92faa8e11b6    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 7 Oct 2026 13:58:08 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 7 Oct 2026 13:58:08 +0200    

Click here for diff

Repository format 6 and archive key rotation are not complete enough to release. Internal options are left out of the documentation and help but are still accepted, which allows format 6 to be tested with this release.  

M build/config.yaml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/config/parse.auto.c.inc

Add web-id key type for GCS Workload Identity Federation.

commit   : 1d217e626301118ae2c1e7be39a6a4affa86e501    
  
author   : Adol Rodriguez <adolsalamanca@gmail.com>    
date     : Tue, 6 Oct 2026 10:06:32 +0200    
  
committer: GitHub <noreply@github.com>    
date     : Tue, 6 Oct 2026 10:06:32 +0200    

Click here for diff

repo-gcs-key-type=web-id authenticates using an external account credential file (https://google.aip.dev/auth/4117) as generated by gcloud iam workload-identity-pools create-cred-config. The file is specified by repo-gcs-key and defines the audience, the STS token url, and the file that contains the web identity token. The web identity token is exchanged at the STS endpoint for an access token that authenticates the federated principal directly, without a service account key.  
  
The web identity token file is reloaded on each exchange to pick up rotated tokens, e.g. Kubernetes projected service account tokens.  
  
Service account impersonation (service_account_impersonation_url) is not supported. Only the file credential source is supported, and when credential_source.format is specified the type must be text.  
  
Authentication token responses that are not JSON (e.g. an HTML error page) or that contain a nested error object are now reported with httpRequestError() rather than failing while parsing the response. This applies to all GCS key types that request a token.

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M src/storage/gcs/storage.c
M src/storage/gcs/storage.h
M test/define.yaml
M test/src/module/storage/gcsTest.c

Merge v2.59.3 release.

commit   : 08fbb97b857deb0cffab933cb299f74c4fbe3736    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 6 Oct 2026 09:22:02 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 6 Oct 2026 09:22:02 +0200    

Click here for diff

M README.md
M doc/RELEASE.md
M doc/xml/auto/metric-coverage-report.auto.xml
M doc/xml/news.xml
M doc/xml/release.xml
A doc/xml/release/2020s/2026/2.59.3.xml
M doc/xml/release/2020s/2026/2.60.0.xml

Fix possible weak encryption subkeys/salts.

commit   : 0d2b88de32fa82f1b1d05243a0aa2e25a59548a3    
  
author   : j0uinim <54897948+j0uinim@users.noreply.github.com>    
date     : Fri, 2 Oct 2026 14:40:33 +0100    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 2 Oct 2026 14:40:33 +0100    

Click here for diff

The result of RAND_bytes() was ignored in cryptoRandomBytes(). When OpenSSL is unable to get random data from the operating system, RAND_bytes() fails and leaves the buffer unchanged, so a subkey was generated from uninitialized stack memory and a salt from data already in the output buffer. No error was reported and the repository was written with weak subkeys and salts.  
  
Throw a CryptoError when RAND_bytes() fails, as other OpenSSL failures do. Test the error with a RAND_bytes() shim in the test module.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/common/crypto/common.c
M test/src/module/common/cryptoTest.c

Check the result of EVP_BytesToKey() and HMAC().

commit   : 2a805e22b646902e7bd28d2493174fe5f3feb5cd    
  
author   : Stefan Fercot <pgstef@fercot.be>    
date     : Fri, 2 Oct 2026 15:10:22 +0200    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 2 Oct 2026 15:10:22 +0200    

Click here for diff

The results were ignored, so a failure would go unnoticed and leave the key and initialization vector or the HMAC uninitialized. Throw a CryptoError instead, as other OpenSSL failures do.  

M src/common/crypto/cipherBlock.c
M src/common/crypto/hash.c

Fix possible checksum error loading encrypted info and manifest files.

commit   : cb79ce7452a32662f16f846e0f77a790f707248a    
  
author   : j0uinim <54897948+j0uinim@users.noreply.github.com>    
date     : Fri, 2 Oct 2026 09:07:17 +0100    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 2 Oct 2026 09:07:17 +0100    

Click here for diff

A read that does not block stops as soon as it has output, and end of file is set on a later read that gets no output. When a filter needed the same input again the read did not stop, so end of file could be set while output remained in the internal buffer used by the line and small readers. A read that loops until end of file then lost whatever remained in that buffer.  
  
The block cipher filter needs the same input again on flush when the buffer has less room than the final block. A large encrypted info or manifest file could lose its last lines on load, which fails the checksum. This requires the lost lines to fit in the final block, so it is unlikely in practice. A filter that holds its output until the end of its input, such as the authenticated cipher proposed in #2866, would hit this on any file larger than the buffer.  
  
Stop a read that does not block as soon as it has output, whether or not a filter needs the same input again.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/common/io/read.c
M test/src/module/common/io/ioTest.c

Add archive key rotation for repository format 6.

commit   : 6e28c0445f2a6a3bac8d1cdbe673f87ea3aa3ddd    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 2 Oct 2026 08:37:45 +0200    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 2 Oct 2026 08:37:45 +0200    

Click here for diff

At format 6 the repository can hold more than one encryption key. This is used to rotate the archive key, which is currently the only bulk data key that never changes.  
  
Keys are stored by id in the new CipherSpecMap, with one key marked current for encrypting new files. At format 6 the cipher section of an info file stores every key by id along with the digest it derives with, e.g. cipher-pass={"1":{"digest":"sha256","key":"..."}}, and archive.info also records the current key id in cipher-pass-current. Format 5 files keep their single key, which is loaded as the key with no id.  
  
archive-push writes the id of the archive key it used into the header of each WAL file. The readers use that id to select the key: archive-get, verify, the restore timeline read, the backup archive copy, and repo-get. A pusher that loaded archive.info before a rotation keeps working since each file records the key it was encrypted with. The filter that writes the header now also works through a repository host.  
  
stanza-create for format 6 creates the first archive key with id 1. stanza-upgrade from format 5 keeps the existing archive key as id 0 so WAL already archived stays readable, and adds a key at id 1 for new WAL.  
  
The new repo-cipher-rotate option sets how old the current archive key may be before expire replaces it, with a default of 90 days and a minimum of 15. Expire adds the new key, makes it current, and records the time of the rotation in archive.info as cipher-pass-rotate. At format 5 the option has no effect and warns only when set explicitly.  
  
Expire now saves archive.info on every run. Backup did this only to refresh the timestamps for object store lifecycle rules and no longer writes archive.info, so with expire-auto disabled the timestamps are refreshed only when expire runs.

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/archive/get/file.c
M src/command/archive/get/file.h
M src/command/archive/get/get.c
M src/command/archive/get/protocol.c
M src/command/archive/push/file.c
M src/command/archive/push/file.h
M src/command/archive/push/protocol.c
M src/command/archive/push/push.c
M src/command/backup/backup.c
M src/command/backup/complete.c.inc
M src/command/expire/expire.c
M src/command/remote/remote.c
M src/command/repo/get.c
M src/command/restore/restore.c
M src/command/restore/timeline.c
M src/command/restore/timeline.h
M src/command/stanza/create.c
M src/command/stanza/upgrade.c
M src/command/verify/file.c
M src/command/verify/file.h
M src/command/verify/protocol.c
M src/command/verify/verify.c
M src/common/format/cipherBlockFormat.c
M src/common/format/cipherBlockFormat.h
A src/common/format/cipherSpecMap.c
A src/common/format/cipherSpecMap.h
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M src/info/info.c
M src/info/info.h
M src/info/infoArchive.c
M src/info/infoArchive.h
M src/info/infoBackup.c
M src/meson.build
M test/define.yaml
A test/spec-rekey.md
M test/src/harness/info.c
M test/src/module/command/archive/getTest.c
M test/src/module/command/archive/pushTest.c
M test/src/module/command/backupTest.c
M test/src/module/command/expireTest.c
M test/src/module/command/repoTest.c
M test/src/module/command/restoreTest.c
M test/src/module/command/stanzaTest.c
M test/src/module/command/verifyTest.c
M test/src/module/common/formatTest.c
M test/src/module/info/infoArchiveTest.c
M test/src/module/info/infoBackupTest.c
M test/src/module/info/infoPgTest.c
M test/src/module/info/infoTest.c
M test/src/module/info/manifestTest.c

Remove the default digest from CipherSpec.

commit   : 3b9f27c086577b62ad4e6f60daeb6755742da27c    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 30 Sep 2026 08:47:43 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 30 Sep 2026 08:47:43 +0300    

Click here for diff

CipherSpec defaulted to SHA-256 when no digest was provided, the default for repository format 6. To work around this, the repository passphrase explicitly set SHA-1, and the format filter ignored the digest on the spec entirely in favor of the digest for the format of the file. That made it hard to tell when the digest on a spec was authoritative.  
  
Now a spec with no digest determines it from the format of the file being read or written, and a digest set on the spec is always used. The block cipher and Info both assert that a digest has been set, so a missing digest is caught right away instead of quietly producing the wrong key.  
  
There is no change in behavior. Tests that use the block cipher directly now set the digest explicitly.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/archive/get/get.c
M src/command/archive/push/push.c
M src/command/restore/blockDelta.c
M src/command/restore/select.c.inc
M src/common/crypto/cipherBlock.c
M src/common/crypto/spec.c
M src/common/crypto/spec.h
M src/common/format/cipherBlockFormat.c
M src/config/config.c
M src/info/info.c
M test/src/harness/storage.c
M test/src/module/command/backupTest.c
M test/src/module/common/cryptoTest.c
M test/src/module/info/infoBackupTest.c
M test/src/module/info/infoPgTest.c
M test/src/module/info/infoTest.c
M test/src/module/info/manifestTest.c
M test/src/module/storage/remoteTest.c

Check that every code module is covered by a test module.

commit   : 9405fd92e21b18934696acaa45911637a485b8f9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 18:31:23 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 18:31:23 +0300    

Click here for diff

The linter now reports C source in src and python modules in the tool libraries that are not in a coverage list in test/define.yaml. Generated .auto.c.inc modules are exempt, and modules that no test fully covers are on a skip list: src/main.c, the prototype manifest command, and vendored xxHash.  
  
The mapping from a coverage entry to a file moves from the coverage command to define.py for use by the linter.  
  
The C coverage filter no longer matches a harness .c.inc under test/src/harness as the module with the same path in src.  

M test/lib/command/coverage/coverage.py
M test/lib/command/coverage/filter_c.py
M test/lib/command/lint/lint.py
M test/lib/command/test/define.py
M test/src/module/test/command/coverage/coverage_test.py
M test/src/module/test/command/coverage/filter_c_test.py
M test/src/module/test/command/lint/lint_test.py
M test/src/module/test/command/test/define_test.py

Add missing modules to unit test coverage.

commit   : 82b3f91b4b5c01c7c2cc70c4cfb399386050cc07    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 18:29:41 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 18:29:41 +0300    

Click here for diff

The info/manifest .c.inc modules, command/backup/resume.c.inc, and common/exec.c were not in any coverage list in test/define.yaml, so their coverage was never checked. The .c.inc modules were already fully covered.  
  
The exec harness includes common/exec.c but execOne() had no tests, so add tests for output, an error exit, and a signal.  

M test/define.yaml
M test/src/module/common/execTest.c

Fix compilation with OpenSSL 4.

commit   : a729ac881f48225622151841e80146f7a3a4bce6    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 09:39:58 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 28 Sep 2026 09:39:58 +0300    

Click here for diff

X509_get_subject_name() returns const X509_NAME * in OpenSSL 4. Make the subject name const and cast away the const for X509_NAME_get_index_by_NID(), which does not accept a const name before OpenSSL 3.  
  
OpenSSL 4 also renamed the SSLv3 alert reason strings, e.g. "sslv3 alert handshake failure" is now "tls alert handshake failure". Add the new message to the TLS tests.  

M src/common/io/tls/common.c
M test/src/module/common/io/tlsTest.c

Merge coding standards into the contributing guide.

commit   : 0b9d7f66dbf043a07ad2bf38e4a2d3b424c632d3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 18:08:51 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 18:08:51 +0300    

Click here for diff

The coding standards in CODING.md and the Coding section of CONTRIBUTING.md covered the same ground, e.g. objects were described in one while memory contexts and logging were described in the other. Move the standards into the Coding section of CONTRIBUTING.md, move the uncoverable/uncovered code markers into Writing a Unit Test, and remove CODING.md.  
  
Section depth is limited to three, so Naming is now a peer of Standards and Braces and Hints, Warnings, and Errors are now part of Standards.  

D CODING.md
M CONTRIBUTING.md
M doc/manifest.xml
D doc/xml/coding.xml
M doc/xml/contributing.xml

Allow test.py options before the command.

commit   : fe2c883c23f72f51cb031f70595f54bdcd810585    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 17:42:33 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 17:42:33 +0300    

Click here for diff

When a command is given, argparse parses the options of the command into a new namespace and copies every value, including defaults, over the values the main parser set from options before the command. As a result, `test.py --vm=u24 vm-build` ran vm-build with --vm=none.  
  
Move the command to the front of the argument list before parsing so the command parser parses every option. An option before the command that the command does not define is now an error instead of being ignored.  

M test/lib/config/cli.py
M test/src/module/test/config/cli_test.py

Merge v2.59.2 release.

commit   : 36afca46367a853e5b0c1604cf992610b3c24ba8    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 17:07:37 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 17:07:37 +0300    

Click here for diff

M README.md
M doc/xml/auto/metric-coverage-report.auto.xml
M doc/xml/news.xml
M doc/xml/release.xml
A doc/xml/release/2020s/2026/2.59.2.xml
M doc/xml/release/2020s/2026/2.60.0.xml

PostgreSQL 19beta4 support.

commit   : 3c821e6ba235b3dbca5ea21efda5fe9765d5b3a4    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 12:11:20 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 27 Sep 2026 12:11:20 +0300    

Click here for diff

Beta4 reverted online data checksum transitions, which removed the in-progress checksum states and the checkpoint checksum state and bumped PG_CONTROL_VERSION:  
  
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=c05d5ce12366f1008e95b09a0a9b08a49818b0e1  
  
A follow-up to the revert bumped XLOG_PAGE_MAGIC:  
  
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=4a9a6c5a69c45ba74c694ccb1ff5921dd09a6676  
  
Beta4 also added data_checksum_version_init to pg_control:  
  
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=aaf8b9989f7aaeb6594122caee1eea6c87e10655  
  
The page checksum version in pg_control is again limited to 0 and 1, so check it against PG_DATA_CHECKSUM_VERSION for all versions and remove the handling of the in-progress states added for PostgreSQL 19.  

M doc/xml/release/2020s/2026/2.60.0.xml
M src/postgres/interface.c
M src/postgres/interface/static.vendor.h
M src/postgres/interface/version.vendor.h
M test/container.yaml
M test/src/module/postgres/interfaceTest.c

Allow time option values greater than 49 days.

commit   : 95a14644f30e49867de8df5dfde2da3848bfe943    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 24 Sep 2026 11:19:15 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 24 Sep 2026 11:19:15 +0300    

Click here for diff

Time option values are stored in milliseconds but the table of time values in the generated parse rules was declared unsigned int, which limits a value to about 49.7 days. A default or allow-range bound larger than that overflows and fails to compile. The parser already reads time values, range bounds, and option values as int64_t, so declare the table as int64_t to match the table of size values.  
  
All existing time option values fit in either type, so none of them change.  

M build/lib/config/render.py
M src/config/parse.auto.c.inc
M test/src/module/build/config/render_test.py

Allow test user names that adduser on Debian rejects.

commit   : ce19dae6a2d6ab3665374ead04c270fd45d563fb    
  
author   : Stefan Fercot <pgstef@fercot.be>    
date     : Thu, 17 Sep 2026 09:59:27 +0200    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 17 Sep 2026 09:59:27 +0200    

Click here for diff

The test container is built with the same user name and id as the user on the host so files created inside it are owned by that user. Debian's adduser only accepts a name that matches NAME_REGEX, which excludes a dot, so vm-build failed on the Debian-based images for a host user named e.g. first.last.  
  
Pass the flag that skips the check, since the name comes from the account running the build rather than from outside. The flag was renamed from --force-badname to --allow-bad-names and Ubuntu 22.04 ships a version that only knows the old spelling, so the spelling is taken from adduser --help when the container is built rather than hardcoded.

M doc/xml/release/2020s/2026/2.60.0.xml
M test/lib/command/vm/build.py
M test/src/module/test/command/vm/build_test.py

Fix async archive-push stalling on a zero-size backup history file.

commit   : 0a6b047292e08a4abe4a0eee4fa8a5ba6d68ead2    
  
author   : Andrew Kroh <id-github@andrewkroh.com>    
date     : Wed, 16 Sep 2026 14:57:13 -0400    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 16 Sep 2026 14:57:13 -0400    

Click here for diff

The archive-push-batch-size option converts the batch size to a segment count using the size of the first file in the ready list, but that list also contains backup and timeline history files. PostgreSQL does not sync backup history files, so a cluster copied from a storage snapshot may contain one with zero size, which caused every asynchronous run to error and prevented any WAL from being archived.  
  
Use the size of the first ready WAL segment instead and skip the batch size when no segment is ready, since the remaining files are small. A zero-size WAL segment is still an error.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/command/archive/push/push.c
M test/src/module/command/archive/pushTest.c

Add checksum-page-error option to error on invalid page checksums.

commit   : d7f54b2245c8a9467e9a811c65626fe77739376b    
  
author   : Andrew Pogrebnoi <absourd.noise@gmail.com>    
date     : Tue, 15 Sep 2026 10:56:36 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Tue, 15 Sep 2026 10:56:36 +0300    

Click here for diff

The new checksum-page-error option determines whether invalid page checksums are reported as a warning or an error. It is available for the backup, restore, and expire commands.  
  
For backup the option defaults to false, which preserves the prior behavior of emitting a warning for each file that contains invalid page checksums. A warning with the total number of checksum warnings is now emitted at the end of the backup so they are less likely to be missed in the log. When the option is enabled the backup terminates with an error as soon as an invalid page checksum is found. Page misalignment is handled the same way since it prevents checksum validation.  
  
For restore the option defaults to true. When the option is enabled the restore terminates with an error as soon as a file with invalid page checksums is restored. Since this happens before pg_control is renamed, the cluster cannot be started. When the option is disabled a warning is emitted for each file that contains invalid page checksums and the total is reported at the end of the restore. Previously the restore command did not report invalid page checksums at all.  
  
For expire the option defaults to true. If the backup that would become the oldest retained backup contains invalid page checksums then expire terminates with an error before removing anything. When the option is disabled expire proceeds and emits a warning if the oldest retained backup contains invalid page checksums. An adhoc expire proceeds in either case but still emits the warning.

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/backup.c
M src/command/backup/process.c.inc
M src/command/expire/expire.c
M src/command/restore/process.c.inc
M src/command/restore/restore.c
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M test/define.yaml
M test/src/harness/backup.c
M test/src/module/command/backupTest.c
M test/src/module/command/expireTest.c
M test/src/module/command/helpTest.c
M test/src/module/command/restoreTest.c

Fix pagination of S3 version listing.

commit   : 917f27c7b3800ae8e5e8ace7b06ae6160e95b560    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 15 Sep 2026 10:33:08 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Tue, 15 Sep 2026 10:33:08 +0300    

Click here for diff

Listing with a repository target time uses the ListObjectVersions API but pagination read NextContinuationToken from the response, which is only returned by ListObjectsV2. A version listing truncated at the maximum key count failed with "unable to find child 'NextContinuationToken':0 in node 'ListVersionsResult'", so a repository with more than a thousand versions under a prefix could not be read at a target time.  
  
Paginate the version listing with NextKeyMarker and NextVersionIdMarker instead. NextVersionIdMarker is missing or empty when the listing is truncated at a common prefix, in which case the next page starts at the key after the key marker, which is what is needed.  
  
Build the query for each page from the base query so a marker set for one page is not reused for the next.  
  
The mock response used to test version listing pagination returned a continuation token rather than markers, so the error was not caught.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/storage/s3/storage.c
M test/src/module/storage/s3Test.c

Fix mem context in GCS service key parsing.

commit   : ce590e9d9be8ad6327886a97b9a80deda8e56e7c    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 14 Sep 2026 11:35:23 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 14 Sep 2026 11:35:23 +0300    

Click here for diff

The parsed key JSON, the file read buffer, and intermediate strings were allocated in the object context rather than a temp context, so they stayed allocated for the life of the storage object.  

M src/storage/gcs/storage.c

Derive the cipher key with SHA-256 at repository format 6.

commit   : 700722b8b577a69f46d2bf023b0e2b8ff71d88ea    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 11 Sep 2026 18:25:19 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 11 Sep 2026 18:25:19 +0300    

Click here for diff

Format 6 derives encryption keys from the passphrase with SHA-256 rather than SHA-1. Older repositories keep SHA-1, since the migration does not rewrite the files they already encrypted. The digest is picked when a passphrase is generated rather than at each use, so it is stored with the passphrase in CipherSpec and defaults to SHA-256.  
  
A passphrase outlives the format of the file it is stored in, so the format cannot decide how the key is derived. Info files at format 6 store cipher-digest beside cipher-pass, and a passphrase stored without one derives with SHA-1. stanza-upgrade writes back the digest the passphrase already had, so archives and backups from before the migration still load. Only a new passphrase gets SHA-256: the one for a stanza created at format 6, or for the manifest of a full backup written at it.  
  
The info file has the same problem in reverse. Its format sits inside the content that the passphrase decrypts, so a reader would have to decrypt the file to find out how to decrypt it. Format 6 puts a plaintext header in front instead: PGBR, the three-digit format, and a spare byte for later. It goes where the OpenSSL Salted__ magic used to go and is the same eight bytes, so the salt still starts in the same place. A file that starts with the magic came from format 5, the only format older than the header. The header format is checked before anything is decrypted, then compared against the format found in the content, so a file stitched together from two others is rejected rather than half read.  
  
The format constants move out of version.h into a new common/format, along with repoFormatValidate(), repoFormatDigest(), and CipherBlockFormat. That filter reads the header, builds a block cipher with the digest the format calls for, and streams the rest of the file through it. The block cipher no longer knows anything about repositories: it takes the digest from the spec and takes what sits in front of the content as a parameter.  
  
Verify checksums info files as stored rather than as decrypted, since the header has to come off before decryption can start. A file and its copy are written from the same bytes, so comparing the two still works. In an encrypted repository created by a Perl version the file and its copy are separate encryptions, so their checksums differ and verify reports it at detail level. A log line is better than a separate code path for repositories with seven year old backups.

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/backup.c
M src/command/backup/file.c.inc
M src/command/manifest/manifest.c
M src/command/remote/remote.c
M src/command/repo/get.c
M src/command/restore/blockDelta.c
M src/command/restore/file.c.inc
M src/command/stanza/common.c
M src/command/stanza/common.h
M src/command/stanza/create.c
M src/command/verify/verify.c
M src/common/crypto/cipherBlock.c
M src/common/crypto/cipherBlock.h
M src/common/crypto/common.c
A src/common/crypto/common.intern.h
M src/common/crypto/hash.c
M src/common/crypto/spec.c
M src/common/crypto/spec.h
A src/common/format/cipherBlockFormat.c
A src/common/format/cipherBlockFormat.h
A src/common/format/format.c
A src/common/format/format.h
M src/config/config.c
M src/info/info.c
M src/info/info.h
M src/info/infoArchive.c
M src/info/infoBackup.c
M src/info/manifest/serialize.c.inc
M src/meson.build
M src/version.h
M test/define.yaml
M test/src/harness/config.h
M test/src/harness/host.c
M test/src/harness/info.c
M test/src/harness/info.h
M test/src/harness/storage.c
M test/src/module/command/backupTest.c
M test/src/module/command/restoreTest.c
M test/src/module/command/stanzaTest.c
M test/src/module/command/verifyTest.c
M test/src/module/common/cryptoTest.c
A test/src/module/common/formatTest.c
M test/src/module/info/infoArchiveTest.c
M test/src/module/info/infoBackupTest.c
M test/src/module/info/infoPgTest.c
M test/src/module/info/infoTest.c
M test/src/module/info/manifestTest.c
M test/src/module/storage/remoteTest.c

Retry Azure batch sub-requests missing from the response.

commit   : 567a2451244e0e7a4c54e41880fac1a007b49930    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 11 Sep 2026 11:20:23 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Fri, 11 Sep 2026 11:20:23 +0300    

Click here for diff

Response parts were mapped to sub-requests by iterating the parts, so when the service returned fewer parts than were requested the remaining sub-requests were silently treated as complete. Azure fails the entire batch when it is unable to parse the request and reports the failure as a single response part, so only one file per batch was removed while the command reported success.  
  
Iterate the sub-requests instead and retry any that have no response part, just as parts that report an error are retried. More response parts than sub-requests is now an error.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/common/io/http/response.c
M src/storage/azure/storage.c
M test/src/module/common/io/httpTest.c
M test/src/module/storage/azureTest.c

Set the Azure version header for SAS authentication.

commit   : 777d69de1aa6fe74b5897962d8a185f1e09c22c2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 9 Sep 2026 18:00:36 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 9 Sep 2026 18:00:36 +0300    

Click here for diff

The version header was not set for SAS since a SAS specifies a version in the sv parameter. That is sufficient for normal requests but batch requires the version to be set explicitly and rejects the entire batch with 400 InvalidInput when it is missing.  
  
Azure reports the rejection as a single response part with no content-id, so it appeared that one sub-request had failed rather than that none had run. That part was retried individually and the remaining files in the batch were silently not deleted, leaving expired backups and WAL in the repository while expire reported success.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/storage/azure/storage.c
M test/src/module/storage/azureTest.c

Disable read retry when filters are passed to the driver.

commit   : 5440bedafc4a04eb3f7f8b5080a27c9a566a8135    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 3 Sep 2026 20:36:40 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 3 Sep 2026 20:36:40 +0300    

Click here for diff

When a read is retried the driver is freed and recreated but the filter group is not set on the new driver. For drivers that require the filter group (i.e. remote) this causes a segfault when the read is opened because the filter group is NULL.  
  
This was introduced in a953aa74b, which changed retry to recreate the driver rather than reopen it, added the filter group callback that is only set when the read is created, and replaced the driver retry flag with the storageFeatureReadRetry feature, which remote storage inherits from the repository host. Before that commit retry was never enabled for remote reads.  
  
Even with the filter group set the retry could not work because the filters are passed to the remote when the read is opened so they cannot be run again, and bytes read are counted after the filters have been applied so the offset to restart the read cannot be determined.  
  
Read retry is only enabled for object stores so this happened when the repository was on an object store accessed via a repository host. The repository host still retries the object store read, where the filters are applied above the driver and the retry works correctly, so the only retry lost is a redundant second attempt by the client.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/storage/read.c
M test/src/module/storage/remoteTest.c

Fix cvtDivToZ() rendering of a quotient less than one.

commit   : d2597a407a7a7d9a177fe9e4ad539c0c47fc4d78    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 3 Sep 2026 18:33:39 +0300    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 3 Sep 2026 18:33:39 +0300    

Click here for diff

The result was zero padded to the width of the precision, which left no room for the integer digit or for the extra digit added when rounding. A quotient less than one lost its leading zero, e.g. .87 rather than 0.87, and when the digits did not fill the precision the result was an empty string. With no precision at all every quotient less than one was empty, so 9/10 rendered as nothing rather than 1.  
  
Pad to the precision plus a digit for the integer part, and one more when the rounding digit was added. Output for a quotient of one or more is unchanged, so strSizeFormat() is not affected.  

M src/common/type/convert.c
M test/src/module/common/type/convertTest.c
M test/src/module/common/type/stringTest.c

Use original size to determine if a file should be bundled.

commit   : 340d58bb49d6c88a002a0d23830fd37be155cc21    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 3 Sep 2026 12:31:57 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 3 Sep 2026 12:31:57 +0300    

Click here for diff

The bundling decision and the process queue ordering used ManifestFile.size, which manifestBuildIncr sets to the prior file size when block incremental values are preserved. A file that grew since the prior backup was measured by its prior size, so a file well above repo-bundle-limit could be stored in a bundle and sorted with the small files rather than ahead of them.  
  
Use sizeOriginal instead since that is the size found during manifest build and therefore the number of bytes that will be copied. This matches the rest of backup processing, which already uses sizeOriginal for the copy limit, the bundle size accumulator, and progress.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/process.c.inc
M test/src/module/command/backupTest.c

Fix empty block map when file is truncated to zero during backup.

commit   : 8687b12560ada75dc2665ab4656acd0710be11e7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 3 Sep 2026 11:04:01 +0300    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 3 Sep 2026 11:04:01 +0300    

Click here for diff

A block incremental file truncated to zero by PostgreSQL during the backup was stored as a block map with no blocks, i.e. a single byte containing only the version flag. Every subsequent backup that referenced the file failed with "unexpected eof" while reading the map and continued to fail until the next full backup.  
  
Zero-length files detected during the copy were already stored as truncated rather than copied, but only when the file was in a bundle. Files larger than repo-bundle-limit get their own repository file and so missed the check.  
  
Never write a block map with no blocks and store zero-length block incremental files as truncated whether or not they are bundled.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/command/backup/blockIncr.c
M src/command/backup/file.c.inc
M test/src/module/command/backupTest.c

Use the Fil-C distribution as-is now that it ships every library needed.

commit   : 908a11a841e85d4e66e4aa551380b52aabfe606f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 22 Aug 2026 10:39:44 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 22 Aug 2026 10:39:44 +0700    

Click here for diff

Fil-C 0.684 adds libxml2 and libpq to the /opt/fil distribution, so neither has to be built here, and it fixes the bug where a volatile local declared without an initializer lost its value across longjmp, so the test source no longer has to be patched.  
  
That leaves nothing to build, only the distribution to download, which is what the container registry cache was holding anyway. Caching it saved the libxml2 and libpq builds rather than the transfer, so the cache, the registry login, and the write permission on packages all go with them.  
  
The job is no longer informational since it passes.  

M .github/workflows/test-compile.yml
M .github/workflows/test.yml

General framework for new repository formats.

commit   : e50b562de79356861059c5e25afb1661770dd7ce    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 21 Aug 2026 19:29:06 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 21 Aug 2026 19:29:06 +0700    

Click here for diff

The repository format has been fixed at 5 since 1.00. It was a compile-time constant written into every info file and manifest, and any mismatch at load was a hard error. A version could only read the format it wrote, so introducing a format meant a flag day where every repository had to be recreated. 1.00 was that flag day and this branch is so the next format does not need one.  
  
A file is readable when its format is between REPOSITORY_FORMAT_MIN and REPOSITORY_FORMAT_MAX. A version can read a format it does not write and a repository can hold both while older backups and archives expire.  
  
The format is requested with repo-format, which is repo indexed and valid only for stanza-create and stanza-upgrade. It is command line only so a format left in the configuration cannot migrate a stanza as a side effect of an upgrade run for another reason, such as a PostgreSQL version upgrade. The default stays 5.  
  
stanza-upgrade --repo1-format=6 rewrites the two info files and nothing else, so a repository of any size migrates in the time it takes to write them. A downgrade is refused, since the info files are what stop a version from reading a format it does not support. The two files are saved separately, so an upgrade interrupted between them leaves a mismatch that running the upgrade again repairs and that every command reading both files now reports.  
  
A backup set adopts a new format only at a full backup. A prior backup is a candidate for diff or incr only if it is at the format new backups are written with, and a resumable backup at another format is discarded, so a backup set is never mixed and its format can be read once rather than per file. Migrating therefore costs one full backup per stanza.  
  
Once the info files are at format 6 a version that supports only format 5 cannot read the stanza at all, including the backups still at format 5. Such a version fails at info load with "expected format 5 but found 6" before touching any data.  

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/backup.c
M src/command/backup/incr.c.inc
M src/command/backup/resume.c.inc
M src/command/check/check.c
M src/command/check/common.c
M src/command/check/common.h
M src/command/stanza/create.c
M src/command/stanza/upgrade.c
M src/command/verify/verify.c
M src/common/crypto/spec.h
M src/config/config.auto.h
M src/config/config.h
M src/config/parse.auto.c.inc
M src/info/info.c
M src/info/info.h
M src/info/infoArchive.c
M src/info/infoArchive.h
M src/info/infoBackup.c
M src/info/infoBackup.h
M src/info/infoPg.c
M src/info/infoPg.h
M src/info/manifest/manifest.c
M src/info/manifest/manifest.h
M src/version.h
M test/src/harness/info.c
M test/src/harness/info.h
M test/src/module/command/backupTest.c
M test/src/module/command/checkTest.c
M test/src/module/command/infoTest.c
M test/src/module/command/repoTest.c
M test/src/module/command/restoreTest.c
M test/src/module/command/stanzaTest.c
M test/src/module/command/verifyTest.c
M test/src/module/common/cryptoTest.c
M test/src/module/config/parseTest.c
M test/src/module/info/infoArchiveTest.c
M test/src/module/info/infoBackupTest.c
M test/src/module/info/infoPgTest.c
M test/src/module/info/infoTest.c
M test/src/module/info/manifestTest.c
M test/src/module/integration/allTest.c
M test/src/module/performance/typeTest.c

Move the test server port range below the ephemeral port range.

commit   : bd22b6809d308265e603ddd96f51559338fa90e2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 18:48:59 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 18:48:59 +0700    

Click here for diff

The ports assigned to the tests started at 34343, which is inside the ephemeral port range on Linux (32768-60999). A test that binds a port in that range can fail when the port is already in use as the source port of an outgoing connection, since SO_REUSEADDR only allows the bind when the conflicting socket is in TIME_WAIT.  
  
Assign the test ports from below the ephemeral port range instead. No module requests more than eight ports, so reducing the maximum per test from 768 to 64 leaves ample headroom and allows the range for all 32 tests to fit in 30720-32767, i.e. ending exactly where the ephemeral port range begins.  

M test/src/harness/server.c
M test/src/harness/server.h

Use a bogus test port outside the ephemeral port range.

commit   : b6de9b4241a5977967bde592e2e6316785188653    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 18:40:00 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 18:40:00 +0700    

Click here for diff

The bogus port was 34342, which is inside the ephemeral port range on Linux (32768-60999). A connection to that port can be assigned the same port as its source, which results in a TCP simultaneous open where the socket connects to itself and the connection succeeds rather than being refused. Since the socket client retries for the duration of the connect timeout each open makes many attempts, so the tests that expect a connection to be refused failed regularly.  
  
Move the bogus port below the ephemeral port range where the kernel will never assign it, and build the expected error messages from the macro so the port only needs to be defined in one place.  

M test/src/harness/server.h
M test/src/module/common/io/httpTest.c
M test/src/module/common/io/tlsTest.c

Remove unused arg_unity variable.

commit   : 83f90304294883ef93af802a1a1820337b71c07d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:40:37 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:40:37 +0700    

Click here for diff

4fb8a0ec passed arg_unity as c_args to the build-code and doc executables so a unity build of those would not fail on unused functions. 59bd2cd9 rewrote both in Python and removed the targets, leaving the variable assigned and never read.  
  
The lint job passes -Wno-unused-function on the command line where it is needed, so nothing has to take its place.  

M src/meson.build

Rename the unity job to lint and widen what it checks.

commit   : db7dfffbb445eaa697fdf71e5883f69c102bca63    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:35:57 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:35:57 +0700    

Click here for diff

Unity is how two of the checks are built rather than what any of them look for, and the job had already grown a check that does not need a unity build at all. lint describes the whole set and pairs with the code-format job below it.  
  
Drop debug from the unused parameter build. Asserts are compiled out of a release build, so a parameter that only an assert reads is reported along with a parameter that nothing reads at all. Both are marked the same way, with a void cast that shows ignoring the parameter is intended.  
  
Add libssh2 so the SFTP driver is built. Without it HAVE_LIBSSH2 is undefined and the driver is preprocessed away, so none of these checks were looking at it.  

M .github/workflows/test-misc.yml

Do not log parameters that only an assert reads.

commit   : 8ab8411d7a3934f3fbbe8ad6bfa23b4d4d02af47    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:12:25 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 16:12:25 +0700    

Click here for diff

The assert is the only thing that touches these parameters, so once it is compiled out of a release build nothing references them at all. Replace the log entry with a void cast, which keeps the assert doing the real work and drops a log entry that never varies.  
  
storageSftpRemove() param: ASSERT(!param.errorOnMissing) rejects an option the driver does not implement.  
  
dbOpenProtocol(), storageRemoteFeatureProtocol(), storageRemoteReadProtocol(), storageRemoteReadMultiProtocol(), and storageRemoteWriteCloseProtocol() param: ASSERT(param == NULL) verifies the client sent no parameters for a command that takes none, so logging param: null adds nothing.  
  
ioSinkProcess() this, input, and output: the sink discards everything and reads none of them. ioFilterProcessInOut() already checks this and output before the call, but ASSERT(input != NULL) records that the sink is never invoked with a NULL input, since ioFilterDone() reports done as soon as flushing begins for a filter with no done or inputSame function.  
  
storagePosixInfo(), storagePosixLinkCreate(), storagePosixPathSync(), and storagePosixRemove() this: read only by ASSERT(this != NULL). objNameToLog() renders the object as a constant {StoragePosix}, and the function name in the same trace line already says which driver is running, so the entry carried nothing.  

M src/common/io/filter/sink.c
M src/db/protocol.c
M src/storage/posix/storage.c
M src/storage/remote/protocol.c
M src/storage/sftp/storage.c

Remove parameters used only by an assert.

commit   : d22d0f61f668430b2ef0570a1c0140ff63da5b2d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:55:50 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:55:50 +0700    

Click here for diff

Follow up to 07f1cd6c, which did not find these because an assert counts as a use in a debug build.  
  
pgControlCrcUpdate() pgVersion: selected the CRC algorithm. 48f511da removed the pgVersion > PG_VERSION_94 test along with support for 9.4, leaving only ASSERT(pgVersion != 0).  
  
backupBuildIncr() infoBackup: supplied the cipher passphrase via infoPgCipherPass(infoBackupPg(infoBackup)). b9b58a17 replaced that when it started passing a cipher spec, leaving only ASSERT(infoBackup != NULL).  

M src/command/backup/backup.c
M src/command/backup/incr.c.inc
M src/postgres/interface.c

Add a unity build check for unused parameters.

commit   : 5e6c2093988772b6905e2ce16d70a42bd319ef05    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:40:11 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:40:11 +0700    

Click here for diff

The FUNCTION_LOG_PARAM*() macros reference each parameter they log, so the compiler counts that as a use and cannot report parameters the function never touches. Append overrides to debug.h that compile the macros out and build again. Nothing is lost by dropping the parameter logging since this build is never run.  
  
Enable debug so asserts are not compiled out, which would report parameters used only by an assert, and disable unused function warnings since the *ToLog() functions have nothing left to call them.  
  
c4fc1b5c and 07f1cd6c cleaned up everything this reports.  

M .github/workflows/test-misc.yml

Remove unused function parameters.

commit   : 07f1cd6cdf20ad7ccf7305be65be9fe13a007c52    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:25:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 14:25:22 +0700    

Click here for diff

Each of these was left behind when the code that used it was changed or removed. The compiler does not report them because the FUNCTION_LOG_PARAM*() macros count as a use.  
  
* backupFilePut() backupData: never used, present since 1f2ce45e added the function.  
  
* backupJobResult() bundle: decided whether the job key held a bundle id. efc09db7 replaced that with a check on the key variant type.  
  
* restoreRecoveryWriteConf() manifest: found the pgdata path to get the owner for chown(). 3ff88ffb removed the chown().  
  
* restoreRecoveryWriteAutoConf() manifest: read backupOptionOnline to decide whether to write recovery.signal. 9cceed6a replaced that condition.  
  
* exitSafe() signalType: built the "terminated on signal [SIGxxx]" message for command end. e13c4629 moved that logging into the signal handler.  
  
* cmdEnd() code: selected the "completed successfully" message at command end. 6361a061 switched the test to errorMessage == NULL.  
  
* infoPgSet() type: set the catalog/control versions and validated the type. 0a96764c removed both. 77c721eb restored the catalog version logic using this->type, which the object already stores from infoPgNew(), so the parameter stayed unused.  
  
* walSegmentNext() pgVersion: skipped the FF segment for PostgreSQL < 9.3. 77c721eb removed that along with support for 9.0/9.1/9.2. walSegmentRange() and queueNeed() passed pgVersion only to reach walSegmentNext(), so remove it from those as well.  
  
* tlsSessionResultProcess() errorSys: passed errno to THROW_SYS_ERROR_CODE() for syscall errors. 08242ee6 removed that throw. The errno read there was not informative (usually 0), and the capture in tlsSessionResult() goes with it.  
  
* storageWriteSftpNew() user, group, timeModified, truncate: passed to the write interface initializer. a953aa74 replaced the per-driver interface with a shared static, so the driver stopped passing them. storageSftpNewWrite() is the only caller and asserts that user and group are NULL, timeModified is 0, and truncate is true, so the values were fixed.  
  
Also remove the common/user.h include from the SFTP write driver, unused since 87087fac added it.  

M src/command/archive/common.c
M src/command/archive/common.h
M src/command/archive/get/get.c
M src/command/backup/db.c.inc
M src/command/backup/process.c.inc
M src/command/command.c
M src/command/command.h
M src/command/exit.c
M src/command/exit.h
M src/command/restore/config.c.inc
M src/command/verify/verify.c
M src/common/io/tls/session.c
M src/info/infoArchive.c
M src/info/infoBackup.c
M src/info/infoPg.c
M src/info/infoPg.h
M src/main.c
M src/storage/sftp/storage.c
M src/storage/sftp/write.c
M src/storage/sftp/write.h
M test/src/module/command/archive/commonTest.c
M test/src/module/command/archive/getTest.c
M test/src/module/command/backupTest.c
M test/src/module/command/commandTest.c
M test/src/module/command/exitTest.c
M test/src/module/command/restoreTest.c
M test/src/module/command/serverTest.c
M test/src/module/common/io/tlsTest.c
M test/src/module/info/infoPgTest.c

Clear archive async errors from the correct spool queue.

commit   : ad4a1a5b9badcbc036f74d9a49c944c65a2ae53f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 13:40:08 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 13:40:08 +0700    

Click here for diff

archiveAsyncErrorClear() ignored archiveMode and always removed status files from the out queue, so the call from archive-get named files in the push queue rather than its own.  
  
There is no user visible change. archive_mode=always is rejected by checkDbConfig() so archive-push and archive-get do not run on the same host, and the get queue is cleared by queueNeed() just before the call in any case.  
  
Use archiveAsyncSpoolQueue() as archiveAsyncStatus() and archiveAsyncStatusErrorWrite() do.  

M src/command/archive/common.c
M test/src/module/command/archive/commonTest.c

Cast unused interface parameters to void rather than logging them.

commit   : c4fc1b5cf8c5defa60dbe4db687a85692f8fde88    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 13:15:30 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 13:15:30 +0700    

Click here for diff

The read drivers ignore block because ioReadInternal() loops until the output buffer is full and stops early itself when block is false. Object store path removal ignores recurse because there are no paths to recurse into.  
  
Logging these parameters implies they have an effect on the function. Cast them to void instead, which is the pattern already used for the unused param structs in the storage drivers, and which allows a build with FUNCTION_LOG_PARAM*() compiled out to report parameters that no code touches.  

M src/common/io/bufferRead.c
M src/common/io/http/response.c
M src/common/io/limitRead.c
M src/storage/azure/read.c
M src/storage/azure/storage.c
M src/storage/gcs/read.c
M src/storage/gcs/storage.c
M src/storage/posix/read.c
M src/storage/readMulti.c
M src/storage/remote/read.c
M src/storage/remote/readMulti.c
M src/storage/s3/read.c
M src/storage/s3/storage.c
M src/storage/sftp/read.c

Fix option name in block incremental map errors.

commit   : f46bfafdd53dd0031a320fa95b08568263901ff3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 12:27:13 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 12:27:13 +0700    

Click here for diff

The error for an invalid value in a block incremental map named the option with cfgParseOptionKeyIdxName(), which expects a key index, but the callers passed a group index. A repository configured as anything but repo1 was named as repo1. Use cfgOptionIdxName() instead, which takes the group index and maps it to the key.  
  
The errors for the checksum size map also named the size map option since that option id was passed by mistake.  
  
These options are internal so neither error was user-facing.  

M src/command/backup/option.c.inc
M test/src/module/command/backupTest.c

Name options with an index when not valid for the command.

commit   : 3aa53ba24c7d4274f855f09734a5f191d8046845    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 12:17:36 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 20 Aug 2026 12:17:36 +0700    

Click here for diff

cfgOptionIdxName() read the option group from the config, which only stores it for options that are valid for the current command. An option that was not valid got the name without an index, and an index above the first failed an assertion.  
  
Read the group from the parse rules instead since they are not specific to a command.  
  
cfgExecParam() needs this because it loops through the options valid for the command being executed, which is not always the command being run. It also read the index total from the config, so for an option that was not valid only the first index was searched and a replacement for any other index was missed.  
  
This is not a live issue since every indexed option of the commands passed to cfgExecParam() is also valid for the command being run.  

M src/config/config.c
M src/config/exec.c
M src/config/parse.c
M src/config/parse.h
M test/src/module/config/execTest.c
M test/src/module/config/parseTest.c

Work around a slow apt mirror on the runner.

commit   : 1849fb1bfe5798f1d0ff5934d08332f111111ec1    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 12:26:48 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 12:26:48 +0700    

Click here for diff

The runner mirrorlist puts azure.archive.ubuntu.com ahead of archive.ubuntu.com and it has been serving at a crawl. apt abandons a mirror that has stopped sending but not one that is merely slow, so apt-get update either takes tens of minutes or hangs until the workflow timeout.  
  
Add an action that drops that mirror, bounds the connection level waits, and refreshes the index under a total time bound with retries, which is the part apt has no setting for. The settings are written to /etc/apt/apt.conf.d so the installs that follow pick them up as well.  
  
Use it in every job that installs packages on the runner, including the ones that reach apt through ci.py. The refresh moves out of ci.py since rewriting the mirror list the runner image ships is about the runner rather than about the run. It was best effort there and is now fatal after three bounded attempts, since an index that cannot be refreshed means the mirror the install downloads from is unusable as well.  
  
The jobs that do their work in containers use the package sources of the image and are not affected.  

A .github/action/apt/action.yml
M .github/workflows/test-arch.yml
M .github/workflows/test-compile.yml
M .github/workflows/test-dist.yml
M .github/workflows/test-doc.yml
M .github/workflows/test-linux.yml
M .github/workflows/test-misc.yml
M test/ci.py

Add context to block map read errors during backup.

commit   : bc91688c4cf18fa6a36ec65441784533d5e6bdd5    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 11:53:36 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 19 Aug 2026 11:53:36 +0700    

Click here for diff

A block incremental file stores its block map at the end of its data and the location of the map is recorded in the manifest, so when the data at that location is not a complete map the error comes from the map parser, which knows nothing about where the map came from. In #2829 that leaves the user with "unexpected eof" and no indication of which file failed, where the map was expected to be, or whether the data is short or simply not a map. The same signature in #2489 could not be diagnosed for the same reason.  
  
Rethrow errors from the read and the parse with the manifest file, repository path, offset and map size. Also measure the bytes read from the repository, before decryption, and error when they are fewer than the manifest expects, since a short read would otherwise surface as a parse error that gives no hint that data is missing. This does not address whatever causes the mismatch, but it makes the next report actionable.  
  
Move the read into backupFileBlockIncr() to keep the copy loop readable. The map is read in its own memory context with the filter created in the prior context, so the map buffer is freed once the filter has parsed it rather than living until the file is done.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/file.c.inc
M test/src/module/command/backupTest.c

Add a Fil-C job to the compile workflow.

commit   : 12396fb353bc4aa10c3739b826189e421732adf5    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 09:57:55 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 09:57:55 +0700    

Click here for diff

Fil-C is a memory-safe implementation of C where pointers are checked on every access, so an out-of-bounds access, a use-after-free, or a type confusion panics instead of doing damage. It found two out-of-bounds reads that gcc, clang and valgrind missed.  
  
Linking to code that was not built with Fil-C is severely restricted, so the /opt/fil distribution is used for the libraries it ships and libxml2 and libpq are built here with the same compiler. That takes minutes and produces the same thing every time, so the prefix is pushed to a container registry rather than actions/cache, which is not branch-scoped and so is shared across the short-lived branches this project works in, as the ccache already is. It is saved before the tests run so a failing test does not stop it being cached and a failed build is never cached.  
  
Two things have to be turned off for libpq. Its exports list names the plain symbols but Fil-C emits pizlonated_-prefixed ones, so the version script hides every entry point, and -fvisibility=hidden hides the prefixed symbols as well because they do not inherit the default visibility PGDLLEXPORT puts on the declaration.  
  
--gcc-toolchain keeps the host gcc out of the search path so a library that was not built with Fil-C cannot be picked up, and PKG_CONFIG_LIBDIR does the same for pkg-config. It also keeps meson from finding the libbacktrace gcc ships, which matters because the Fil-C runtime does not implement _Unwind_Backtrace. TZDIR is set because the glibc in /opt/fil looks for the timezone database under its own prefix, where there is none, and falls back to UTC without saying so.  
  
The too large allocation test is skipped since Fil-C either satisfies an allocation or treats it as a hard error, so malloc() never returns NULL. A volatile local declared without an initializer loses its value across longjmp, which is a Fil-C bug reported upstream, so that declaration is patched in the job rather than in the test.  

M .github/workflows/test-compile.yml
M .github/workflows/test.yml
M test/src/module/common/memContextTest.c

Store the file owner as an index rather than a pointer in the pack.

commit   : 35fe00e3147db55e8174207cc041369ac94c11c9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 09:24:40 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 19 Aug 2026 09:24:40 +0700    

Click here for diff

The packed file stored user and group by casting the interned String pointer to an integer and casting it back on unpack, spending five or six varint bytes per file to name one of a handful of values. The reference field in the same function already stores an index into referenceList with strLstFindIdxP() and reads it back with strLstGet(), so owner now does the same.  
  
That requires the owner list to only ever grow at the end, which reference already depends on. Nothing sorts it or removes from it, so an owner seen for the first time cannot disturb files already packed.  
  
The performance test gave every file the same owner, which made it the default so no owner was ever encoded and this code was never reached. It now spreads a few owners with one repeated, leaving that one as the default. Timings are unchanged.  

M src/info/manifest/store.c.inc
M test/src/module/performance/typeTest.c

Add a workflow to compile and test on the runner without a container.

commit   : f32a5d5543c4dc547006629bdb2dd52d3edb9205    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 22:11:54 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 22:11:54 +0700    

Click here for diff

The container jobs cover what pgBackRest is normally built with, so what is tested here is whether it builds and runs somewhere else. gcc is the baseline and clang is a second opinion from a different compiler, and each runs with the optional libraries present and absent, which catches code that only compiles when a feature is enabled.  
  
The optional libraries are removed rather than just left out of the install, and what pkg-config reports is checked afterwards, since the runner image is not a fixed set of packages and one of them reappearing would quietly turn the minimal variation into a second full one.  

A .github/workflows/test-compile.yml
M .github/workflows/test.yml

Fix tool tests that depend on the environment they run in.

commit   : 95e2051c46dfa0dd9c28ca01c1416738b2df3842    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 22:01:29 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 22:01:29 +0700    

Click here for diff

The doc host and vm build tests read GITHUB_REPOSITORY_OWNER to decide which fork cache is in play, so on a fork they took a path they were not written for and failed. This never showed up because the tool tests only ever ran in a container, where docker exec does not pass the environment through.  
  
Start these tests as if the environment set nothing, which leaves the cases that set the owner themselves in control of it.  

M test/src/module/doc/command/render/host_test.py
M test/src/module/test/command/vm/build_test.py

Fix conversions in tests that clang reports as errors.

commit   : df1491ef0f7ef962491e614ecca1f162ee022e7f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:53:28 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:53:28 +0700    

Click here for diff

cipherBlock->mode is an enum of StringId values so compare it as unsigned, as the test two lines above already does for cipherSpecType(). Declare invalidLinkType as the enum it is assigned to rather than int, which keeps it an invalid value for that enum while no longer changing signedness.  

M test/src/module/common/cryptoTest.c
M test/src/module/storage/posixTest.c

Return bool from a function declared to return bool.

commit   : d9e5e14e93f460d62930f58dd4dd5bbc2a9f3a85    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:50:43 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:50:43 +0700    

Click here for diff

jsonTypeContainer() is declared to return bool but returned with FUNCTION_TEST_RETURN(STRING_ID), which made the return value a StringId narrowed back to bool. The value was always zero or one so nothing misbehaved.  
  
No compiler reports this, unlike the enum cases that clang flags as a narrowing conversion. It was found by looking for every FUNCTION_TEST_RETURN(STRING_ID) in a function not declared to return StringId while fixing those.  

M src/common/type/json.c

Return the declared type from functions that return a StringId-based enum.

commit   : 195e66bafd89da511fb0d48e6e053176104c47c7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:30:04 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:30:04 +0700    

Click here for diff

These functions are declared to return an enum whose values are StringIds but returned with FUNCTION_TEST_RETURN(STRING_ID), which makes the return value a StringId that is then narrowed back to the enum. gcc says nothing but clang reports the narrowing as an error.  

M src/common/io/socket/session.c
M src/common/io/tls/session.c
M src/common/type/json.c
M src/common/type/pack.c

Fix test harness build when libssh2 is not present.

commit   : 66edabb57b94dbef552549ee1545dac6377ea216    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:21:59 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:21:59 +0700    

Click here for diff

The SFTP case in hrnHostConfig() calls storageSftpNewP(), which storage/sftp/storage.h only declares when libssh2 is present, so the harness failed to compile without it.  
  
Compile the case out and leave the default case to assert that the storage is posix. The sftp repo type cannot be selected without libssh2 so the case is unreachable either way.  

M test/src/harness/host.c

Fix test harness build when libsystemd is not present.

commit   : e6609b59beeced9e8fb509b14f0efa6c229b837f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:13:15 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:13:15 +0700    

Click here for diff

hrnSystemdStatic was declared outside the HAVE_LIBSYSTEMD block that uses it. Without libsystemd its only remaining use is inside an ASSERT, which is compiled out of a release build, so the performance tests failed on an unused variable.  
  
Every caller of hrnSystemDCheck() is already inside HAVE_LIBSYSTEMD, so compile the whole harness out rather than just the variable.  

M test/src/harness/systemd.c
M test/src/harness/systemd.h

Require the header when checking for libbacktrace.

commit   : 84b031af68fe6a30b4150ec6c903cc8c1781fc05    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:00:28 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 21:00:28 +0700    

Click here for diff

On Linux gcc ships libbacktrace.a in its library directory and backtrace.h in its private include directory. Both compilers search the library directory but only gcc searches that include directory, so a clang build reported the library as found and then failed to compile stackTrace.c.  

M meson.build

Fix out-of-bounds read when adding to the protocol client session list.

commit   : a787e20b14f75375a9c8cc8685d02b31cdf1c5d6    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 18:34:34 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 18:34:34 +0700    

Click here for diff

The session list holds pointers -- lstAdd() is passed the address of one and lstGet() casts back to ProtocolClientSession ** -- but the list was created with the size of the struct rather than the size of a pointer. Every add copied a struct-sized block out of a pointer-sized stack variable and the list allocated six times the memory it needed.  
  
Found by compiling with Fil-C, a memory-safe implementation of C that checks every pointer access against an invisible capability. Only the first eight bytes of each item are ever read back, so the over-read is invisible on a normal compiler:  
  
    filc safety error: cannot read 48 bytes when upper - ptr = 16  
    (libpizlo.so) <runtime>: memmove  
    (test-unit) src/common/type/list.c:341:5: lstInsert  
    (test-unit) src/common/type/list.h:143:12: lstAdd  
    (test-unit) src/protocol/client.c:532:9: protocolClientSessionNew  
    (test-unit) src/protocol/parallel.c:213:64: protocolParallelProcess  
    (test-unit) src/command/backup/process.c.inc:907:48: backupProcess  
    (test-unit) src/command/backup/backup.c:259:9: cmdBackup  
    (test-unit) test/src/module/command/manifestTest.c:104:13: testRun  
  
This also fixed the command/server unit test, which hung under Fil-C unless the session list was sized correctly.  

M src/protocol/client.c

Fix out-of-bounds read when decoding an empty base64 string.

commit   : 02a9f372785614e209e21c963f3b518a224bacf4    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 18:32:19 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 18:32:19 +0700    

Click here for diff

An empty string is valid base64 since zero is evenly divisible by four, but it has no last character, so the check for the padding character read the byte before the string. If that byte happened to be = then the decoded size underflowed to SIZE_MAX.  
  
Found by compiling with Fil-C, a memory-safe implementation of C that checks every pointer access against an invisible capability. The read is one byte before a string literal so it is harmless on a normal compiler and nothing reported it, even though bufNewDecode() has always been tested with an empty string:  
  
    filc safety error: cannot read pointer with ptr < lower.  
    pointer: 0xaaaabd8cd837,0xaaaabd8cd838,0xaaaabd8cd840,global,readonly  
    expected 1 bytes.  
    semantic origin:  
    (test-unit) src/common/encode.c:209:9: decodeToBinSizeBase64  
    check scheduled at:  
    (test-unit) src/common/encode.c:209:9: decodeToBinSizeBase64  
    (test-unit) src/common/encode.c:533:31: decodeToBinSize  
    (test-unit) src/common/type/buffer.c:89:33: bufNewDecode  
    (test-unit) test/src/module/common/type/bufferTest.c:49:9: testRun  
  
Test the empty string in the encode module as well, since the guard added here is a branch that nothing there covered.  

M src/common/encode.c
M test/src/module/common/encodeTest.c

Specify the repo for stanza-delete in the integration test.

commit   : ad755d250284acb7996a5b9b1b375cf8675f4466    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 17:29:12 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 17:29:12 +0700    

Click here for diff

The test deletes the stanza when the repo is versioned so that repo-target-time has something to restore from. stanza-delete works on one repository, so it must be told which one when more than one is configured, and the call did not specify one.  
  
This worked only because the versioned test ran with a single repository. It failed as soon as the repo total for the S3 test was raised to two.  
  
Pass --repo=1, which is the repository the restore below already names for repo-target-time.  

M test/src/module/integration/allTest.c

Inherit an allow list documented under a command.

commit   : 18c398670798a1d88df1dcacda19bb53b7cbf702    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 13:41:56 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 13:41:56 +0700    

Click here for diff

The help describes the values an option allows with an allow list. An option that allows the same values as another option can inherit that list instead of describing the values again.  
  
This only worked for a list documented by an option on its own. A list documented under a command could not be inherited, so an option that only the commands document has its values written out once per command. The output option describes the same two values under manifest, repo-ls, info, verify, and version.  
  
Record the list each command documents as well, and let inherit name the command to take it from. The command has to be named because more than one command can document the same option and the lists need not match. The type option allows full, diff, and incr for backup but nine values for restore.  
  
A list is still checked against the values the inheriting option or command allows, so it can only be inherited where both allow the same values.  
  
In the test declaration set is now taken by backup and restore so that backup can describe the values and restore can inherit them.  

M build/lib/help/parse.py
M test/src/module/build/help/parse_test.py

Pass a cipher spec rather than a type and passphrase.

commit   : b9b58a17db0b475b2d1178ebfacae18c6c1849d9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 10:23:25 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Tue, 18 Aug 2026 10:23:25 +0700    

Click here for diff

The cipher type and passphrase were separate parameters in every function that reads or writes an encrypted file, every info file load and save, and every protocol message that hands a file to a local process. Anything added alongside them meant changing all of it again.  
  
A cipher spec keeps them together. The pass is a buffer rather than a string so an absent pass is simply NULL, and it is copied into the object so the caller is free to release whatever it was read from. The spec is packed and unpacked as a unit for the protocol and writes nothing but the type when there is no cipher.  
  
The digest the key derives with was a parameter of the block cipher filter, packed with every filter that crossed the protocol, and nothing but the tests ever set it. It is gone and the key derives with SHA-1 where that happens, since the spec is where the choice would go if there is ever more than one.  
  
The specs form a chain. cfgCipherSpecMainIdx() builds one from the options and caches it per repository, the info files are read with that, and each info file carries the spec for the files that depend on it, so nothing below the info files consults the options again. The cache is filled the first time a spec is asked for, so a command has to get the repo storage before then, since that is what pulls the cipher options down from a remote repository.  
  
An info file stores a pass but not a type, so the spec it carries takes the type of the spec the file was read with. That spec is never NULL, and is none when the file has no pass, so it can be handed on without a check.  
  
A spec is named for what it opens: cipherSpecMain from the configuration, cipherSpecArchive from archive.info, cipherSpecManifest from backup.info, and cipherSpecBackup from the manifest. That turned up names that said the opposite of what they held, since what backup called the backup passphrase is what opens manifests, and restore documented the passphrase it loads the manifest with as being for backup files.  
  
The test harness takes a spec as well, so the storage and backup harnesses no longer accept a type and pass pair, and the rule that a missing pass meant the main test pass is now spelled out in TEST_CIPHER_SPEC.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/annotate/annotate.c
M src/command/archive/get/file.c
M src/command/archive/get/file.h
M src/command/archive/get/get.c
M src/command/archive/get/protocol.c
M src/command/archive/push/file.c
M src/command/archive/push/file.h
M src/command/archive/push/protocol.c
M src/command/archive/push/push.c
M src/command/backup/backup.c
M src/command/backup/complete.c.inc
M src/command/backup/db.c.inc
M src/command/backup/file.c.inc
M src/command/backup/incr.c.inc
M src/command/backup/process.c.inc
M src/command/backup/protocol.c
M src/command/backup/resume.c.inc
M src/command/check/check.c
M src/command/check/common.c
M src/command/check/common.h
M src/command/expire/expire.c
M src/command/info/info.c
M src/command/manifest/manifest.c
M src/command/repo/get.c
M src/command/restore/blockDelta.c
M src/command/restore/blockDelta.h
M src/command/restore/file.c.inc
M src/command/restore/process.c.inc
M src/command/restore/protocol.c
M src/command/restore/restore.c
M src/command/restore/select.c.inc
M src/command/restore/timeline.c
M src/command/restore/timeline.h
M src/command/stanza/common.c
M src/command/stanza/common.h
M src/command/stanza/create.c
M src/command/stanza/upgrade.c
M src/command/verify/file.c
M src/command/verify/file.h
M src/command/verify/protocol.c
M src/command/verify/verify.c
M src/common/crypto/cipherBlock.c
M src/common/crypto/cipherBlock.h
A src/common/crypto/spec.c
A src/common/crypto/spec.h
M src/config/config.c
M src/config/config.h
M src/config/config.intern.h
M src/info/info.c
M src/info/info.h
M src/info/infoArchive.c
M src/info/infoArchive.h
M src/info/infoBackup.c
M src/info/infoBackup.h
M src/info/infoPg.c
M src/info/infoPg.h
M src/info/manifest/manifest.c
M src/info/manifest/manifest.h
M src/info/manifest/serialize.c.inc
M src/meson.build
M test/define.yaml
M test/src/harness/backup.c
M test/src/harness/backup.h
M test/src/harness/blockIncr.c
M test/src/harness/config.h
M test/src/harness/host.c
M test/src/harness/host.h
M test/src/harness/storage.c
M test/src/harness/storage.h
M test/src/module/command/archive/getTest.c
M test/src/module/command/archive/pushTest.c
M test/src/module/command/backupTest.c
M test/src/module/command/checkTest.c
M test/src/module/command/expireTest.c
M test/src/module/command/infoTest.c
M test/src/module/command/manifestTest.c
M test/src/module/command/repoTest.c
M test/src/module/command/restoreTest.c
M test/src/module/command/stanzaTest.c
M test/src/module/command/verifyTest.c
M test/src/module/common/cryptoTest.c
M test/src/module/config/loadTest.c
M test/src/module/config/parseTest.c
M test/src/module/info/infoArchiveTest.c
M test/src/module/info/infoBackupTest.c
M test/src/module/info/infoPgTest.c
M test/src/module/info/infoTest.c
M test/src/module/info/manifestTest.c
M test/src/module/integration/allTest.c
M test/src/module/performance/typeTest.c
M test/src/module/storage/remoteTest.c

Fix annotate and manifest commands on a remote encrypted repository.

commit   : 078489fa344b0e37ad7c35359260b9893a43c733    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 09:44:34 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 18 Aug 2026 09:44:34 +0700    

Click here for diff

Both commands read the repo cipher options to load backup.info, but when the repo is remote those options are pulled from the remote the first time the repo storage is created. Reading them before that leaves the load with no cipher when the repository is encrypted and the cipher is configured only on the repo host, and the load fails.  
  
In manifest the storage and the options are arguments to the same call, so the order they are evaluated in is up to the compiler and whether the command works depends on how it was built. In annotate the cipher type is read by the statement before the load, so it is always read too early.  
  
Get the repo storage first, as backup, expire, verify, info, and archive-get already do. In annotate this goes inside the TRY block so a repo that cannot be reached is reported and skipped like any other error there.  

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/annotate/annotate.c
M src/command/manifest/manifest.c

Check that a block macro is closed by the macro that matches it.

commit   : 3adc6558534e8775a824d2d57f383b1c45e234d6    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 17:48:05 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 17:48:05 +0700    

Click here for diff

The block macros pair a BEGIN with the one END that matches it, but several closers are an alias for another: MEM_CONTEXT_PRIOR_END(), MEM_CONTEXT_OBJ_END(), and TEST_ERROR_MEM_CONTEXT_END() are each defined as the base MEM_CONTEXT_END(), and OBJ_NEW_END() as MEM_CONTEXT_NEW_END(). Closing with the wrong one expands identically, so the compiler has nothing to report and the mistake stays. Eight sites had drifted to the base closer when this was first run, fixed in 42699b1ff.  
  
Checking it needs a lexer rather than a parser. The block macros are brace-unbalanced by design, so a C file in this project is not valid C as written and only balances once it has been preprocessed. A parser that reads the source as written puts everything in the block at the wrong depth and, being error tolerant, produces a tree that is wrong rather than failing, and a parser that reads preprocessed source never sees a macro at all since every one worth checking has been expanded by then. A lexer has neither problem: a macro is just an identifier and the rule reading the tokens supplies what it means.  
  
Add the lexer as lex.py and the check as macro.py, where each macro that opens a block is declared with the one macro that may close it. That table cannot be derived from the names, since several openers share a closer and several closers are an alias, so a second check requires every macro defined with a name ending in _BEGIN or _END to be classified as opening a block or not. Without it a block macro added with a closer of its own is a false negative: neither name is known, both are skipped, and the pairing check passes with nothing to say. It found 14 macros that had been missed by hand, including the whole HRN_FORK_* family, which was 172 pairs going unchecked while the check still reported nothing.  
  
Report what the linter finds without stopping the run, and fail on it once the tests have run. A linter reads the source as written, so a mistake shows up wherever the source stopped making sense to it rather than where it is: a misspelled MEM_CONTEXT_BEGIN is not a block macro at all, so the pairing check reports the next closer 14 lines later and blames a block that opened 33 lines before the typo. The compiler places the same mistake exactly, naming the line and the macro that was meant, so the run needs to reach it. The linter now returns what it found instead of raising, which also means one bad file no longer stops the rest of the repository from being scanned, and it names the file after the warnings for it since they carry only the line they are on.  
  
The checks apply to hand-written C, which is what the StringId check already applies to, and add about half a second to a lint of the repository. They wait for the file to be ASCII, since what the lexer would report about a character it cannot read is what the ASCII check has already reported.  
  
A sentinel variable in the macros themselves, as debug.h already declares for FUNCTION_TEST_BEGIN/END, would make closing with an alias a compile error and is the better fix where one is possible. It fits only the four cases where an alias exists, and leaves the classification check standing either way, so it is left as a possibility rather than done here.  

M test/define.yaml
A test/lib/command/lint/lex.py
M test/lib/command/lint/lint.py
A test/lib/command/lint/macro.py
M test/lib/command/test/test.py
M test/src/module/test/command/lint/lint_test.py
M test/src/module/test/command/test/test_test.py

Merge v2.59.1 release.

commit   : 113901cabf5c6744691d746d6019d3dd76c8eaca    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 17:08:44 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 17:08:44 +0700    

Click here for diff

M README.md
M doc/xml/auto/metric-coverage-report.auto.xml
M doc/xml/news.xml
M doc/xml/release.xml
A doc/xml/release/2020s/2026/2.59.1.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml

PostgreSQL 19beta3 support.

commit   : 7ba17b767d3910472266d95b889f17e7f1fcc8c2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 10:14:29 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 17 Aug 2026 10:14:29 +0700    

Click here for diff

Update XLOG_PAGE_MAGIC, which beta3 bumped for this bug fix:  
  
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=ed62d26cacac96ca5c6b4e6fcc5308b11a8371c8  

M doc/xml/release/2020s/2026/2.59.0.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/postgres/interface/version.vendor.h
M test/container.yaml

Regenerate the built code when the generator changes.

commit   : a35ba37ee1c5d6a62520c60d8defa30e4db0e6aa    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 13 Aug 2026 15:21:33 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 13 Aug 2026 15:21:33 +0700    

Click here for diff

The help and the PostgreSQL interface are generated from declarations that are declared as dependencies of the targets that generate them, but the result depends just as much on the generator that reads those declarations. A change to how something is rendered did not regenerate anything and the build carried on with whatever had been generated last.  
  
Changing what a generator writes is a normal way for the generated code to change, so this was no harder to hit than a change to a declaration. It went unnoticed because the test harness runs the generators itself before every build, leaving only the meson build of the binary to compile against a stale file.  
  
Declare the library the generator is built from so the generated code is built again when any part of it changes. The list is built when meson configures since meson has no glob, so a module added to the library is picked up the next time it configures.  

M src/command/help/meson.build
M src/meson.build
M src/postgres/meson.build

Reduce the manifest checks made when reconstructing backup.info.

commit   : 0d3c340087bfb97263c29a8fd681cf079071236b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 13 Aug 2026 11:23:05 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 13 Aug 2026 11:23:05 +0700    

Click here for diff

Reconstruction runs at the head of backup, expire, and annotate, and checks for a backup.manifest once for every backup in backup.info and once for every backup path in the repository that is not. Each check is a request, so a command pays one per backup before it does any work, and on an object store every check for a backup that is not there shows up as a 404 in the store's log.  
  
A diff or incr backup is only added when the backup it depends on is in backup.info, and its label is generated from the label of that backup, so the two are always in the same full backup set. When no backup from the set is in backup.info the manifest can never be added no matter what it contains, and there is no reason to read it. The test is made against the whole set rather than against the full backup alone, since the backup a diff or incr depends on may be another diff or incr, and that holds without assuming backup.info lists a full backup for everything that depends on it. Backups are processed oldest to newest, so a full backup added by the same loop is in backup.info before the backups that depend on it are reached and a set rebuilt from the repository is still rebuilt whole.  
  
The repository listing is read before the first loop and already indicates whether a backup path is there at all. A backup in backup.info with no path in the repository has no manifest either, so the listing answers for it and the request is only made when there is a path to check.  
  
One warning goes away with the reads. A leftover diff or incr path whose set is no longer in backup.info was reported as an invalid backup that could not be added, and reaching that message means reading the manifest that is no longer read. It was always an additional complaint about a set already reported broken, and a leftover path with no manifest at all never produced it.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/info/infoBackup.c
M test/src/module/info/infoBackupTest.c

Update contributor github account.

commit   : 67c020fb98a3c162ec8b82933cc858718eab0433    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 13 Aug 2026 10:47:09 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 13 Aug 2026 10:47:09 +0700    

Click here for diff

M doc/xml/release/contributor.xml

Send keep-alive noops only when the client has been idle.

commit   : f652261d32a4ffba98e7da26a41ef4ef1cd08da3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:49:03 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:49:03 +0700    

Click here for diff

protocolKeepAlive() sent a noop to every remote each time it was called, and callers call it once per unit of work: per completed job in archive-push and backup, and per request in the server. A local process that was busy therefore produced a stream of round trips to remotes that had just been sent a request anyway.  
  
The member needed to prevent this has been present since 88201f37a, which stamped keepAliveTime on every write with the comment "Reset the keep alive time", but the check that would have read it was never written. 6a1c0337d and df8cbc91c each dropped one of those stamps, leaving only the initializer, so keepAliveTime has never been read in any version.  
  
Stamp keepAliveTime where requests are flushed and send a noop only after PROTOCOL_KEEP_ALIVE_TIME has elapsed. Since a request is itself data put to the server, a remote that is doing work needs no noop at all. A protocol-timeout below the 30 seconds chosen here would require the interval to be derived from it instead, but the default is 31 minutes.  

M src/protocol/client.c
M src/protocol/client.h
M src/protocol/helper.c
M test/src/module/protocol/protocolTest.c

Remove struct member that was only written.

commit   : d8538e15e6281db5fbdae0d29c29baaa81f4419b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:42:00 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:42:00 +0700    

Click here for diff

The storageStandby member of BackupData was added by 1f2ce45e along with the assignment that is its only reference, so it has never been read. BackupData carries a matching storagePrimary that is read in several places, so the standby member appears to have been added for symmetry rather than for a caller that wanted it.  

M src/command/backup/backup.c

Represent the values that vary by version as interface data.

commit   : cc9ff041f410f1c1a04c2829dbde0dea04767d04    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:16:57 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 12 Aug 2026 10:16:57 +0700    

Click here for diff

A value that varies by version was read inside a function, so two versions got a rendering of it that differed only by that value. pgInterfaceControlVersion() returned PG_CONTROL_VERSION, pgInterfaceWalIs() compared XLOG_PAGE_MAGIC, and pgInterfaceControlIs() compared both PG_CONTROL_VERSION and CATALOG_VERSION_NO, so none of them could ever be shared no matter how little of the version they actually depended on.  
  
Include them in the interface struct instead. A version block captures each as a constant named for it, since a value only has a value inside the block and the struct is built after every block has undefined what it defined. A define cannot do this because it is expanded where it is used rather than where it is made, by which time the value it was given is gone. The constant also names the member it is assigned to, so adding a value is a change to the header and to nothing else.  
  
Each value is captured by a macro of its own, so the analysis that decides which versions can share a function decides the same for a value. One that does not vary between two versions is captured once for both and named for the range it covers, e.g. pgInterfaceControlVersion13_16, which makes what a version shares readable where it is assigned rather than something to work out from the vendored header. The control version is the same from 13 through 16 and the crc offset for 15 and 16.  
  
The functions that only compared a value go with them. Matching pg_control against an interface is now a comparison of the fields that are at the same offset in every version, which pgControlCommon and pgWalCommon already read to report a version that is not supported, and the WAL magic is compared the same way. The comparison is rendered by the generator in the form that fits the versions rendered, so the range that an unreleased version needs is only rendered while there is one and there is never a comparison that no supported version can fail.  
  
Whether a version has been released is set from the declaration, which is where it is stated, rather than derived in the header.  
  
The harness shims the interface so it writes with the types that interface declares rather than declaring them again, and takes the defaults a test does not ask for from the interface the code matches on rather than from a second copy of them.  
  
This brings the binary interface to 22 rendered functions from 62 and the harness to 12 from 32.  

M build/lib/postgres/parse.py
M build/lib/postgres/render.py
M src/postgres/interface.c
M src/postgres/interface/version.intern.h
M test/define.yaml
M test/src/harness/postgres.c
M test/src/harness/postgres/version.intern.h
M test/src/module/build/postgres/render_test.py
M test/src/module/postgres/interfaceTest.c

Render a PostgreSQL interface function once per set of types it uses.

commit   : 6e7a9d19d289778d87839edf04b176f906b4c0d8    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 22:19:35 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 22:19:35 +0700    

Click here for diff

Every supported version got a rendering of every interface function, which is 77 functions for the seven macros the binary interface declares and 33 for the three the harness declares. Many of them compile to identical code, since most of the vendored types do not change in a given release. The functions are trivial enough today that the duplication costs little, but more ambitious ones would multiply by the number of versions.  
  
Read the vendored header as a chain of version branches per type and define, resolve each of them for each version, and take the transitive closure of what an interface macro reaches. Two versions share a rendering when every entity in that closure resolves to the same branch for both, since that is what makes the code the compiler sees the same. Following the closure rather than the names a macro uses directly is what makes the comparison sound: ControlFileData is unchanged from 13 to 14, but CheckPoint is embedded in it and does change, so the layout differs.  
  
The oldest version of a group renders the function and the struct points the rest at it, so adding a version later never renames what is already there. A rendering more than one version shares is named for the range it covers, e.g. pgInterfaceWal14_19, and the block it is rendered in says which versions share it. This brings the binary interface to 62 functions and the harness to 32.  
  
A macro that does not take a version is now read as a helper the function macros are written with rather than as a function of its own. The dependency scan follows a helper to the types it names, so declaring one is now possible, where before it would have been rendered as a function.  

M build/lib/postgres/parse.py
M build/lib/postgres/render.py
M test/src/module/build/postgres/parse_test.py
M test/src/module/build/postgres/render_test.py

Remove unused struct members.

commit   : d433433005be7884ea811e1120bf2c45c42efbf8    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 22:10:42 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 22:10:42 +0700    

Click here for diff

The filter member of Lz4Compress and Lz4Decompress was added with the driver in c279a002 and the same member of ZstCompress and ZstDecompress in 47aa7653, but neither was ever read or written. The gz and bz2 drivers never had an equivalent member, so this was duplicated boilerplate rather than something a later change orphaned.  
  
The subPath member of RestoreCleanCallbackData was added with the C implementation of restore in 451ae397 and was never read or written either. 1db52ef9 moved it into clean.c.inc unchanged when it split the restore module.  

M src/command/restore/clean.c.inc
M src/common/compress/lz4/compress.c
M src/common/compress/lz4/decompress.c
M src/common/compress/zst/compress.c
M src/common/compress/zst/decompress.c

Regenerate the PostgreSQL interface when its headers change.

commit   : 1aaf2533a448b9f42bb2e80ee9393633872ea743    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 21:18:08 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 21:18:08 +0700    

Click here for diff

The generated interface is built from postgres.yaml, version.vendor.h, and version.intern.h, but only postgres.yaml was declared as a dependency of the target that generates it. A change to either header therefore did not regenerate anything and the build carried on with whatever had been generated last.  
  
Adding a type to the vendored header or a macro to the interface header is how the interface normally changes, so this was the usual case rather than an unlikely one. It went unnoticed because the test harness runs the generators itself before every build, leaving only the meson build of the binary to compile against a stale file, where it shows up as a missing type or an undeclared function rather than as anything that points at the cause.  
  
Declare both headers so the interface is generated again when either changes.  

M src/postgres/meson.build

Check the unity build object rather than the binary.

commit   : d9186c83e0cc53a3ee633188a64cd1b837f41852    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 15:17:51 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 15:17:51 +0700    

Click here for diff

The check compared nm output for the linked binary against a list of every symbol it exported, but only one of them, main, was pgBackRest's. The rest was the C runtime, the symbols of the static libbacktrace archive, which is the only dependency not linked as a shared object, and copy relocations for the shared library variables the code reads, i.e. environ, stderr, and the xmlFree function pointer in libxml2.  
  
Run nm on the objects instead so only what pgBackRest itself defines is considered. The expected output is main and the only thing that can add to it is a function or variable that escaped FN_EXTERN, which is what the check is for. It also no longer depends on the architecture, the C library, or which libraries are linked, e.g. the same code built for aarch64 has five more runtime symbols and none of the copy relocations, since data is read through the GOT there.  

M .github/workflows/test-misc.yml

Build the expected symbol list into the unity check.

commit   : c52bb542aa744097c96323300258b5206d78b8a2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 14:53:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 14:53:22 +0700    

Click here for diff

The unity build checks that the binary exports only the symbols expected by diffing the output of nm against a list, which was kept in a file beside the workflow. That file was the only thing in .github/workflows/data, a directory that exists at all because GitHub scans the top level of .github/workflows for workflows and would have tried to load the list as one.  
  
Move the list into the workflow as a heredoc, the way the dist workflow already compares the contents of the tarball, so the check and the data it checks against are read together and the data directory goes away.  

D .github/workflows/data/symbol.out
M .github/workflows/test-misc.yml

Invalidate memory that leaves a Buffer, String, or List for Valgrind.

commit   : d5f7499609a951fa3bae1fc49acb5c22737e6022    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 14:46:37 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 14:46:37 +0700    

Click here for diff

Valgrind memcheck client requests were added in 702d664f so use-after-free and uninitialized reads would still be detected on allocations retained by the memory context free list. The same detection is missing when memory leaves a container without being freed. A buffer or string that shrinks and a list that removes an item keep the memory allocated, so the stale data still looks valid to memcheck and a read past the new end, or through a pointer to a removed item, goes unreported.  
  
Mark the memory as undefined when it leaves the container so any read from it that affects behavior is reported. Undefined rather than inaccessible because the memory is still owned by the container and will be written again when it grows.  
  
Move the client requests to a new header, valgrind.h, now that they are used by more than one module.  
  
This immediately found a client in the protocol test harness that was read after being removed from the list, which is fixed here. The backup test also depended on data beyond a shrink, so it now zeroes the buffer again before growing it.  

M src/common/memContext.c
M src/common/type/buffer.c
M src/common/type/list.c
M src/common/type/string.c
A src/common/valgrind.h
M test/src/harness/protocol.c
M test/src/module/command/backupTest.c

Create the data path for a performance test.

commit   : b661d15b3e3ab3c8f24b4fefd82aac3a8950a864    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 13:43:58 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 13:43:58 +0700    

Click here for diff

A performance test was not given a data path since it writes no data, but the harness clears that path between tests and clearing a path that is not there fails, so a run of nothing but performance tests reported "unable to clear data path" after its first test.  
  
Two things kept this from being noticed. A test that runs in a container gets the path anyway, since docker creates a mount point that is missing, and a run that includes any other test gets it from whichever one ran first. Only a performance test on the none vm, with no other test to create the path first, ever reaches the failure.  

M test/lib/command/test/job.py
M test/src/module/test/command/test/job_test.py

Generate the PostgreSQL interfaces the test harness uses.

commit   : 2b51a7f73389f58f0ad6608c0b8b95ff04647ee9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 13:36:36 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 13:36:36 +0700    

Click here for diff

The harness had a file per version that did nothing but expand a macro for it, a block of declarations for the functions those files defined, and the struct that dispatched to them, all maintained by hand, so adding a PostgreSQL version meant adding a file and three more entries to keep in step. The interfaces the binary uses are generated from the same versions, so the harness generates its own the same way with 'build.py postgres-harness'.  
  
What differs between the two interfaces is a declaration: the header the macros are read from, how the generated code includes it, the prefix of the functions the macros define, and where the interface is rendered. The versions, the vendored types that are renamed for each of them, and the defines that are undefined after each one are the same, so one generator renders both.  
  
The harness interface is one translation unit with the vendored header included again for each version, the way the binary's is, rather than a translation unit per version. The functions are static, so the declarations and the pragma that silenced the missing prototypes go away with the files, and they are named for the version the way the binary's are, e.g. 19 rather than 190. The harness macros are renamed to say which function each one defines, since the generator names a function after the macro that defines it, and the macro that expanded all of them for a version is gone because the generated file expands each one itself. The header they are declared in loses its include guard and everything that does not vary by version, since it is now included once per version and the module that expands the macros includes the rest.  
  
The system id constants are generated into the harness header rather than into a file of their own, so the header still holds everything the harness declares. The defines are replaced where they are, which is what the version in src/version.h is already generated by. Every version gets the offset form that only two of them had, since a block that reads the same for every version is easier to follow than one that does not.  
  
The interface is generated rather than committed, the same as the interface the binary uses, so a test run generates it and the repository copy duplicates it for a unit build to compile.  

M build/lib/command/main.py
M build/lib/postgres/parse.py
M build/lib/postgres/render.py
M test/.gitignore
M test/lib/command/test/test.py
M test/src/harness/postgres.c
M test/src/harness/postgres.h
D test/src/harness/postgres/096.c
D test/src/harness/postgres/100.c
D test/src/harness/postgres/110.c
D test/src/harness/postgres/120.c
D test/src/harness/postgres/130.c
D test/src/harness/postgres/140.c
D test/src/harness/postgres/150.c
D test/src/harness/postgres/160.c
D test/src/harness/postgres/170.c
D test/src/harness/postgres/180.c
D test/src/harness/postgres/190.c
M test/src/harness/postgres/version.intern.h
M test/src/module/build/command/main_test.py
M test/src/module/build/postgres/parse_test.py
M test/src/module/build/postgres/render_test.py
M test/src/module/test/command/test/test_test.py

Generate the code meson builds into the build path.

commit   : 3b4928dcaaac7731f6b9ff051f8c787f22ca7969    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 12:32:12 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 12:32:12 +0700    

Click here for diff

The generators for the help and the PostgreSQL interfaces were given a repository path but no build path, so they wrote into the repository while meson declared their output in the build path. The output was never there, so ninja ran both generators again on every build looking for a file that would not appear.  
  
Passing the build path puts each file where its output is declared, which is what the distribution script already does. A generated file is only written when its content changes and custom commands run with restat, so a declaration that is touched but not changed regenerates nothing and settles rather than running again on the next build.  
  
The repository copy of each file is still what a unit build compiles, since the test command generates both into the repository before it makes the copy.  

M src/command/help/meson.build
M src/postgres/meson.build
M test/lib/command/test/test.py

Include the generated help data in the help command.

commit   : 1a8b7711751b0679dfd8276b7e0588291b7eb326    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 12:18:13 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 12:18:13 +0700    

Click here for diff

main.c included the generated help data and passed it to cmdHelp() as a buffer, so no unit build ever compiled it and the help test had to run the generator and load the packed help from a file to have anything to render. Including it in help.c puts the data in the module that reads it and lets the test render exactly what the binary carries.  
  
The generated file is added to the files the repository copy needs, since git does not list it, and the test command generates it along with the PostgreSQL interfaces. Those are the two files that are generated rather than committed, and a unit build needs both from the copy because it does not run the code generation the build does. The file is regenerated at the start of every run, so the test still runs against the current declarations without generating them itself.  
  
cmdHelp() and helpRender() no longer take the data as a parameter and help.h no longer needs the buffer type. The help-data generator command is removed along with its only caller.  

M build/lib/command/main.py
M build/lib/help/render.py
M src/command/help/help.c
M src/command/help/help.h
M src/command/help/meson.build
M src/main.c
M test/lib/command/test/test.py
M test/src/module/build/command/main_test.py
M test/src/module/build/common/storage_test.py
M test/src/module/build/help/render_test.py
M test/src/module/command/helpTest.c
M test/src/module/test/command/test/test_test.py

Mirror the repository into the copy the tests are built from.

commit   : 1d03fc2689795c6f0289279b297e6bb8dd65419f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 11:15:36 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 11 Aug 2026 11:15:36 +0700    

Click here for diff

A file that was deleted or renamed in the repository lived on in the copy the tests are built and run from, so it was still linted, built, and run. rsync was called with --delete but that never removed anything, since --delete only applies to directories rsync recurses into and a transfer driven by --files-from contains none.  
  
The copy could not simply be mirrored either, because the PostgreSQL interfaces were generated into it. Generate them into the repository instead, which is where the build generates them anyway, so everything the copy needs is either version controlled or generated into the repository.  
  
Make the copy in python rather than with rsync. The file list still comes from git, a file is copied only when its size or timestamp differs so an unchanged file keeps its timestamp and does not make the build rebuild what depends on it, and anything else in the copy is removed along with the paths that leaves empty. The removal runs before the copy so a file that took the name of a path, or a path that took the name of a file, does not run into what is left of the old name.  
  
Nothing runs rsync now, so it is no longer installed for the tests, for CI, or in the base images, and the special case for MacOS where --ignore-missing-args is not supported goes with it. The Alpine images are rebuilt since the package list is part of the script their hash is built from.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M CONTRIBUTING.md
M doc/xml/contributing.xml
M test/ci.py
M test/lib/command/test/test.py
M test/lib/command/vm/build.py
M test/src/module/test/command/test/test_test.py

Fix hang when the chunk buffer is smaller than the input buffer.

commit   : ea133fa0c01bb3e7e480eaa6394594f26d86949b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 8 Aug 2026 11:39:41 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Sat, 8 Aug 2026 11:39:41 +0700    

Click here for diff

The chunk buffer used for S3/GCS/Azure multi-part upload is only written when it contains exactly chunk size bytes, so when the chunk buffer is allocated smaller than chunk size and fills up, no more data can be copied into it and no write is triggered, which results in an infinite loop.  
  
This could happen when chunk size grew after a chunk was written, since the chunk buffer was resized only once before the copy loop, or when the chunk buffer had been sized exactly to fit a prior partial write and doubling was not enough to hold the next input buffer.  
  
Resize the chunk buffer in the copy loop and make sure it is always large enough to hold the entire input buffer, unless max chunk size has been reached. Also grow the chunk buffer aggressively when it is not empty, since in that case more data is likely no matter how full the input buffer is.

M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml
M src/storage/azure/write.c
M src/storage/gcs/write.c
M src/storage/s3/write.c
M src/storage/write.c
M src/storage/write.h
M test/src/module/storage/azureTest.c
M test/src/module/storage/gcsTest.c
M test/src/module/storage/posixTest.c
M test/src/module/storage/s3Test.c

Compare a new backup label only against its own full backup set.

commit   : eb48d76fd179cdc162979404c0c7a3881c10bad7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 8 Aug 2026 10:38:38 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Sat, 8 Aug 2026 10:38:38 +0700    

Click here for diff

A diff or incr label is generated from the prior backup label, so it begins with the full backup part of that label and can only collide with, or need to sort after, a label in the same full backup set. The history lookup has always known this and restricts itself to one set, but the repository listing beside it compared against every backup in the repository, including full backups from other sets, which a diff or incr label sorts before whenever the full backup it belongs to is older.  
  
That costs nothing while the listing holds only backups that exist. On object stores that keep a prefix alive after everything under it has been deleted, such as MinIO with object locking or SeaweedFS, a backup that pgBackRest removed goes on being returned by the listing. An aborted full backup cleaned up by resume therefore keeps sorting after the prior full backup, and every diff and incr taken afterwards fails with "new backup label ... is not later than latest backup label ...", naming a backup that is no longer there.  
  
Build the expression that restricts a comparison to a single full backup set once and filter both the backup listing and the history listing with it. For a full backup the expression matches everything, as it did before.  
  
Build the expression from the prior backup label rather than from the newest label in the repository listing, since that is the value the listing gets wrong and it is also the label the new label is generated from. Where the last full backup has been removed and a diff or incr is generated from the last remaining full backup, which is the case the history restriction was written for, the two labels are the same.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/label.c.inc
M test/src/module/command/backupTest.c

Replace deprecated OpenSSL APIs.

commit   : 816ed191d23d093bfe417a5375d422a022bcb7ce    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 8 Aug 2026 10:04:16 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Sat, 8 Aug 2026 10:04:16 +0700    

Click here for diff

The minimum required OpenSSL version is 1.1.1, which is new enough to provide replacements for nearly all the deprecated APIs in use.  
  
Hardcoded 2048-bit DH parameters are replaced by SSL_CTX_set_dh_auto(), which selects parameters that match the strength of the certificate key. This is at least as strong as the parameters that were hardcoded and removes the need to read them from a PEM buffer with PEM_read_bio_DHparams().  
  
The ECDH curve is now set with SSL_CTX_set1_groups_list() rather than being constructed as an EC_KEY and passed to SSL_CTX_set_tmp_ecdh(). Since 1.1.0 the latter simply extracts the curve from the key and sets it as the only supported group, so this is equivalent. The macro casts the curve list to char *, which drops const because the build enables -Wwrite-strings, so the resulting -Wcast-qual error is suppressed. GCC does not report this but clang does.  
  
Together these remove all the -Wdeprecated-declarations pragmas from the TLS server. SSL_OP_SINGLE_DH_USE and SSL_OP_SINGLE_ECDH_USE are also removed since they have been defined as no-ops since 1.1.0.  
  
Also replace SSLv23_method() with TLS_method(), ERR_load_crypto_strings()/OpenSSL_add_all_algorithms() with the OPENSSL_init_crypto() call they expand to, and EVP_MD_CTX_create()/EVP_MD_CTX_destroy() with EVP_MD_CTX_new()/EVP_MD_CTX_free().  
  
SSL_get_peer_certificate() is left as is since SSL_get1_peer_certificate() is not available in 1.1.1 and adding a compatibility macro is not worth it while the deprecated name still works.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/common/crypto/common.c
M src/common/crypto/hash.c
M src/common/io/tls/common.c
M src/common/io/tls/server.c
M src/storage/gcs/storage.c
M test/src/module/common/cryptoTest.c

Update SeaweedFS to 4.41 in the documentation build.

commit   : 2deb9c363bc647d20b17e144f9836795516d63e2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 20:20:54 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 20:20:54 +0700    

Click here for diff

SeaweedFS 4.40 kept listing a prefix after everything under it had been deleted, so after the stanza delete in the user guide the S3 repository still returned the stanza prefix and info reported missing stanza data rather than missing stanza path.  
  
This was reported upstream in seaweedfs #10416: in a versioning-enabled bucket a delimited ListObjectsV2 returned a CommonPrefix for a prefix whose objects were all delete-marked, which neither AWS S3 nor Ceph RGW does. SeaweedFS derives CommonPrefixes from the filer directory tree, which the retained version history keeps populated. The report cited #2792, where the same behavior on MinIO with object locking causes a hard error rather than merely cosmetic noise.  
  
The fix landed the same day as seaweedfs #10419. Before emitting a directory as a CommonPrefix the S3 listing now probes it and suppresses it only when every entry underneath is delete-marked. The probe is gated to versioned buckets, bounded by an entry budget, and fails open, so the worst case is a phantom prefix surviving rather than a live prefix disappearing. It was verified against builds at the merge commit and its parent, and one remaining edge reported back: the probe budget is shared across all candidate prefixes in a request, so a large deleted prefix can exhaust it and resurrect the smaller deleted prefixes probed after it.  
  
4.41 ships the fix, so pin the documentation image to it and restore the expected info output to missing stanza path.  

M doc/xml/user-guide.xml
M test/src/harness/host.c

Move the archive tests into a command/archive path.

commit   : 13ca21f43f7f56a67ba25f746f8143ca3e0e4d74    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 19:12:01 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 19:12:01 +0700    

Click here for diff

The three archive test modules were named command/archive-common, command/archive-get, and command/archive-push, so each was a sibling of every other command test rather than one of a group, even though the source they cover has always lived together under src/command/archive. A module name is a path and --module matches a name or anything below it, so naming them command/archive/common and so on makes --module=command/archive run all three, which is what selecting a group of tests is for.  
  
The file a test module lives in follows from its name, so the files move with the names: test/src/module/command/archiveCommonTest.c becomes test/src/module/command/archive/commonTest.c, and get and push follow.  
  
The arch and bsd workflows select the archive modules by name, so they are updated. Each keeps the modules it had rather than taking the group, since neither ran the common tests.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M test/define.yaml
R100 test/src/module/command/archiveCommonTest.c test/src/module/command/archive/commonTest.c
R100 test/src/module/command/archiveGetTest.c test/src/module/command/archive/getTest.c
R100 test/src/module/command/archivePushTest.c test/src/module/command/archive/pushTest.c

Move the distribution trimming from .gitattributes to dist.sh.

commit   : 5aece4075e677e6ec4fdd84854ea2ed73467a7dc    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:39:24 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:39:24 +0700    

Click here for diff

The export-ignore rules applied to every archive git produced, not just the one meson dist builds, so the source archives GitHub generates for a tag were missing the code generation tooling, the declarations it reads, and the documentation source. That left them unbuildable even for someone willing to run the tooling, which was never the intent -- the rules existed to keep the distribution tarball to what a packager needs.  
  
Remove the rules and have dist.sh trim the distribution root instead, keeping the same set: src, the root meson files, LICENSE, and the smoke test that packagers use to verify a build. The trimming runs before the generated files are added so the documentation source is replaced by the rendered man page and HTML rather than shipped alongside them.  
  
Trimming in a script rather than with export-ignore means git no longer guarantees the result, so check the full tarball path list in CI rather than just the generated files. Everything under src collapses to the directory since it changes too often to list, except the generated code, which stays listed so a failed generation is still caught. This catches a keep list that drifts as well as a missing dist marker, and the zero-size check widens to every file since none that ships is empty.  

M .gitattributes
M .github/workflows/test-dist.yml
M build/dist.sh

Fix stale Perl script names in the dist script.

commit   : ee5664233ca851e96f9cc11f53ef3af7dd80edb2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:33:36 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:33:36 +0700    

Click here for diff

The comment and the error message in build/dist.sh still tell the user to run doc/release.pl, which has not existed since the documentation tooling moved to Python. Point both at doc/release.py, which is what the dist job runs to render the man page and HTML that meson dist copies into the tarball.  

M build/dist.sh

Add reformatting and rename commits to .git-blame-ignore-revs.

commit   : 43a12cfffa45ef1b0303327e7634ea9be5085b13    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:02:38 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 17:02:38 +0700    

Click here for diff

The file has only ever held the commits that were added as they happened, so reformatting done before it existed, and reformatting nobody thought to add at the time, still stands between a line and whoever wrote it. Thirty-five commits are added, the earliest from 2018.  
  
The candidates were found by running git log --numstat over the whole history twice, once plain and once with -w, and comparing the two. A commit whose churn falls away when whitespace is ignored changed nothing but whitespace. The double space commits already in the file came back out of that scan, which is how the check was calibrated.  
  
Mechanical changes that are not whitespace are added as well. A rename carried through every file that used the name, a parameter added to or dropped from a macro everywhere it is called, a define moved to a header: each rewrites every line it touches without changing what any of them do, which leaves blame in the state a reformat leaves it.  
  
Commits that only touched files no longer in the tree were left out since ignoring them buys nothing. Most are from the Perl era or are expect logs, and the largest changed the spacing of 49537 lines that have since been deleted.  

M .git-blame-ignore-revs

Check the allow list an option documents against the declaration.

commit   : 3067394985e676364b08368b4ceb8dfd9550c9be    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 15:13:51 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 15:13:51 +0700    

Click here for diff

The values an option allows are declared in config.yaml and were written out again by hand in reference.xml as an ordinary list, so nothing held the two together. A value added, removed, or renamed in the declaration left the documentation describing something else and only a reader would find it.  
  
An allow list is now marked as what it is and its values are checked against the declaration. Where each value needs an explanation allow-item gives it and caption says what the list is a list of, which renders the sentence that was written above each list by hand. Where the values speak for themselves the tag is left empty and the values are rendered from the declaration into the sentence around it, so they are not written out at all. Where an option allows what another option allows the list is inherited, the way the declaration inherits the values themselves, which is what log-level-file, log-level-stderr, and pg-host-type do.  
  
Documenting the list is required rather than optional, since a value a user cannot read about is a value they cannot use. That turned up four options that never listed their values. buffer-size and repo-sftp-host-key-hash-type render the sentence they already had from the declaration, and repo-retention-archive-type and repo-retention-full-type described a value per paragraph, which is now the list it always was.  
  
The markup is resolved where the help is parsed, so the help in the binary and the documentation are both rendered from it and no renderer knows it exists. Everything else in the documentation renders exactly as it did, except for four introductions reworded to fit the caption.  

M CONTRIBUTING.md
M build/lib/help/parse.py
M doc/xml/contributing.xml
M doc/xml/reference.xml
M test/src/module/build/help/parse_test.py

Center the page title beside the logo and left align it when wrapped.

commit   : 940f4311375dda3caabdc0bdd01c36bedf43d46f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 13:48:55 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 13:48:55 +0700    

Click here for diff

The title of a page sits on the same line as the logo and was pushed to the end of that line by an auto margin, so it hung off the right edge rather than reading as balanced against the logo. Centering it is not just a matter of aligning it, since a title centered by an auto margin at each end or by text alignment stays centered when the window is too narrow to hold it beside the logo and it wraps to its own line, where a line of its own should start at the left.  
  
The space either side of the title is now a flexible item of the header rather than a margin, and the two grow equally, which centers the title in what the logo leaves over. The space that follows the title asks for no room of its own, so a line too narrow to hold both breaks before the title rather than between the title and the space that follows it, and the title starts its line at the left. The space before the title also holds it off the logo, which the column gap of the header did before.  
  
Below the narrow breakpoint the title is still given the full width so it goes under the logo. The space above and below it is halved there, since a row of its own costs a small screen height it has none of to spare, which leaves the header shorter than it was.  

M doc/resource/html/default.css
M doc/xml/release/2020s/2026/2.60.0.xml

Add a linter for lines that exceed 132 characters.

commit   : 3650bfe69bd6f29063af8ca749784ee266f40274    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:42:11 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:42:11 +0700    

Click here for diff

The line length has been in the coding standards for a long time but nothing checked it, so lines crept over and were fixed by hand after the fact. The check now runs with the other linters on every test run, before anything is built, so a long line is reported the way a non-ASCII character or a bad StringId already is.  
  
The check is excludes rather than includes so a file that is added is checked without anyone remembering to add it. Documentation is exempt, which the coding standards already allow for, as is markdown, written a paragraph per line so the editor wraps it, vendored source, kept as upstream wrote it, and the fixtures, certificates, and generated resources that are not source at all.  
  
Ignore the commits that fixed the long lines for blame since they are only reformatting.  

M .git-blame-ignore-revs
M test/define.yaml
A test/lib/command/lint/line.py
M test/lib/command/lint/lint.py
M test/src/module/test/command/lint/lint_test.py

Fix lines that exceed 132 characters.

commit   : 3f590f7012e919174948d35dd75ea804ad46dedb    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:35:14 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:35:14 +0700    

Click here for diff

Most of these are prose in a python docstring or comment wrapped a few characters wide, which black does not reflow, and macro continuation backslashes left stranded past the margin. The rest are long calls broken after the first parenthesis, section separator comments with too many dashes, and long expectation strings split along what they hold. A few long shell commands in the workflows are wrapped with continuations.  
  
hrnTestResultDiff() had a continuation backslash left over from when it was a macro, which spliced the closing brace onto the line above it. It is removed.  
  
The linter that enforces the limit follows, so this commit is only the reformatting and can be ignored by blame.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M .github/workflows/test-misc.yml
M build/build.py
M build/dist.sh
M build/lib/command/main.py
M build/lib/common/error.py
M build/lib/common/pack.py
M build/lib/common/xml.py
M build/lib/common/yaml.py
M build/lib/config/common.py
M build/lib/config/parse.py
M build/lib/config/render.py
M build/lib/error/render.py
M build/lib/help/parse.py
M build/lib/help/render.py
M doc/doc.py
M doc/lib/command/build/man.py
M doc/lib/command/build/pre.py
M doc/lib/command/build/reference.py
M doc/lib/command/render/execute.py
M doc/lib/command/render/host.py
M doc/lib/command/render/manifest.py
M doc/lib/command/render/render.py
M doc/lib/common/eval.py
M doc/lib/common/exec.py
M doc/lib/common/user.py
M doc/lib/common/var_store.py
M doc/release.py
M src/command/backup/common.h
M src/common/compress/bz2/decompress.c
M src/common/io/http/request.h
M src/common/io/http/session.h
M src/common/type/buffer.h
M src/common/type/json.h
M src/config/parse.h
M src/protocol/client.h
M src/storage/readMulti.h
M src/storage/s3/storage.intern.h
M src/storage/sftp/storage.c
M src/storage/sftp/write.c
M src/storage/storage.h
M test/lib/command/test/python.py
M test/src/harness/libSsh2.c
M test/src/harness/manifest.h
M test/src/harness/server.h
M test/src/harness/test.c
M test/src/module/build/command/main_test.py
M test/src/module/build/common/pack_test.py
M test/src/module/build/common/xml_test.py
M test/src/module/build/common/yaml_test.py
M test/src/module/build/config/common_test.py
M test/src/module/build/config/parse_test.py
M test/src/module/build/config/render_test.py
M test/src/module/build/error/render_test.py
M test/src/module/build/help/parse_test.py
M test/src/module/build/help/render_test.py
M test/src/module/build/postgres/parse_test.py
M test/src/module/command/infoTest.c
M test/src/module/command/repoTest.c
M test/src/module/common/cryptoTest.c
M test/src/module/common/io/httpTest.c
M test/src/module/common/type/variantTest.c
M test/src/module/doc/command/build/man_test.py
M test/src/module/doc/command/build/pre_test.py
M test/src/module/doc/command/build/reference_test.py
M test/src/module/doc/command/render/execute_test.py
M test/src/module/doc/command/render/release_test.py
M test/src/module/doc/command/render/render_test.py
M test/src/module/performance/storageTest.c
M test/src/module/storage/s3Test.c
M test/src/module/storage/sftpTest.c
M test/src/module/test/command/test/job_test.py
M test/src/module/test/command/test/test_test.py

Add missing vm_covered annotations to the storage modules.

commit   : 21cd6268eb12a96bb1e1c60bf715f31bc67089e5    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:24:33 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 12:24:33 +0700    

Click here for diff

The test that moves a file out of a path created with sudo is only built when TEST_CONTAINER_REQUIRED is defined, which leaves the branch in storagePosixMove() for an errno other than ENOENT untaken outside a container. The tests that move a file across a filesystem boundary depend on the unit path being a bind mount while /tmp is not, which is also only true in a vm, so rename() succeeds instead of failing with EXDEV and the copy fallback in storageMove() is never reached.  
  
Part of the EXDEV case was already annotated but the rest was missed, so the storage tests report coverage errors when run outside a container. Coverage only runs in a vm in CI, where the annotation is ignored, so this does not hide a coverage regression.  

M src/storage/posix/storage.c
M src/storage/storage.c

Test verify against an encrypted repository.

commit   : cb50a5d4fcecbb58b12cb5604fb236d11a65f7ee    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 11:27:28 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 7 Aug 2026 11:27:28 +0700    

Click here for diff

The verify tests never ran the command against an encrypted repository. Only verifyFile() had an encrypted case and it is handed the passphrase directly, so nothing covered the info files, the manifests, or the passphrase being read out of backup.info and passed down to the file jobs.  
  
Add an encrypted case to the info file tests and a second, encrypted repository to the test that builds a repository with the commands. Verify works against one repository at a time, so repo1 stays in the clear and repo2 is encrypted, which also puts a multi-repo configuration in place for when verify checks all repositories.  
  
The WAL segments that hrnBackupPqScript() writes were left in the clear even when the caller asked for a cipher. Backup never reads them so it did not matter, but verify does.  

M test/src/harness/backup.c
M test/src/module/command/verifyTest.c

Remove the repo-put command and the cipher-pass option.

commit   : f26bfb32f776983297e19d476e3eb5a4de6a4a4d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 19:57:40 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 6 Aug 2026 19:57:40 +0700    

Click here for diff

Both are internal and in the past the integration tests used them. They were left in place for possible administration, investigation, and testing but in fact those uses have not materialized. The repo format has moved on so repo-put is now actively dangerous (it always was, really).  
  
Without cipher-pass, repo-get always determines the passphrase from the repository: the repo passphrase for the info files, and the passphrase each of those carries for the files below it. That was already what happened whenever the option was not given, so what is left is a single path rather than two.  
  
The tests now write the repository directly with the storage harness, which is all the get tests needed from repo-put.

M build/config.yaml
M doc/xml/reference.xml
M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/repo/get.c
D src/command/repo/put.c
D src/command/repo/put.h
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M src/main.c
M src/meson.build
M test/define.yaml
M test/src/module/command/repoTest.c

Put the bar of a section2 header under the title rather than beside it.

commit   : 17de50192b7e40d7517a4079451e93da5c39b3f3    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 19:38:46 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 19:38:46 +0700    

Click here for diff

A reviewer found that a section2 header no longer set its section apart the way the old header did. Each level is marked by a bar that thins as the level drops, and at the second level that bar ran down the left edge of the title. A bar beside a title marks where the title starts but closes nothing off, so where one section ended and the next began was left to the size of the type to carry.  
  
The bar now runs under the title, in the accent the bar of the level above is drawn in, and it stops where the title stops. A title too long for the column wraps and takes the rule out to the full width, which is as far as it can go. The space under the header grew a little to give the rule room.  

M doc/resource/html/default.css
M doc/xml/release/2020s/2026/2.60.0.xml
M doc/xml/release/contributor.xml

Add missing format to the info files in the repo tests.

commit   : 128cacf1ce6beebe7e0253b093030f8318d743f2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 15:08:05 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 15:08:05 +0700    

Click here for diff

The buffers that stand in for archive.info and backup.info were written by hand and left out backrest-format, which every info file in a repository has and which harnessInfoChecksumZ() supplies for the buffers it builds. Nothing in these tests looked at the format, so the omission went unnoticed even though the buffers are put in the repository and read back as info files.  
  
The checksums change because they cover the added key.  

M test/src/module/command/repoTest.c

Reuse large freed allocations from a memory context free list.

commit   : 702d664f8815582ab5206121f047e163596763ac    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 11:50:33 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Thu, 6 Aug 2026 11:50:33 +0700    

Click here for diff

Commands that process many files, e.g. backup, cycle through large allocations (buffers, etc.) in short-lived contexts. When these are freed the C library may return the memory to the kernel and fault it back in for the next file. Minor page faults are cheap on some hosts but expensive on others, e.g. x86 with kernel page-table isolation, where the faults have been observed to more than double backup time. Whether the memory is returned depends on heap layout, which varies from run to run with address randomization, so performance can also vary significantly between otherwise identical runs.  
  
Retain large freed allocations on a size-keyed free list in the memory context manager and hand them back for allocations of the same size. This keeps the pages resident and makes reuse deterministic, independent of the C library and heap layout. Only allocations above a threshold are recycled since smaller allocations are served well by the allocator's own arena and do not cause the churn. Retained memory is bounded by a per-size block limit and an overall byte limit so the cost stays a few MiB per process at the default buffer size.  
  
The first free list bucket is permanently reserved so it is never assigned to an arbitrary size. main() reserves it for ioBufferSize() after the configuration is loaded, which guarantees the size that churns most always has a bucket even when many other large sizes are freed first, and that it is checked first.  
  
Each bucket tracks how many times it has been reused, up to a limit. Once all buckets are in use a new size evicts the least reused bucket and the reuse of every remaining bucket is decremented. A new bucket starts with a reuse of one so it is not evicted before it has had a chance to be reused, which would leave two sizes trading the same slot and never settling. Specialized one-off sizes, e.g. from config parsing, never get reused so they erode immediately, while a bucket that was reused a lot but is no longer being reused erodes over time and is eventually evicted. The reserved bucket is never evicted.  
  
Because retained memory is not returned to the allocator, Valgrind can no longer detect use-after-free or uninitialized reads on a recycled block. Add memcheck client requests that poison a block while it is on the free list and mark it undefined when it is reused, which restores both. The requests are compiled into debug builds when the Valgrind header is available, which includes the unit tests, and are no-ops when not run under Valgrind.

M doc/xml/release/2020s/2026/2.60.0.xml
M meson.build
M src/common/memContext.c
M src/common/memContext.h
M src/main.c
M test/src/module/common/memContextTest.c

Fix lines that exceed 132 characters.

commit   : 9b3831242c11712008d9c4f013a81b69ff80e544    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 10:45:31 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 6 Aug 2026 10:45:31 +0700    

Click here for diff

M src/command/manifest/manifest.c
M src/info/manifest/manifest.c
M test/src/module/command/backupTest.c

Combine prior block incremental map reads during backup.

commit   : 5ae4015d37081eba4259f4de379a9d160236de0e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 5 Aug 2026 16:10:43 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 5 Aug 2026 16:10:43 +0700    

Click here for diff

During a block incremental backup, each file based on a prior backup reads that backup's block map to determine which blocks have changed. Previously each map was read with its own open and read, which is slow against an object store where every read is a separate round trip.  
  
Order the files so the prior maps are laid out in read order and read them all through a single StorageReadMulti, combining many small reads into one sequential pass. Adjacent maps can be read over so they are fetched together.  
  
Files are ordered at two levels. The process queue orders bundled files to mirror the prior backup they reference (by reference, then bundle id, then offset). Within a backup job the files are ordered block incremental first (by prior map file, then map offset) and then whole files by size, which keeps the maps together and in order and places the smallest whole files nearest them. Keeping files in the same relative order from one backup to the next also lets the same read pattern benefit future backup and restore.  
  
The backupFileComparator() test shim places pg_control at the end of the bundle because its contents vary by architecture and would otherwise shift bundle offsets, making test output non-reproducible across architectures.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/command/backup/file.c.inc
M src/command/backup/process.c.inc
M src/command/backup/protocol.c
M test/define.yaml
M test/src/harness/backup.c
M test/src/harness/backup.h
M test/src/module/command/backupTest.c
M test/src/module/command/manifestTest.c
M test/src/module/command/verifyTest.c

Fade an end the menu carries on past rather than cutting it flat.

commit   : 46babedaed8429026efa4592d93a8d9876acad0b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 22:45:46 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 22:45:46 +0700    

Click here for diff

A reader on a phone found no sign that the menu had more in it than the window was showing. The menu scrolls sideways when it is wider than the window, but a phone draws the scrollbar only while a finger is on it, so at rest the menu simply stops at the edge of the window and nothing suggests there is any more of it.  
  
An end the menu carries on past now fades out. The script measures which ends those are, on the menu scrolling and on the window changing, and says so in a class on the bar. Both ends fade when the menu carries on both ways, and neither fades when the whole menu fits, which is every window wide enough to center it.  
  
The fade is drawn over the bar rather than cut out of the menu with a mask. A mask on the menu is measured against everything the menu can be scrolled to rather than against the part of it the window is showing, so on a menu with padding it lands somewhere off the side of the window and is never seen. It is drawn inside the rules the bar is drawn with, so those still run the full width whichever end is fading.  
  
It is drawn in a color of its own rather than in the color of the bar. Where the menu breaks between one page and the next there is no text beneath the fade for it to take, and a fade in the color of the bar would be a fade over nothing. That color is the rule color against a light bar and a shade inside it against a dark one, since the same step away from the bar reads harder against a near-black bar than against a white one, and the band holds it across its near fifth rather than reaching it only in the last pixel.  

M doc/resource/html/default.css
M doc/resource/html/default.js

Jump straight to a section rather than gliding to it.

commit   : ec57431aa88860b9058d971b938afb5e35a142c7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 21:51:18 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 21:51:18 +0700    

Click here for diff

The glide was set on the page as a whole, so it applied to every link into the page rather than only to the links in the contents. Chrome scales the time it takes with the distance, and the command reference runs to nearly two hundred sections, so a link near the end of the contents left the reader watching the page scroll rather than arriving at what they asked for. Firefox and Safari hold the time to something short however far the jump, which is why this only showed up on Chrome.  
  
Also ask for the distance a section sits below the menu bar once rather than on every frame of a scroll. Only a change in the window can change it, so it is asked for again when the window changes.  

M doc/resource/html/default.css
M doc/resource/html/default.js

commit   : b8da5b061d0f9c5d1cc01e936dc65cbdd9f70be7    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 20:34:46 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 20:34:46 +0700    

Click here for diff

A browser caches by url and, with no cache headers to say otherwise, judges freshness by how long a file has gone unchanged, so after a deploy a reader could see new pages with the old style until the cache let go. Carry a short hash of the rendered content in the links to the style and the script, which changes the url when the content changes and leaves it alone when it does not. Since the hash is of the rendered content, a change to nothing but a comment changes no links, and one build of the documentation still comes out the same as the next.  

M doc/lib/command/render/html.py
M test/src/module/doc/command/render/html_test.py

Highlight the current page in the menu rather than leaving it out.

commit   : 9ae2c32a35f56f53f299e8c04f004ed3386b95e4    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 19:59:42 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 19:59:42 +0700    

Click here for diff

Readers found it confusing that the page they were on disappeared from the menu, so keep it in place marked by full-strength text and an accent underline. It is still not a link, since a page never links to itself, which is also how the logo behaves on the index.  

M doc/lib/command/render/html.py
M doc/resource/html/default.css
M test/src/module/doc/command/render/html_test.py

Scroll the menu sideways rather than wrapping it out of view.

commit   : b5d527471444f90df5529f15726b3d33065f9829    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 19:06:57 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 19:06:57 +0700    

Click here for diff

The menu wrapped when it was wider than the window, but the bar holding it keeps its height, so the rows that wrapped were cut off. A phone reading the FAQ wrapped the menu to three rows with room for two, and Metrics was on the third. The bar was already written to scroll sideways when the menu does not fit, but wrapping kept the row from ever being wider than the bar, so the scroll never had anything to do.  
  
The row no longer wraps, so a menu that does not fit scrolls. Centering moves from the bar to the margins at the ends of the row, since a centered row wider than its box hangs out both ends and the start end is past where a scroll can reach. The margins center the row when there is room and give way when there is not. The scrollbar is the thin one the blocks that scroll already use.  

M doc/resource/html/default.css

Render option type examples as code blocks in the reference.

commit   : 5b8987da0bb24fc3979fb764ccbde82b7b0e9500    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 17:36:33 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 17:36:33 +0700    

Click here for diff

The examples were inline settings, which do not allow wrapping, so on narrow screens they overflowed the text and forced the page to scroll horizontally. Code blocks scroll on their own when they do not fit and match the generated default/example blocks that appear with each option.  
  
This also allows the list and key/value configuration file examples to be shown with each setting on its own line, as it would be written in the file, rather than just describing that.  

M doc/xml/reference.xml

Fix incorrect header comment.

commit   : 435eb5dc3adfaf956acafb0a59a2cec13596b48c    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 09:49:16 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 3 Aug 2026 09:49:16 +0700    

Click here for diff

M src/command/stanza/upgrade.c

List the most recent news items on the index automatically.

commit   : b56b6ab4d24ff6298b95b415b3115591e311de20    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 19:50:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 19:50:22 +0700    

Click here for diff

The three items the index lists were written on it by hand, so every news item was written twice: once as the news and again as a date, a title, and a link on the index. Nothing kept the two in step, so an item added to one and forgotten in the other would go unnoticed by anything that builds the documentation.  
  
The index asks for the items now and the build takes them from the news. The title on the index is the title of the item as the news writes it, so a title that changes on the news changes on the index as well, and the project name in one is shown there the way it is shown everywhere else.  
  
A news item says when it was posted as an attribute written the way a date sorts rather than as the paragraph the item begins with, so the date is written once and rendered both where the item shows it and where the index lists it. An item that is more recent than the item before it is an error, since the index lists the items the news begins with.  
  
The release notes render their date the same way, so the months are named in one place rather than in three.  

M build/lib/common/xml.py
A doc/lib/command/build/news.py
M doc/lib/command/doc.py
M doc/lib/command/render/release.py
A doc/lib/common/date.py
M doc/xml/index.xml
M doc/xml/news.xml
M test/define.yaml
M test/src/module/build/common/xml_test.py
A test/src/module/doc/command/build/news_test.py
M test/src/module/doc/command/doc_test.py
A test/src/module/doc/common/date_test.py

Speed up the documentation build.

commit   : 8f902c66609c0bf1de6720e60552adde515209cd    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 18:59:21 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 18:59:21 +0700    

Click here for diff

Building the user guide takes long enough to be worth measuring, and nearly all of the time is spent inside the commands the guide runs rather than in the tool that runs them. Two things in there were paying for nothing: commands that waited on a clock rather than on the thing they were waiting for, and a build of the project that happened twice. A user guide build measured here went from 191 seconds to 164.  
  
The wait after a cluster is started is a poll now rather than a sleep. Starting a cluster already waits for it to accept connections before it returns, so in the ten places this happens the two seconds after it were spent on a cluster that was already up. Where the start cannot wait, because the cluster is still in recovery, two seconds was a guess at how long recovery would take. Asking the cluster returns in under a tenth of a second where there is nothing to wait for and waits exactly as long as recovery takes where there is.  
  
The wait on the asynchronous archive-get log was the same thing at five seconds: it slept for a line that the command after it then goes looking for. It polls for that line now.  
  
The distribution tarball the build section makes is only there to stand in for a release download, but meson dist configures, builds and tests what it packs unless it is told not to, and the guide then configures, builds and tests the extracted tarball itself. Passing --no-tests takes that command from 11.9 seconds to 1.0 and leaves what the reader is shown alone, since the command is not one of them.  
  
A command the documentation does not show is no longer checked for being too wide to read. The check is there because the documentation is showing the line to be read, which a command marked show="n" never is, and a wait written to be run rather than read does not fit in eighty characters.  
  
The wait names the full path to pg_isready because sudo replaces PATH with secure_path on RHEL, where the packaging links psql into a path that survives and nothing else.  

M doc/lib/command/render/execute.py
M doc/xml/user-guide.xml
M test/src/module/doc/command/render/execute_test.py

Stop setting hot_standby in the user guide.

commit   : 38cdb1e53a357ee0d24412e1a27e3381efb8b03d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 16:19:14 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 16:19:14 +0700    

Click here for diff

The setting has been on by default since PostgreSQL 10, so the standby section and the upgrade section were setting it to what it already was. The upgrade section configured nothing else, so its configuration is gone rather than emptied.  
  
The standby is still configured with the archive settings, which are there in case it is promoted to a primary, and the text that went with the configuration now says that and nothing about read-only connections being refused.  
  
The setting is still worth knowing about even though it is not worth setting: a standby will not take read-only connections without it, and any cluster may be restored as a standby later. That is in the quickstart now, where the rest of the PostgreSQL configuration is done, rather than at the point a standby is created.  

M doc/xml/user-guide.xml

commit   : 2f3b2f22a30a35340549d42b09d5aaa58d9dfe61    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 14:02:21 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 14:02:21 +0700    

Click here for diff

Clicking a logo is expected to go home, but ours was drawn as a pseudo-element on the header and so did nothing. Draw it as an anchor instead, with the project name held for a reader who cannot see what it is drawn as. On the index the logo is not a link, since the menu leaves out the page a reader is on as well.  

M doc/lib/command/render/html.py
M doc/resource/html/default.css
M test/src/module/doc/command/render/html_test.py

Error when a config section changes nothing the reader can see.

commit   : 1fb93f92725124d6afa8953b7c7243aee92fcaf5    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 13:49:04 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 13:49:04 +0700    

Click here for diff

A config file is shown in full every time a section changes it and every line is marked with what the change did to it, so a section that changes nothing shows the reader a file with nothing marked in it and leaves them looking for what changed.  
  
A change that is shown but leaves the file as the reader last saw it is now an error. The file is compared against the file as the document last showed it, so a change that is not shown is not an error: it is left for the next change that is shown, which is where it will be marked.  
  
The user guide had two of these. The full backup retention section set repo1-retention-full=2, which the quickstart had already set, so it now says where the option was set rather than setting it again. The standby section enabled hot_standby again on RHEL, where a restore had overwritten postgresql.conf, but the file the document had shown the reader still held the setting, so there was nothing to mark. The paragraph explaining why it was set again goes with it, since hot standby is always enabled in current versions of PostgreSQL.  
  
A file with nothing in it falls under the same rule, since a section that shows the reader an empty file says nothing either, so the renderer no longer carries a message for a configuration that has no lines in it.  

M doc/lib/command/render/execute.py
M doc/lib/command/render/html.py
M doc/xml/user-guide.xml
M test/src/module/doc/command/render/execute_test.py
M test/src/module/doc/command/render/html_test.py

Copy a command as well as a config file.

commit   : 578fc2de14a6a739401fd58b9a232cf587c52fb4    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 09:38:13 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 09:38:13 +0700    

Click here for diff

A reader following the user guide runs the commands in it, which means copying each one out of the page. Selecting a command by hand also selects the prompt it is written at, which the style writes and which is not part of the command. Each command now has a copy button that hands over the command alone.  
  
It is the same button that copies a config file, written once and used in both places. It copies what the block is showing: a command, or a config file without the lines the section took out. The config button stays in the title of the block. The command button sits on a row with the command, so a command too wide for the page scrolls in the space beside it rather than under it.  
  
The user guide has many commands, and a button on every one would be a column of buttons down the page. The button now appears when the pointer is over the block it copies, or when a reader working from the keyboard focuses it, which is where a reader already is when they go to copy something. It fades in rather than being hidden, so showing it moves nothing on the page, and it stays up while it says whether the copy worked. A device with no pointer cannot hover, so where a browser reports none every button is shown.  

M doc/lib/command/render/html.py
M doc/resource/html/default.css
M doc/resource/html/default.js
M test/src/module/doc/command/render/html_test.py

Mark the output of a command the way a config file is marked.

commit   : bd33623ba945ed251e2a6704cea14a81d51c2c9e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 00:51:12 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 00:51:12 +0700    

Click here for diff

The output of a command was drawn as a box for each run of lines: the run the documentation is pointing at in one, the lines above and below it in others, each with its own margin, its own corners and its own sideways scroll. That set the run apart from the lines that give it its meaning, and scrolling a long line to the end took one box across and left the lines around it where they were. The user guide shows the output of eighty-five commands, drawn as two hundred and seventeen boxes.  
  
The output of a command is now one block, laid out the way a config file has been laid out since the lines of one were marked: the lines sit in a block of their own inside the block that scrolls, and a run being pointed at is a color across the width of the block rather than a box beside it. The whole of the output moves together, and a run that is marked is marked to the end of the widest line however far it has scrolled.  
  
The style defines that layout once for both. What a command wrote and what is in a config file take the same rule and differ only in what marks a line: the color of what is being pointed at or of an error for output, and a plus or a minus with the color of a change for a file.  
  
What scrolls is now the output rather than each run of it, so that is what carries the focus stop the script gives to a block that scrolls and takes back from one that does not.  

M doc/lib/command/render/html.py
M doc/resource/html/default.css
M doc/resource/html/default.js
M test/src/module/doc/command/render/html_test.py

Mark the lines a section changes in a config file.

commit   : 078d52c5768bb67989624b5418fdd687fab9e485    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 00:31:05 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sun, 2 Aug 2026 00:31:05 +0700    

Click here for diff

A config file is shown in full every time a section changes it, so a section that adds one option to a file that has grown to forty leaves the reader to find which line is new. By the time the user guide has configured the object stores, pgbackrest.conf is forty-two lines of which four are what that section is about.  
  
Every line now shows what the change did to it. A line the section added is marked with a plus and carries the color the highlighted output of a command carries, a line it took out is marked with a minus and carries the color an error carries, and a line it left alone is marked with neither.  
  
The file is compared against the file as the document last showed it rather than against the file as it was. A change a section makes but does not show is left for the next change that is shown, so nothing the reader is shown goes unmarked, and a file that is reset has nothing to compare against and is shown as all new, which is what the reader has in front of them. The comparison is made after the cache is replayed rather than before it is stored, so the cache is untouched and a build that has one runs nothing it did not run before.  
  
What is on the page is now a change to the file rather than the file, so the title carries a copy button for the file: the lines that are in it, without the lines that were removed. The marks are written by the style rather than into the line, so they were never part of what is copied. The button is written into the page hidden and shown by the script, since a browser that cannot copy is better off without a button that does nothing.  
  
The icon the button is drawn with is in the style rather than in the page, which would otherwise carry it once for every file the documentation shows, and it is drawn as a mask rather than as an image so that it takes the color of the button. That is what lets the button say what it did without being drawn again: a tick where the file was copied, and the color of a warning where the browser refused to.  
  
The lines are held in a block of their own inside the block that scrolls, so a line the change marks is marked across the width of the widest line in the file rather than across the width of the part of it that is in view.  

M doc/lib/command/render/execute.py
M doc/lib/command/render/html.py
M doc/resource/html/default.css
M doc/resource/html/default.js
M test/src/module/doc/command/render/execute_test.py
M test/src/module/doc/command/render/html_test.py

Document exception for dots in S3 bucket names when uri-style=path.

commit   : 6cf101d340aa3cd9379764844d2f43e9d79b8868    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:45:06 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:45:06 +0700    

Click here for diff

Path-style URIs prepend the bucket to the URI rather than the host, so the certificate is matched against the endpoint alone and dots in the bucket name do not cause a host name mismatch.  

M doc/xml/faq.xml

Clarify the command titles in the command reference.

commit   : dc3af7b2aee5778dbe9200e31d5a2f95ecae5777    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:42:07 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:42:07 +0700    

Click here for diff

A command is headed by its title and then its name, e.g. Stanza Start (start), and the contents list the title alone. Ten commands had a title that was just the name with a capital letter, so the heading said the same word twice and the contents said no more than the name already did. Start, Stop, Info, Verify and Check give the verb and leave out what it acts on, which is the part a reader scanning the contents needs.  
  
The titles now say what the command acts on, in the naming the reference already used for the commands whose name says it: repo-ls is Repository List and stanza-create is Stanza Create, so info and verify are Repository Info and Repository Verify, and start and stop are Stanza Start and Stanza Stop. server and server-ping are TLS Server and TLS Server Ping, TLS being what the server speaks. annotate, check, expire and restore are Annotate Backup, Check Configuration, Expire Backups and Restore Backup.  
  
A title that says what the command does on its own is left as it was, even where it is the name, e.g. Backup, Help and Version. The names are untouched, and nothing but the reference changes: the title is written for the documentation, and the command line help and the man page render the summary instead.  

M doc/xml/reference.xml

commit   : 3e86c71f5546c86941f35d5be3a7fdcd98af3cca    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:35:16 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:35:16 +0700    

Click here for diff

The command reference described every option a command takes in full, under every command that takes it. Since most options are valid for most commands the same description was written out again and again: --config was described eighteen times, and the backup command alone ran to a hundred and thirty options, of which fifteen are actually about backup. The page came to 1.2 MB and reaching a command meant scrolling past everything before it.  
  
An option is now described once. The options a command documents itself, which are the ones whose meaning depends on the command, stay with the command as they were. The rest are a list of links, each naming the option the way the heading at the other end names it, e.g. Repository Host (--repo-host), and pointing at the configuration reference where it is described. The page is 350 KB now.  
  
An option that can only be given on the command line, e.g. --config or --stanza, is not in the configuration reference because it cannot be set in the configuration file, so there was nowhere for it to link to. These are described in a General Options section before the commands and each command links there. They are left out of the contents, since the contents list the commands rather than the options each of them takes.  
  
A link now renders the markup it holds rather than its text alone, so an option named in a list keeps the monospace it has in a heading. Every link written in doc/xml holds text alone and every page but the command reference is byte for byte what it was. A link may also hold text and markup at once when a document is normalized, since otherwise a hand-written document could not say what the generated one says.  
  
The configuration reference is unchanged.  

M build/lib/common/xml.py
M doc/lib/command/build/reference.py
M doc/lib/command/render/render.py
M doc/xml/reference.xml
M test/src/module/build/common/xml_test.py
M test/src/module/doc/command/build/reference_test.py
M test/src/module/doc/command/render/render_test.py

Remove the contents from the releases page and close up the date.

commit   : 76ae49456c7dace20bbd6f17e8acdfd36bf03a46    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:32:04 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 20:32:04 +0700    

Click here for diff

The contents held the introduction, the groups the releases are listed in, and whichever release is newest. That was as much as they could hold, since listing every release would run to well over a hundred entries, and each of those few is already on the first screen of the page. So a column of the page went to repeating what the reader could see without it. The document says toc="n" now and the notes have the whole width to themselves.  
  
The flag that kept the rest of the releases out of the contents goes with it, there being no contents left to keep them out of. The attribute a section sets to stay out of the contents is untouched, and the command reference still uses it for the option categories it lists.  
  
The date a release came out had as much space above it as below, so it sat between the title of the release and the notes rather than with either, and read as a line of its own. It has no margin above it now and sits directly under the title.  

M doc/lib/command/render/release.py
M doc/resource/html/default.css
M test/src/module/doc/command/render/release_test.py

commit   : ab8df23807cc4c11768ef40196bcade70ebec23a    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 19:14:39 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 19:14:39 +0700    

Click here for diff

The image a link to the documentation showed where it was posted was the project logo at 96 by 96. The sites that show a preview lay one out in a frame of 1.91 to 1 and crop what does not fit, so a square image lost about a quarter of its height off the top and another off the bottom, and the logo was drawn edge to edge with nothing to give the crop. It was also well under the 1200 by 627 those sites ask for, so what survived the crop had been scaled up from too little to begin with.  
  
The card is laid out at 1200 by 630, which is the shape the frame already is, and carries the header lockup -- the mark, the name and the tagline -- at 960 wide and centered. That is the middle 80 percent of the card, which also leaves the whole of it inside the 2 to 1 frame X crops to. The background is opaque because a site composites what is transparent onto a background of its own choosing, and dark artwork on a background nobody has said anything about is a poor bet.  
  
The variable that names the image is project-card and the one that turns it off is card, since a 1200 by 630 preview image is not a logo. logo.png went with them, the preview being the only thing it was for, and it is the entry that changes in the linter skip list. logo.svg stays and is unaffected: it is the favicon, and it is the bare mark rather than the lockup.  
  
A page now says how big the card is, what it shows, and that a preview of it is a card rather than a thumbnail beside the text. The size is read out of the file rather than written down beside it, so the two cannot disagree.  
  
The html-logo variable is removed. It was never set, and the div it added was hidden by the style from the point the header began drawing the mark and the name itself, so it had been dead since the site was modernized.  

M doc/lib/command/render/html.py
M doc/manifest.xml
M doc/release.py
A doc/resource/card.png
M doc/resource/html/default.css
D doc/resource/logo.png
M test/lib/command/lint/lint.py
M test/src/module/doc/command/doc_test.py
M test/src/module/doc/command/render/html_test.py
M test/src/module/test/command/lint/lint_test.py

Modernize the website.

commit   : 60b85c58d98dd65d47747e6355f47c934cd6b430    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:34:16 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:34:16 +0700    

Click here for diff

The style had grown a dark mode that set the color of the page and the color of a link and left everything else at the value it was given for a light page. A table header ruled itself with a black line against a near black background, the footer ruled itself with a near white one, and a section heading sat under the contrast a reader needs. Every color is now a token declared once and declared again for a dark page, so the two are designed together and neither is left behind when the other changes. A rule that names a color directly steps outside that, which is worth watching for in review since only the tokens are redeclared.  
  
The contents move into a column beside the text on a window wide enough for one and stay in view while the page scrolls. This is what the pages needed most: the configuration reference lists over a hundred and fifty entries and reading one meant scrolling back to the top to find the next. It is the same contents the page already carried rather than anything new, so a window too narrow for a column puts them back above the text where they were.  
  
That brings the first script the site has. It marks the section being read in the contents and takes the focus stop back off the blocks that turned out to fit, and the page works without it: the contents are rendered as links and work as links, and every block that can scroll says it is reachable from the keyboard whether or not the script runs.  
  
A section can now say four things about how it is rendered. A section with header="n" leaves its header off the page where the title of the page already says what the header would say, toc-title gives the contents a shorter title than the heading, aside="y" puts a section down the side of the page with the text running beside it and returning to full width once it ends, and html="n" keeps something out of the html for a document that needs it in another rendering. The reference pages use the first two, the index uses the last two for the sponsors. A condition cannot say any of this, since a condition is evaluated once for the document rather than once for each way it is rendered.  
  
Sections are no longer numbered. The numbering was three counters in the style that produced a number nothing could cite, since it is computed when the page is rendered and changes whenever a section is added or moved, and every cross reference in the documentation already links by title and anchor. The markdown never numbered, so the html was the last place it survived. The toc-number attribute went with it, and the releases document, which was its only user, now says what the project is in its title and what the page is in its subtitle like every other document.  
  
A command, its output, and a config file are shown as they were written and scroll sideways when they do not fit, since a line broken to fit is harder to read and copies back as something that was never typed. Each block scrolls on its own so the inset background of an output block stays with the block rather than ending where the panel does.  
  
The header is one row: the mark and the name on the left, what the page is on the right. The mark and the name are one file rather than an image beside a heading, drawn as itself rather than masked so it keeps the colors it was drawn with and the ones it was drawn with for a dark page.  
  
A page said nothing about how wide it was, so a phone laid it out at the width of a desktop and scaled the result down to fit, which is why the style had nothing to say about a narrow screen. Every page now carries a viewport meta tag and the style has rules for a screen narrower than the reading column.  
  
The style and the script are written out with their comments taken out, since the page carries them for a browser rather than for a reader, but where their lines fall is left alone so one build of the documentation can still be compared with the next.  

M .github/workflows/test-dist.yml
M doc/lib/command/build/reference.py
M doc/lib/command/render/html.py
M doc/lib/command/render/release.py
M doc/lib/command/render/render.py
M doc/resource/html/default.css
A doc/resource/html/default.js
A doc/resource/slogo.svg
M doc/xml/faq.xml
M doc/xml/index.xml
M doc/xml/metric.xml
M doc/xml/release.xml
M doc/xml/user-guide.xml
M test/src/module/doc/command/build/reference_test.py
M test/src/module/doc/command/doc_test.py
M test/src/module/doc/command/render/html_test.py
M test/src/module/doc/command/render/release_test.py
M test/src/module/doc/command/render/render_test.py

Move the sponsors down the index and shorten the appeal.

commit   : b668d71f22489cbd4d5e120e7e6e73be0ca9e167    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:21:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:21:22 +0700    

Click here for diff

The sponsors sat between the news and the features, so reaching what pgBackRest actually does meant scrolling past nine logos first. In preparation for putting them in a column down the side of the page, where they can be near the top without being in the way, the section moves to the end of the document. Until that column exists the page shows them where the document now puts them, which is below the features rather than above.  
  
The appeal said in three sentences what it now says in two, and the list of what sponsorship pays for was five verb phrases where five nouns say the same thing. A column has a good deal less room than a page.  

M README.md
M doc/xml/index.xml

Shorten and disambiguate the documentation titles.

commit   : 5bc14b581a054ab3dc9cd633954a689acc99a58f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:11:35 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 18:11:35 +0700    

Click here for diff

The titles were written to be read across the width of a page. In preparation for putting the contents in a column beside the text, where a title has a good deal less room, several of them are shortened to what they actually say. Three sections of the user guide were named "X-Compatible Object Store Support", which said "Compatible Object Store Support" three times over; they are "S3 Support", "Azure Support" and "GCS Support" now, and the feature list on the index says the same. "Upgrading pgBackRest from v2.x to v2.y" sits directly under "Upgrading pgBackRest" and repeated it, so it is "From v2.x to v2.y". "Available User Guides" is "User Guides", which is what the menu has always called it.  
  
Four keys in the reference were called "Keep Alive" and some qualifier with nothing to say which belonged to the socket and which to TCP, which matters most where they are listed one after another. They say which they are now. Two more were the only titles in the reference not capitalized as titles, and one led with a verb where the reference reads as nouns everywhere else, so "Check WAL Headers" is "WAL Header Check".  
  
The user guide puts the project in the title and what the page is in the subtitle, rather than the whole name in the title. The header shows one on each side, which only works if the document says which is which.  

M README.md
M doc/xml/index.xml
M doc/xml/reference.xml
M doc/xml/user-guide-index.xml
M doc/xml/user-guide.xml

Remove the pretty and compact documentation render modes.

commit   : 317868779c36358cb88bd5ba6db39229260fa93a    
  
author   : David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 10:07:14 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Sat, 1 Aug 2026 10:07:14 +0700    

Click here for diff

The pretty mode indented the HTML and the compact mode embedded the CSS in the page. Both were added to generate single-page documentation for other projects and have no meaning anymore.  
  
Neither mode is enabled in manifest.xml so the rendered documentation is unchanged.  

M doc/lib/command/render/html.py
M doc/lib/command/render/manifest.py
M test/src/module/doc/command/render/html_test.py
M test/src/module/doc/command/render/manifest_test.py

Never run the tests or start a container on a dry run.

commit   : 52892e511660ea8d02fd5c33e7c47c4b5a9eeaf2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:53:25 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:53:25 +0700    

Click here for diff

A dry run with --vm-out ran the tests rather than listing them. This came from the Perl harness, where the option showed more about what a test would have run, but the python harness has no such mode so the tests simply ran, which is the opposite of what a dry run is for. Now the list is written whatever --vm-out says and nothing is started.  
  
A dry run also started the build container even though it builds nothing, and the command that shuts the container down only runs when the tests do. That left the container running so the next run failed on the name conflict, which a dry run does not clean up either. Now no container is started and no cleanup is done, so a dry run leaves nothing behind.  

M test/lib/command/test/job.py
M test/lib/command/test/test.py
M test/src/module/test/command/test/job_test.py
M test/src/module/test/command/test/test_test.py

Allow any PostgreSQL version installed on a vm to be tested.

commit   : 06880fb2e205f403a1234bb781cedba19178b3e8    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:35:36 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:35:36 +0700    

Click here for diff

The db-test list spreads the supported versions over the default vms so that a full run covers every version exactly once, but it also limited what --pg-version was able to select. That made it impossible to test a version on a vm that installs it but does not test it by default, e.g. --vm=u24 --pg-version=18 reported that no tests were selected even though u24 has PostgreSQL 18 installed.  
  
Select from the versions the vm installs when a version is named and make --pg-version=all mean every version installed rather than an alias for the default selection. A version the project does not support is now an error rather than a selection of nothing, since that is a typo just as an unknown module name is.  

M test/container.yaml
M test/lib/command/test/list.py
M test/lib/common/vm.py
M test/lib/config/cli.py
M test/src/module/test/command/test/list_test.py

Trim the stack trace the tools show and report ctrl-c as a signal.

commit   : 5c004ec3ce8ace90a92b7a72caa02be06bc556a5    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:21:47 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 23:21:47 +0700    

Click here for diff

A ctrl-c raised KeyboardInterrupt out of main() and python printed the traceback, which is the whole path from main() down to the select() the harness happened to be waiting in and says nothing about a run that was stopped on purpose. Catch it and log "terminated on signal SIGINT" as the C does, exiting with the status the C exits with for TermError.  
  
An unexpected exception is a bug in the tool so the trace is what gets it fixed, but the trace ends with the message that says what went wrong, which is where the message gets lost. Show the message first and keep only the innermost frames, since the frames above them are the path from main() into the tool and are much the same for every error. The frame count says how many were dropped and how to get them, and the complete trace, which is what python printed before, is shown at debug level the same way the C shows a stack trace only at debug level.  
  
The rendering is error_trace() in common/error.py, beside ToolError, so every tool reports a bug in itself the same way. The CI wrapper asks for the complete trace since a run there cannot be repeated at debug level to get it, and the code builder handles the interrupt but not the trace since it has no log level to raise.  

M build/build.py
M build/lib/common/error.py
M doc/doc.py
M doc/release.py
M test/ci.py
M test/src/module/build/common/error_test.py
M test/test.py

Select the release documentation to build by distro rather than vm.

commit   : 354f7f07c7ffd4b46a7b9fe9eaa6955d7378b2ce    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 22:46:41 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 22:46:41 +0700    

Click here for diff

The documentation defines the containers it runs its commands in, so it is built for a distribution rather than on a vm. Naming a vm meant mapping u24 to Debian/Ubuntu and rh9 to RHEL, a mapping that had no purpose except to be updated whenever those vms were retired.  
  
Accept --distro=debian or --distro=rhel instead, which are the os types the documentation already generates commands for, with no default so omitting the option still builds every distribution. The CI wrapper and the doc workflow pass the distro through.  

M .github/workflows/test-doc.yml
M doc/release.py
M test/ci.py

Move the vm definitions to test/container.yaml.

commit   : 8e5fcfa69052d6477a748020a58af9135d65f22f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 22:32:02 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 22:32:02 +0700    

Click here for diff

The definitions move out of common/vm.py into a vm section at the top of test/container.yaml, next to the container revisions that go with them, and are read and checked when the module is imported as they were before. The revisions move under a revision key to make room.  
  
The declaration is read with the loader the other declarations use, so a version keeps the text it was written as rather than arriving as a float. A new yaml_map_dict() reads a mapping as a dict where a key is looked up by name, which also reports a key that was written twice instead of silently keeping the last one.  
  
The constants that named each vm are gone. A vm is named by the string that --vm accepts, which is how the CI workflows and the documentation build already name them. A name used as a lookup is validated by vm_get(), and a name used in a comparison is protected by branch coverage since a comparison that can never match leaves a branch unhittable. VM_NONE, VM_ALL, and the os base and architecture constants remain because they name kinds rather than vms. VM_LIST is now the vms declared as default, so it is in declaration order.  
  
The container build no longer tests a vm by name. What a vm installs and configures is declared instead: valgrind, epel, powertools, python, coverage-python, pg-repo-key, pg-repo-release, dnf-module, and ssh-rsa. The three near identical blocks that added the PostgreSQL repo collapse into one, which removes the uncoverable branch annotation on the fedora branch. Every generated script is unchanged, so no image is rebuilt.  
  
The supported PostgreSQL versions are generated from build/postgres.yaml rather than declared a second time. A new bld_pg_version_list() reads just the versions, since a tool that only needs to know which ones are supported has no use for the interface that bld_pg_parse() scans the vendored headers for. The release flag comes with them, so the beta repository is added for whichever installed version has not been released rather than for a hardcoded vm and version.  
  
A vm with no container declares only default and coverage-c, since every other key says how a container is built. The os base declared for none was never read, and the os the tests run on with --vm=none is whatever the host is rather than the debian it claimed. The PostgreSQL versions declared for it were never read either, since only an integration test runs against a version and those need a container.  
  
A PostgreSQL version installed on a vm must now be one that build/postgres.yaml declares, and a version tested on a vm must be one installed there, both of which were only true by inspection before.  

M build/lib/common/yaml.py
M build/lib/postgres/parse.py
M test/container.yaml
M test/lib/command/vm/build.py
M test/lib/common/vm.py
M test/src/module/build/common/yaml_test.py
M test/src/module/test/command/test/job_test.py
M test/src/module/test/command/test/list_test.py
M test/src/module/test/command/test/test_test.py
M test/src/module/test/command/vm/build_test.py
M test/src/module/test/common/vm_test.py

Move --vm-build to a new vm-build command.

commit   : 369f18a6c721776a9e014b5abb73689e8315042a    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 20:43:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 20:43:22 +0700    

Click here for diff

The containers are built by a command of its own rather than by an option on a test run, e.g. test.py vm-build --vm=u24. The code that builds them moves from command/test/container.py to command/vm/build.py.  
  
The --vm-force option becomes --no-cache to match the option docker uses for the same thing. Since the point of it is to build without the cache rather than to clean up, it no longer removes the images that were built before, which leaves them in place to go back to.  
  
The --vm-arch option keeps its name since it also applies to a test run.  

M .github/workflows/test-arch.yml
M CONTRIBUTING.md
M doc/xml/contributing.xml
M test/ci.py
M test/define.yaml
M test/lib/command/test/job.py
M test/lib/command/test/test.py
R096 test/lib/command/test/container.py test/lib/command/vm/build.py
M test/lib/config/cli.py
M test/src/module/test/command/test/test_test.py
R088 test/src/module/test/command/test/container_test.py test/src/module/test/command/vm/build_test.py
M test/src/module/test/config/cli_test.py
M test/test.py

Run the linter and coverage in the test command.

commit   : 3ab669241d99074046f4df755c7baf2c76299723    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 17:02:00 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 17:02:00 +0700    

Click here for diff

The linter ran in the unit command, which runs once per test module, so a run scanned the entire repository again for every test in it. Move the call to the test command, which runs once, and lint the repository copy since that is what the tests are built and run from.  
  
The lint command was confusing to run because it linted whatever repo path it was given rather than the copy the tests are built from. Replace it with --lint-only, which does what a run does and stops once the source has been linted.  
  
The coverage command was only ever run by the test command, as a subprocess, so call it directly and pass it the test modules that ran. An error in coverage is now reported as itself rather than as a status the caller has to translate back into an error. The coverage tests move ahead of the test command test in define.yaml since it now depends on them.  
  
The test command is now a real command, listed in the help and noted as the default, but a run still never has to name it. Its options remain on the main parser as well since running the tests with no command is by far the most common usage, so the help must show them without being asked for the command.  
  
The unit command is left out of the command list since the test run is what calls it, in a container where only the repository copy is available, rather than something to run by hand.  

M test/define.yaml
M test/lib/command/coverage/coverage.py
M test/lib/command/test/test.py
M test/lib/config/cli.py
M test/src/module/test/command/coverage/coverage_test.py
M test/src/module/test/command/test/test_test.py
M test/src/module/test/config/cli_test.py
M test/src/module/test/config/config_test.py
M test/test.py

Lint the source once per test run rather than once per test.

commit   : ee0f8d353f0c6341672965e9e07f390563324ced    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:57:12 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:57:12 +0700    

Click here for diff

The linter ran in the unit command, which runs once per test module, so a run scanned the entire repository again for every test in it. Move the call to the test command, which runs once, and lint the repository copy there since that is what the tests are built and run from.  
  
Running the unit command directly no longer lints, but the test run does, which is what CI runs.  

M test/lib/command/test/test.py
M test/lib/command/test/unit.py
M test/src/module/test/command/test/test_test.py

Add a linter check that every test module is defined in define.yaml.

commit   : b56227463745c8f92455df407ad2f714fdab41bb    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:48:32 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:48:32 +0700    

Click here for diff

A test module that is not declared in define.yaml is never built or run and nothing reports it, since the file simply goes unread. This was the case for common/type/c, which is added to the definition here.  
  
The name of the file a test module lives in moves to define.py so the linter and the build cannot disagree about it.  

M test/define.yaml
M test/lib/command/lint/lint.py
M test/lib/command/test/build.py
M test/lib/command/test/define.py
M test/lib/command/test/unit.py
M test/src/module/test/command/lint/lint_test.py
M test/src/module/test/command/test/define_test.py

Add missing vm_covered annotations to the socket and TLS modules.

commit   : 56bd6d6f536c98b3144966e5cd82fd981ee3c824    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:33:57 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:33:57 +0700    

Click here for diff

The tests that cover this code require a container for sudo access, e.g. to add entries to /etc/hosts, so they are only built when TEST_CONTAINER_REQUIRED is defined. Without the vm_covered exception the common/io/tls test reports coverage errors when run outside a container.  
  
Wrap a few statements to make room for the annotation, which requires braces where a single statement was sufficient before.  

M src/common/io/socket/address.c
M src/common/io/socket/client.c
M src/common/io/tls/common.c

Select tests by module path instead of module and test.

commit   : 6480497a5ade1409de5baa5881b5db93e8d73bab    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:14:05 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 15:14:05 +0700    

Click here for diff

The --module option now takes a test module or the path of a group of them, e.g. --module=common/type/string runs the string test while --module=common/type runs every test under it. The --test option that named a test within a module is gone, since the module name now says which test it is. A name that matches no module is an error rather than a selection of nothing, so a typo in one of several --module options is reported instead of quietly running the rest.  
  
The option that selects a single test within a module is --test rather than --run, e.g. --module=command/backup --test=2, since --run has been a source of confusion. It requires a single --module naming a module, since the path of a group of them has nothing to apply it to. The test harness labels its output "test N" to match.  
  
C test modules move into subdirectories that match the code they cover, e.g. common/typeStringTest.c becomes common/type/stringTest.c, so that a path selects a group that makes sense.  
  
The test definitions are a flat list of modules per type, each named for the path of its test module, rather than tests nested under a module group. An entry that sets only options and defines no test is not a module but the options that every module under its path inherits, and it must be declared before them.  
  
The python tests for the build, documentation, and test tools move to a new tool type at the end of the definitions, where most development never has to look at them. The lang keyword goes away since a tool is python and everything else is C, and vm is now valid only for a tool since the tools are the same everywhere while a C test is exercising the platform it runs on.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M CONTRIBUTING.md
M doc/xml/contributing.xml
M test/define.yaml
M test/lib/command/test/define.py
M test/lib/command/test/job.py
M test/lib/command/test/list.py
M test/lib/command/test/test.py
M test/lib/config/cli.py
M test/src/harness/test.c
M test/src/harness/test.intern.h
R100 test/src/module/common/assertOffTest.c test/src/module/common/assert/offTest.c
R100 test/src/module/common/assertOnTest.c test/src/module/common/assert/onTest.c
R100 test/src/module/common/debugOffTest.c test/src/module/common/debug/offTest.c
R094 test/src/module/common/debugOnTest.c test/src/module/common/debug/onTest.c
R099 test/src/module/common/errorTest.c test/src/module/common/error/errorTest.c
R100 test/src/module/common/errorRetryTest.c test/src/module/common/error/retryTest.c
R100 test/src/module/common/ioHttpTest.c test/src/module/common/io/httpTest.c
R100 test/src/module/common/ioTest.c test/src/module/common/io/ioTest.c
R100 test/src/module/common/ioTlsTest.c test/src/module/common/io/tlsTest.c
R100 test/src/module/common/typeBlobTest.c test/src/module/common/type/blobTest.c
R100 test/src/module/common/typeBufferTest.c test/src/module/common/type/bufferTest.c
R100 test/src/module/common/typecTest.c test/src/module/common/type/cTest.c
R100 test/src/module/common/typeConvertTest.c test/src/module/common/type/convertTest.c
R100 test/src/module/common/typeJsonTest.c test/src/module/common/type/jsonTest.c
R100 test/src/module/common/typeKeyValueTest.c test/src/module/common/type/keyValueTest.c
R100 test/src/module/common/typeListTest.c test/src/module/common/type/listTest.c
R100 test/src/module/common/typeObjectTest.c test/src/module/common/type/objectTest.c
R100 test/src/module/common/typePackTest.c test/src/module/common/type/packTest.c
R100 test/src/module/common/typeStringTest.c test/src/module/common/type/stringTest.c
R100 test/src/module/common/typeVariantTest.c test/src/module/common/type/variantTest.c
R100 test/src/module/common/typeXmlTest.c test/src/module/common/type/xmlTest.c
M test/src/module/test/command/coverage/coverage_test.py
M test/src/module/test/command/test/build_test.py
M test/src/module/test/command/test/define_test.py
M test/src/module/test/command/test/job_test.py
M test/src/module/test/command/test/list_test.py
M test/src/module/test/command/test/test_test.py
M test/src/module/test/command/test/unit_test.py
M test/src/module/test/config/cli_test.py

Show the coverage report location when coverage is incomplete.

commit   : 20f880db3e5fe77fbd052049cd1d197ef51bb7d9    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 12:29:57 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 12:29:57 +0700    

Click here for diff

A run that ends with a module missing coverage names the module but not where to see what is missing. The report path is now logged with a file:// prefix so it can be pasted into a browser.  

M test/lib/command/test/test.py
M test/src/module/test/command/test/test_test.py

Reduce RHEL container and dist test exposure to slow package mirrors.

commit   : 205bec497b0ba03a24e62398bf713a18b2e4023b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 12:13:07 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 12:13:07 +0700    

Click here for diff

Apply the dnf mirror failover tuning from dd2e0ce8 to the test containers and the dist test, which pull from the same mirrors and see the same throughput variance. Raise the rate floor at which a mirror is abandoned, shorten the timeout, sort mirrors by latency, and fetch in parallel.  
  
The repository disable that accompanied that change is not carried over. It needs an enable list that tracks what each container installs, which is noisier than it is worth here, and the failover tuning is what addresses the stalls.  
  
The option names are the same for dnf 4 and dnf 5, so one block covers Rocky 8, 9, and 10 as well as Fedora. Reading the effective configuration back in each image confirms all five options are applied.  
  
The container revision is left alone since the cache key hashes the generated Dockerfile and these edits invalidate it on their own.  

M .github/workflows/test-dist.yml
M test/lib/command/test/container.py
M test/src/module/test/command/test/container_test.py

Run the code format check on Ubuntu 24.04.

commit   : 4427ef6a1fa87405a33b5701563883c29ebe16dc    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 11:58:09 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 11:58:09 +0700    

Click here for diff

The check ran on 22.04 for uncrustify 0.72, which meant a development container on 24.04 had to build 0.72 from source to get the same result. It now runs on 24.04 against the packaged uncrustify 0.78, so the container can install it.  
  
Uncrustify 0.78 maps .inc to Pawn, which fails to parse the .c.inc files and aborts the run, so file_ext maps the extension back to C.  
  
Comments in the body of a multiline #define were reindented. pp_multiline_define_body_indent arrived in 0.76 with a default of 8 and is documented as requiring pp_ignore_define_body=false, but 0.78 applies it to comments in the body whichever way that option is set, so the comments moved out to eight columns and pushed the line continuations after them past 132 characters. Setting it to indent_columns leaves them where they are.  
  
A case label generated by a macro was indented at the level of the switch braces rather than the level of the other case labels, which is what indent_switch_body sets and what 0.73 added it for.  
  
Six options changed from true/false to a number. The numeric form means what the boolean did and every version accepts it, so the formatting is unchanged, but 0.78 reported an error for each line and fell back to the default.  
  
A ternary in a switch expression makes 0.78 tag the colon of the first case label as the colon of the ternary, so it wrote case cfgCmdAnnotate : and indented the statement below it. No option avoids this, so main.c assigns the ternary to a variable and switches on that.  
  
Uncrustify rejects an option it does not know and exits, so the two options added here mean the configuration requires 0.78. This is noted in the coding standards.  
  
Black is not affected. Both the 21.12b0 packaged with 22.04 and the 24.2.0 packaged with 24.04 report every Python file unchanged, so the job moves without reformatting anything.  
  
Apart from main.c no source file changes, so what 0.78 produces is the formatting the project already had, and running the format again after the check produces nothing further.  

M .github/workflows/test-misc.yml
M CODING.md
M doc/xml/coding.xml
M src/main.c
M test/uncrustify.cfg

Migrate the remaining documentation code from Perl to Python.

commit   : fa3d1979b77d6252642c64d708a951914e0a0ac2    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 11:22:41 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 31 Jul 2026 11:22:41 +0700    

Click here for diff

doc.pl and release.pl become doc.py and release.py and the Perl library in doc/lib/pgBackRestDoc is gone, which is 32 files and 11,059 lines replaced by about 3,850 lines of Python. Nothing in the project runs on Perl now.  
  
doc.pl and doc.py merge into one tool rather than one running the other. doc.py built the reference documents and the preprocessed user guide, wrote them to doc/output/xml, and doc.pl read them back; they are now passed in memory and those files are not written at all. That also removes a duplication the split had hidden: the preprocessor added a document's variables to its own store and left the declarations in the file, so the Perl parsed them again into a store of its own and the same variable existed twice with different values.  
  
Most of Doc.pm does not come across. Its parse step rebuilt the parsed tree into a structure of its own and its build step invented a second way to address that structure, so a document was reached through three accessors where the tree has one. What survives is about ninety lines in common/xml.py: a field is the text of a child that holds nothing else, the text of a node is the node itself for the tags that are text and a text child for the rest, and asking for a child rather than the list of them now reports a second one as the mistake in the document that it is.  
  
External entities are expanded before the parser sees a document, since the standard library does not fetch them. The release list is assembled from one file per release this way, 122 of them, and a declaration that is never referenced is reported rather than leaving the file it names silently unread.  
  
Nothing validated against a document type, so the document type declarations and the two dtd files are removed. A declaration that is not enforced does not get maintained. The two documents that assemble themselves from parts keep the declaration because the parts are declared inside it.  
  
The release notes become a document the tool builds, like the command and configuration references, rather than something the renderer reaches into a custom module to produce.  
  
A variable may still be evaluated rather than written out, but it is a Python expression against a short list of names rather than a Perl program. The three that ask for a fact about the environment keep doing so. The four that read the project version, the current stable version, and the release date are facts the tool computes, since working them out involved constructing library objects and reading the release list a second time from inside a variable body.  
  
exec and user move down from test/lib to doc/lib because the documentation needs both, the project version splits out into config/project, and eval and var_store move to common now that the manifest uses them as much as the preprocessor. The version is built from the parts in src/version.h rather than read from the string generated from them, which is what the Perl did and what a release needs, since a release clears the development suffix and the string is only regenerated by a test run.  
  
libxml-checker-perl is no longer installed on CI runners, in the development container, or in the documentation images, and the RHEL image no longer installs cpanminus and a toolchain to build XML::Checker::Parser from CPAN. The user guide never ran Perl, and the Debian image never had a parser and built the same guide.  
  
Every module in doc/lib is declared in define.yaml and has full line and branch coverage.  
  
Ubuntu 24.04 ships a user and a group at id 1000, so a build by a user with that id could not create the test user. The test image removes whatever holds the id before creating it, as it already did for the group, and the development container image removes both because adduser gives a new group the id of the user.  
  
The coverage summary listed the python under build/lib as modules with mangled names, since it stripped a fixed src/ prefix from paths that no longer start with one. It now summarizes what is under src, which is what the metric page says it reports.  
  
Deploying the website no longer removes the links at the top of the site or CNAME. The Perl used find -type f, which does not match a symbolic link, and the port used a test that follows one, which took out the guides kept under retired platform names. CNAME is a regular file and the Perl removed it every deploy, which was never intended.  
  
The rendered documentation is byte identical to what the Perl produced, for every page, the manual page, and the generated markdown. The user guide was also built end to end under Docker on both documented platforms, and rendering it again from the cache that build wrote produces the same pages, which exercises the whole path rather than only what can be checked without containers. A release was then simulated from beginning to end.  

M .github/workflows/test-dist.yml
M CONTRIBUTING.md
M build/lib/common/xml.py
M doc/RELEASE.md
D doc/doc.pl
M doc/doc.py
D doc/lib/command/build/build.py
M doc/lib/command/build/pre.py
M doc/lib/command/build/reference.py
D doc/lib/command/build/var_store.py
A doc/lib/command/doc.py
A doc/lib/command/render/execute.py
A doc/lib/command/render/host.py
A doc/lib/command/render/html.py
A doc/lib/command/render/manifest.py
A doc/lib/command/render/markdown.py
A doc/lib/command/render/release.py
A doc/lib/command/render/render.py
R100 doc/lib/command/build/eval.py doc/lib/common/eval.py
R062 test/lib/common/exec.py doc/lib/common/exec.py
A doc/lib/common/ini.py
R079 test/lib/common/user.py doc/lib/common/user.py
A doc/lib/common/var_store.py
A doc/lib/config/project.py
D doc/lib/pgBackRestDoc/Common/Doc.pm
D doc/lib/pgBackRestDoc/Common/DocContainer.pm
D doc/lib/pgBackRestDoc/Common/DocExecute.pm
D doc/lib/pgBackRestDoc/Common/DocManifest.pm
D doc/lib/pgBackRestDoc/Common/DocRender.pm
D doc/lib/pgBackRestDoc/Common/Exception.pm
D doc/lib/pgBackRestDoc/Common/Execute.pm
D doc/lib/pgBackRestDoc/Common/Host.pm
D doc/lib/pgBackRestDoc/Common/HostGroup.pm
D doc/lib/pgBackRestDoc/Common/Ini.pm
D doc/lib/pgBackRestDoc/Common/Io/Base.pm
D doc/lib/pgBackRestDoc/Common/Io/Buffered.pm
D doc/lib/pgBackRestDoc/Common/Io/Filter.pm
D doc/lib/pgBackRestDoc/Common/Io/Handle.pm
D doc/lib/pgBackRestDoc/Common/Log.pm
D doc/lib/pgBackRestDoc/Common/Storage.pm
D doc/lib/pgBackRestDoc/Common/StorageBase.pm
D doc/lib/pgBackRestDoc/Common/StoragePosix.pm
D doc/lib/pgBackRestDoc/Common/StoragePosixRead.pm
D doc/lib/pgBackRestDoc/Common/StoragePosixWrite.pm
D doc/lib/pgBackRestDoc/Common/String.pm
D doc/lib/pgBackRestDoc/Common/Wait.pm
D doc/lib/pgBackRestDoc/Custom/DocCustomRelease.pm
D doc/lib/pgBackRestDoc/Html/DocHtmlBuilder.pm
D doc/lib/pgBackRestDoc/Html/DocHtmlElement.pm
D doc/lib/pgBackRestDoc/Html/DocHtmlPage.pm
D doc/lib/pgBackRestDoc/Html/DocHtmlSite.pm
D doc/lib/pgBackRestDoc/Markdown/DocMarkdown.pm
D doc/lib/pgBackRestDoc/Markdown/DocMarkdownRender.pm
D doc/lib/pgBackRestDoc/ProjectInfo.pm
M doc/manifest.xml
D doc/release.pl
A doc/release.py
M doc/xml/coding.xml
M doc/xml/contributing.xml
M doc/xml/distribution.xml
D doc/xml/dtd/doc.dtd
D doc/xml/dtd/manifest.dtd
M doc/xml/faq.xml
M doc/xml/index.xml
M doc/xml/metric.xml
M doc/xml/news.xml
M doc/xml/reference.xml
M doc/xml/release.xml
M doc/xml/user-guide-index.xml
M doc/xml/user-guide.xml
D pyrightconfig.json
M test/ci.py
M test/define.yaml
M test/lib/command/code/format.py
M test/lib/command/coverage/report.py
M test/lib/command/test/container.py
M test/lib/command/test/test.py
M test/lib/config/config.py
M test/src/module/build/common/xml_test.py
D test/src/module/doc/command/build/build_test.py
M test/src/module/doc/command/build/pre_test.py
M test/src/module/doc/command/build/reference_test.py
D test/src/module/doc/command/build/var_store_test.py
A test/src/module/doc/command/doc_test.py
A test/src/module/doc/command/render/execute_test.py
A test/src/module/doc/command/render/host_test.py
A test/src/module/doc/command/render/html_test.py
A test/src/module/doc/command/render/manifest_test.py
A test/src/module/doc/command/render/markdown_test.py
A test/src/module/doc/command/render/release_test.py
A test/src/module/doc/command/render/render_test.py
R099 test/src/module/doc/command/build/eval_test.py test/src/module/doc/common/eval_test.py
R062 test/src/module/test/common/exec_test.py test/src/module/doc/common/exec_test.py
A test/src/module/doc/common/ini_test.py
R100 test/src/module/test/common/user_test.py test/src/module/doc/common/user_test.py
A test/src/module/doc/common/var_store_test.py
A test/src/module/doc/config/project_test.py
M test/src/module/test/command/code/format_test.py
M test/src/module/test/command/coverage/report_test.py
M test/src/module/test/command/test/container_test.py
M test/src/module/test/config/cli_test.py
M test/src/module/test/config/config_test.py
M test/test.py

Migrate the build and documentation code from C to Python.

commit   : 59bd2cd96cfba2b02b07e81ade57d9beb5f2812e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 30 Jul 2026 19:37:43 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 30 Jul 2026 19:37:43 +0700    

Click here for diff

The build tool in src/build becomes build/build.py with its library in build/lib, and the C part of the documentation tool in doc/src becomes doc/doc.py with its library in doc/lib. They migrate together because doc/src was built on src/build, linking its config and help parsing along with its extensions to the core types. doc.pl runs doc.py rather than building and running a C binary, and meson runs build.py for the code it generates as it builds.  
  
The three libraries form the hierarchy the C include path did: build/lib is the base, doc/lib sits on it, and test/lib sits on both. They are implicit namespace packages so a package spans them, and a module lives in exactly one library and holds the union of what the tools need from it, since Python resolves a module name to a single file and cannot extend one the way the C included a core module and added to it. define.yaml declares which libraries each tool may import and the test runner puts only those on the path, so a test of the build library cannot reach the doc or test library any more than the build tool can.  
  
The declarations move with the code. config.yaml, error.yaml, and postgres.yaml are now at the root of build, and help.xml becomes doc/xml/reference.xml since it is documentation that the help happens to be generated from, so the generator is told where it is rather than knowing. dist.sh moves to build as well. The documentation tool's own option declarations are gone because they existed only to generate the option parser for the C binary and doc.py parses its command line directly.  
  
Three modules in src/build/common were never build tool code. exec, regExp, and string extend the core types for the test harness and the unit tests, which were their only users, so they move to test/src/harness. The json and xml extensions had no users outside the two tools and are removed with them, and the tests for both are trimmed to the core functions that remain.  
  
The help unit test built its fixture by calling the generator's C functions directly, which was the last thing outside the tools that depended on them. It now runs build.py help-data to write the packed help and loads that, so it still runs against the current declarations without needing the generated C, which may not have been written when the test runs.  
  
The manual page wrapping is a port of the wrapper the binary uses for its own help, so the manual page and pgbackrest help break lines the same way, including where a word longer than a line is left unbroken.  
  
libyaml is no longer needed to build pgBackRest since the only thing that read yaml was the C generator. It is dropped from the development container and from every CI platform, and meson no longer looks for it. Code generation needs nothing compiled first either, so a test run no longer builds a generator before it can select the tests.  
  
The common source list is merged into the pgbackrest source list since it existed only so the generator could be built from the same files, and the entries that were out of alphabetical order are put in order while the list is being rewritten anyway.  
  
Every file the generator writes is byte identical to what the C wrote, for the project declarations and for the documentation's own. The documentation is checked the same way: the HTML rendered on this branch is byte identical to the HTML rendered on integration, every file of it, which exercises the whole pipeline on the real content rather than only the four files the tool writes.  
  
Every module in both libraries is declared in define.yaml and has full line and branch coverage. The coverage list is keyed by file rather than by module name, since the same name in two languages is two files and one of them would have silently lost its requirement while both existed.  
  
TestError becomes ToolError, since it is the error type for three tools now rather than for the harness alone.  

M .gitattributes
M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M .github/workflows/test-dist.yml
M .github/workflows/test-misc.yml
M CONTRIBUTING.md
A build/build.py
R100 src/build/config/config.yaml build/config.yaml
R067 src/build/dist.sh build/dist.sh
R100 src/build/error/error.yaml build/error.yaml
A build/lib/command/main.py
R060 test/lib/common/error.py build/lib/common/error.py
R100 test/lib/common/log.py build/lib/common/log.py
A build/lib/common/pack.py
A build/lib/common/render.py
R082 test/lib/common/storage.py build/lib/common/storage.py
R095 test/lib/common/string_id.py build/lib/common/string_id.py
A build/lib/common/xml.py
A build/lib/common/yaml.py
A build/lib/config/common.py
A build/lib/config/parse.py
A build/lib/config/render.py
A build/lib/error/parse.py
A build/lib/error/render.py
A build/lib/help/parse.py
A build/lib/help/render.py
A build/lib/postgres/parse.py
A build/lib/postgres/render.py
R100 src/build/postgres/postgres.yaml build/postgres.yaml
M doc/doc.pl
A doc/doc.py
A doc/lib/command/build/build.py
A doc/lib/command/build/eval.py
A doc/lib/command/build/man.py
A doc/lib/command/build/pre.py
A doc/lib/command/build/reference.py
A doc/lib/command/build/var_store.py
M doc/manifest.xml
D doc/src/build/config/config.yaml
D doc/src/build/help/help.xml
D doc/src/command/build/build.c
D doc/src/command/build/build.h
D doc/src/command/build/eval.c
D doc/src/command/build/eval.h
D doc/src/command/build/man.c
D doc/src/command/build/man.h
D doc/src/command/build/pre.c
D doc/src/command/build/pre.h
D doc/src/command/build/reference.c
D doc/src/command/build/reference.h
D doc/src/command/build/varStore.c
D doc/src/command/build/varStore.h
D doc/src/command/help/meson.build
D doc/src/config/load.c
D doc/src/config/load.h
D doc/src/config/meson.build
D doc/src/main.c
D doc/src/meson.build
M doc/xml/contributing.xml
R100 src/build/help/help.xml doc/xml/reference.xml
M doc/xml/user-guide.xml
M meson.build
D src/build/common/json.c
D src/build/common/json.h
D src/build/common/render.c
D src/build/common/render.h
D src/build/common/xml.c
D src/build/common/xml.h
D src/build/common/yaml.c
D src/build/common/yaml.h
D src/build/config/parse.c
D src/build/config/parse.h
D src/build/config/render.c
D src/build/config/render.h
D src/build/error/parse.c
D src/build/error/parse.h
D src/build/error/render.c
D src/build/error/render.h
D src/build/help/parse.c
D src/build/help/parse.h
D src/build/help/render.c
D src/build/help/render.h
D src/build/main.c
D src/build/postgres/parse.c
D src/build/postgres/parse.h
D src/build/postgres/render.c
D src/build/postgres/render.h
M src/command/help/meson.build
M src/common/error/error.auto.c.inc
M src/common/error/error.auto.h
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M src/meson.build
M src/postgres/meson.build
M src/postgres/version.auto.h
M test/ci.py
M test/define.yaml
M test/lib/command/code/format.py
M test/lib/command/coverage/coverage.py
M test/lib/command/lint/lint.py
M test/lib/command/lint/string_id.py
M test/lib/command/test/build.py
M test/lib/command/test/container.py
M test/lib/command/test/define.py
M test/lib/command/test/job.py
M test/lib/command/test/python.py
M test/lib/command/test/test.py
M test/lib/command/test/unit.py
M test/lib/common/exec.py
D test/lib/common/render.py
M test/lib/common/vm.py
M test/lib/config/config.py
M test/lib/harness/test.py
M test/smoke.py
M test/src/harness/config.c
R094 src/build/common/exec.c test/src/harness/exec.c
R079 src/build/common/exec.h test/src/harness/exec.h
M test/src/harness/host.c
M test/src/harness/log.c
R092 src/build/common/regExp.c test/src/harness/regExp.c
R079 src/build/common/regExp.h test/src/harness/regExp.h
R093 src/build/common/string.c test/src/harness/string.c
R080 src/build/common/string.h test/src/harness/string.h
A test/src/module/build/command/main_test.py
R090 test/src/module/test/common/error_test.py test/src/module/build/common/error_test.py
R100 test/src/module/test/common/log_test.py test/src/module/build/common/log_test.py
A test/src/module/build/common/pack_test.py
R100 test/src/module/test/common/render_test.py test/src/module/build/common/render_test.py
R091 test/src/module/test/common/storage_test.py test/src/module/build/common/storage_test.py
R095 test/src/module/test/common/string_test.py test/src/module/build/common/string_test.py
A test/src/module/build/common/xml_test.py
A test/src/module/build/common/yaml_test.py
D test/src/module/build/commonTest.c
A test/src/module/build/config/common_test.py
A test/src/module/build/config/parse_test.py
A test/src/module/build/config/render_test.py
D test/src/module/build/configTest.c
A test/src/module/build/error/parse_test.py
A test/src/module/build/error/render_test.py
D test/src/module/build/errorTest.c
A test/src/module/build/help/parse_test.py
A test/src/module/build/help/render_test.py
D test/src/module/build/helpTest.c
A test/src/module/build/postgres/parse_test.py
A test/src/module/build/postgres/render_test.py
D test/src/module/build/postgresTest.c
M test/src/module/command/helpTest.c
M test/src/module/common/execTest.c
M test/src/module/common/regExpTest.c
M test/src/module/common/typeJsonTest.c
M test/src/module/common/typeStringTest.c
M test/src/module/common/typeXmlTest.c
D test/src/module/doc/buildTest.c
A test/src/module/doc/command/build/build_test.py
A test/src/module/doc/command/build/eval_test.py
A test/src/module/doc/command/build/man_test.py
A test/src/module/doc/command/build/pre_test.py
A test/src/module/doc/command/build/reference_test.py
A test/src/module/doc/command/build/var_store_test.py
M test/src/module/test/command/code/format_test.py
M test/src/module/test/command/coverage/coverage_test.py
M test/src/module/test/command/coverage/report_test.py
M test/src/module/test/command/lint/lint_test.py
M test/src/module/test/command/test/build_test.py
M test/src/module/test/command/test/container_test.py
M test/src/module/test/command/test/define_test.py
M test/src/module/test/command/test/job_test.py
M test/src/module/test/command/test/test_test.py
M test/src/module/test/command/test/unit_test.py
M test/src/module/test/common/exec_test.py
M test/src/module/test/common/vm_test.py
M test/src/module/test/config/config_test.py
M test/test.py

Migrate the rest of the test harness from Perl to Python.

commit   : e25c6621e8427e862e4ce0196d059ab651bb276d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Thu, 30 Jul 2026 08:11:30 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Thu, 30 Jul 2026 08:11:30 +0700    

Click here for diff

test.pl becomes test.py and ci.pl becomes ci.py, so an invocation only changes from .pl to .py. Running the tests needs no command since that is what a developer does, and the unit and coverage commands that a run drives are commands of test.py because a test runs inside a container where only the repository copy is available.  
  
The Perl library in test/lib is gone. The modules the documentation depends on move to doc/lib, with ExecuteTest becoming Execute, and are trimmed to what the documentation calls, which removes the storage manifest, info, and owner along with everything else the tests were the only user of. release.pl no longer needs the vm definitions since all it did with them was compare two vm names.  
  
Nothing in a test container or on a CI runner runs on Perl now so none of it is installed in either place, which was a substantial part of a base image. The packaging tools go with it since they are written in Perl and nothing builds a package, and ca-certificates is installed explicitly because it was arriving as a dependency of a package that is no longer there. Every base image is rebuilt as a result.  
  
The generated Dockerfile is otherwise unchanged. The one difference is that the PostgreSQL repository for RHEL is installed for the architecture being built rather than for the architecture of the host, which only differs when cross building.  
  
The list of tests to run is unchanged. It was compared against the Perl across the vm, module, test, run, and PostgreSQL version options before the Perl was removed.  
  
The vm definitions, the user lookup, the test list, the container build, the job, the test run itself, and the code format are covered by tests, which the Perl equivalents never were. The container build is checked by comparing the generated script in full, since the hash of that script is the cache key.  
  
Make the code format a command rather than two options of a test run, and fold in the black check that CI has been running as a step of its own. It gets the files to format from git rather than from the repository copy, so it no longer depends on a test run having made that copy and is told nothing about where either path is.  
  
Wire up --log-level-test, which the driver has been collecting and discarding. Remove --psql-bin, --gen-check, --no-coverage-report, --build-max, --log-level-test-file, --no-optimize, --no-debug, and --debug-test-trace, none of which have done anything for some time.  
  
Allow --vm=all only for --vm-build. Building every container at once works but running the tests on every vm never has, since a test needs the binary and the build for the vm it runs on and there is one of each.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M .github/workflows/test-doc.yml
M .github/workflows/test-linux.yml
M .github/workflows/test-misc.yml
M CODING.md
M CONTRIBUTING.md
M doc/doc.pl
M doc/lib/pgBackRestDoc/Common/DocContainer.pm
M doc/lib/pgBackRestDoc/Common/DocExecute.pm
R089 test/lib/pgBackRestTest/Common/ExecuteTest.pm doc/lib/pgBackRestDoc/Common/Execute.pm
M doc/lib/pgBackRestDoc/Common/Host.pm
M doc/lib/pgBackRestDoc/Common/HostGroup.pm
R098 test/lib/pgBackRestTest/Common/Io/Base.pm doc/lib/pgBackRestDoc/Common/Io/Base.pm
R093 test/lib/pgBackRestTest/Common/Io/Buffered.pm doc/lib/pgBackRestDoc/Common/Io/Buffered.pm
R098 test/lib/pgBackRestTest/Common/Io/Filter.pm doc/lib/pgBackRestDoc/Common/Io/Filter.pm
R098 test/lib/pgBackRestTest/Common/Io/Handle.pm doc/lib/pgBackRestDoc/Common/Io/Handle.pm
R067 test/lib/pgBackRestTest/Common/Storage.pm doc/lib/pgBackRestDoc/Common/Storage.pm
R073 test/lib/pgBackRestTest/Common/StorageBase.pm doc/lib/pgBackRestDoc/Common/StorageBase.pm
A doc/lib/pgBackRestDoc/Common/StoragePosix.pm
R096 test/lib/pgBackRestTest/Common/StoragePosixRead.pm doc/lib/pgBackRestDoc/Common/StoragePosixRead.pm
R092 test/lib/pgBackRestTest/Common/StoragePosixWrite.pm doc/lib/pgBackRestDoc/Common/StoragePosixWrite.pm
R098 test/lib/pgBackRestTest/Common/Wait.pm doc/lib/pgBackRestDoc/Common/Wait.pm
M doc/lib/pgBackRestDoc/Custom/DocCustomRelease.pm
M doc/lib/pgBackRestDoc/Html/DocHtmlSite.pm
M doc/lib/pgBackRestDoc/Markdown/DocMarkdown.pm
M doc/lib/pgBackRestDoc/ProjectInfo.pm
M doc/release.pl
M doc/xml/coding.xml
M doc/xml/contributing.xml
D test/ci.pl
A test/ci.py
M test/define.yaml
A test/lib/command/code/format.py
M test/lib/command/coverage/coverage.py
M test/lib/command/lint/ascii.py
M test/lib/command/lint/lint.py
M test/lib/command/lint/string_id.py
M test/lib/command/test/build.py
A test/lib/command/test/container.py
A test/lib/command/test/job.py
A test/lib/command/test/list.py
A test/lib/command/test/test.py
M test/lib/command/test/unit.py
M test/lib/common/exec.py
A test/lib/common/user.py
A test/lib/common/vm.py
M test/lib/config/cli.py
M test/lib/config/config.py
D test/lib/pgBackRestTest/Common/BuildTest.pm
D test/lib/pgBackRestTest/Common/ContainerTest.pm
D test/lib/pgBackRestTest/Common/DbVersion.pm
D test/lib/pgBackRestTest/Common/DefineTest.pm
D test/lib/pgBackRestTest/Common/JobTest.pm
D test/lib/pgBackRestTest/Common/ListTest.pm
D test/lib/pgBackRestTest/Common/StoragePosix.pm
D test/lib/pgBackRestTest/Common/VmTest.pm
A test/src/module/test/command/code/format_test.py
M test/src/module/test/command/coverage/filter_py_test.py
A test/src/module/test/command/test/container_test.py
A test/src/module/test/command/test/job_test.py
A test/src/module/test/command/test/list_test.py
A test/src/module/test/command/test/test_test.py
M test/src/module/test/command/test/unit_test.py
M test/src/module/test/common/exec_test.py
A test/src/module/test/common/user_test.py
A test/src/module/test/common/vm_test.py
M test/src/module/test/config/cli_test.py
M test/src/module/test/config/config_test.py
D test/test.pl
M test/test.py

Migrate the test harness from C to Python.

commit   : 163e5ba0f9ff72ec730f0b35ddac0e5d68ece531    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 13:00:33 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 13:00:33 +0700    

Click here for diff

The C harness in test/src/command/test is replaced by test/test.py with its library in test/lib, and test.pl runs that per test rather than building and running a C binary. Python is already required by meson so the only new dependency is PyYAML, and the harness no longer needs to be compiled before any test can run.  
  
The harness is now tested the same way the rest of the code is. Test modules live in test/src/module/test and are declared in define.yaml with lang: python, so they run through the existing test.pl --module and --test interface. A python test is a plain function rather than a run inside testBegin(), since these tests are fast enough that selecting a single run is not useful, and --test-name selects one function when debugging.  
  
Coverage from both languages merges into a single report, so a run that covered C and python produces one result. Full line and branch coverage is required and the uncovered and uncoverable annotations work as they do for C. Python reports the lines and arcs it knows about rather than everything a module contains, and which arcs a run reports depends on which ones it took, so python coverage merges by line number and by where an arc goes rather than by position.  
  
A python test may import only the library modules it declares, which is what the C link step enforced by compiling in only what was declared. An import that is already in sys.modules never reaches an import hook, so the test module is loaded in its own interpreter with the hook installed ahead of the library.  
  
Fix coverage for a shimmed .c.inc module, which has been missing from the report rather than reported as uncovered. The build writes the shimmed copy to the include path so it resolves ahead of the repository copy, which puts it outside src, and the filter only matched the path such a module is normally compiled from. In practice this is command/backup/process.inc, the only shimmed .c.inc.  
  
Install PyYAML wherever the harness runs and a coverage module wherever the python tests run. rh8 installs python3.12 since PyYAML is not packaged for the platform python there, u24 and rh10 package a coverage new enough to report branch detail per line, and rh9 installs one with pip since the packaged version predates it. The jobs that run outside a container install PyYAML as each platform allows, with pip on macOS since homebrew has no formula for it.  
  
Check the format of the harness and its tests with black, which previously covered only the smoke test.  

M .github/workflows/test-arch.yml
M .github/workflows/test-bsd.yml
M .github/workflows/test-misc.yml
M .gitignore
M doc/xml/contributing.xml
M meson.build
A pyrightconfig.json
M test/ci.pl
M test/define.yaml
A test/lib/command/coverage/coverage.py
A test/lib/command/coverage/filter_c.py
A test/lib/command/coverage/filter_py.py
A test/lib/command/coverage/report.py
A test/lib/command/lint/ascii.py
A test/lib/command/lint/lint.py
A test/lib/command/lint/string_id.py
A test/lib/command/test/build.py
A test/lib/command/test/define.py
A test/lib/command/test/python.py
A test/lib/command/test/unit.py
A test/lib/common/error.py
A test/lib/common/exec.py
A test/lib/common/log.py
A test/lib/common/render.py
A test/lib/common/storage.py
A test/lib/common/string_id.py
A test/lib/config/cli.py
A test/lib/config/config.py
A test/lib/harness/test.py
M test/lib/pgBackRestTest/Common/ContainerTest.pm
M test/lib/pgBackRestTest/Common/DefineTest.pm
M test/lib/pgBackRestTest/Common/JobTest.pm
M test/lib/pgBackRestTest/Common/ListTest.pm
D test/src/build/config/config.yaml
D test/src/build/help/help.xml
D test/src/command/help/meson.build
D test/src/command/test/build.c
D test/src/command/test/build.h
D test/src/command/test/coverage.c
D test/src/command/test/coverage.h
D test/src/command/test/define.c
D test/src/command/test/define.h
D test/src/command/test/lint.c
D test/src/command/test/lint.h
D test/src/command/test/test.c
D test/src/command/test/test.h
D test/src/config/load.c
D test/src/config/load.h
D test/src/config/meson.build
D test/src/main.c
D test/src/meson.build
A test/src/module/test/command/coverage/coverage_test.py
A test/src/module/test/command/coverage/filter_c_test.py
A test/src/module/test/command/coverage/filter_py_test.py
A test/src/module/test/command/coverage/report_test.py
A test/src/module/test/command/lint/lint_test.py
A test/src/module/test/command/test/build_test.py
A test/src/module/test/command/test/define_test.py
A test/src/module/test/command/test/python_test.py
A test/src/module/test/command/test/unit_test.py
A test/src/module/test/common/error_test.py
A test/src/module/test/common/exec_test.py
A test/src/module/test/common/log_test.py
A test/src/module/test/common/render_test.py
A test/src/module/test/common/storage_test.py
A test/src/module/test/common/string_test.py
A test/src/module/test/config/cli_test.py
A test/src/module/test/config/config_test.py
D test/src/module/test/coverageTest.c
D test/src/module/test/testTest.c
M test/test.pl
A test/test.py

Reduce RHEL documentation build exposure to slow package mirrors.

commit   : dd2e0ce8c192a751b110f78e5d05d338d99eec9f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 12:32:04 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 12:32:04 +0700    

Click here for diff

The RHEL documentation builds have been slow and intermittently failing. The mirrors are not down: three consecutive full metadata refreshes from the same host measured 73, 124, and 157 seconds with a full mirror list and no dead mirrors, so the variance is mirror throughput rather than availability. Every documentation container pays this cost because Rocky sets metadata_expire to six hours and the cached base image is normally older than that by the time it is used.  
  
Tune dnf to fail over from a slow mirror. By default a mirror is only abandoned when it delivers less than 1000 bytes per second for 30 seconds, so a mirror that is crawling rather than failing is retried instead of dropped. Raise the rate floor to 50k, shorten the timeout, sort mirrors by latency, and fetch in parallel.  
  
Disable repositories the documentation does not use so their metadata is not refreshed in every container. The epel-release package adds an openh264 repository and the PGDG repository enables one repository per PostgreSQL version, of which only the documented version and the upgrade version are needed. This takes the enabled repository count from twelve to eight. The enable list is driven from the existing version variables so it follows a version bump.  
  
After both changes the same three-run measurement was 81, 53, and 51 seconds, though the mirror variance being addressed also makes that comparison noisy.  
  
The container revision is left alone since the cache key hashes the generated Dockerfile and these edits invalidate it on their own.  

M doc/xml/user-guide.xml

Update tests and documentation builds to Ubuntu 24.04.

commit   : ef0fdc99ff26df6cab494d23e3cd4601d0edc02f    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 09:33:48 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 29 Jul 2026 09:33:48 +0700    

Click here for diff

Add a u24 vm and split the PostgreSQL versions between it and u22 so the integration tests run as two jobs instead of one. u22 tests 9.6 and 11 and no longer installs anything above 14, while u24 installs the full range and tests 12, 13, and 19. The unit test jobs (coverage/backtrace and valgrind), the coverage summary generated for a release, and the Debian/Ubuntu documentation build all move to u24, so the PostgreSQL 19 beta repository is now enabled there rather than on u22. The manual container revision for the beta is dropped since the u24 base image is new and has no cached tag to invalidate.  
  
Build the contributing guide development environment and the user guide host from ubuntu:24.04 rather than ubuntu:22.04.  
  
Remove the guard that skipped PostgreSQL 18 unless the container was being built for a particular vm and architecture. It dates from when 18 was in beta and not built for ppc64le and s390x, and those architectures no longer reach it since they install a single version via the postgresql metapackage.  
  
Drop python3-distutils, which no longer exists on Ubuntu 24.04. Nothing here uses distutils and on the older distributions it is pulled in as a meson dependency anyway.  
  
Install the AWS CLI from the vendor installer on the Debian/Ubuntu documentation host since awscli is no longer packaged for Ubuntu 24.04. This matches what the RHEL host already does with awscli2 and means ca_bundle must now be set for both, since the vendored CLI ships its own certificate bundle instead of using the system store.  
  
Leave the arch workflow and the code format check on u22 and ubuntu-22.04. Ubuntu 22.04 is still supported and uncrustify formats differently on 24.04, which is a separate change.  

M .github/workflows/test-doc.yml
M .github/workflows/test-linux.yml
M doc/release.pl
M doc/xml/contributing.xml
M doc/xml/user-guide.xml
M test/ci.pl
M test/container.yaml
M test/lib/pgBackRestTest/Common/ContainerTest.pm
M test/lib/pgBackRestTest/Common/VmTest.pm

Convert the smoke test to the new Python comment style.

commit   : 6454d59f7016fc8d21a7f9fdf18704fa1fc59b9e    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 28 Jul 2026 21:58:39 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 28 Jul 2026 21:58:39 +0700    

Click here for diff

Banner comments become docstrings with a separator before each top level definition and each method, matching the rest of Python and making a description reachable by an editor rather than only by reading the file.  
  
Two of the banners introduced a group of helpers rather than the function that followed, so those titles are dropped and each helper describes itself. A few functions had no description at all and were given one.  

M test/smoke.py

Convert the shim list in define.yaml from a map to a sequence.

commit   : f88ddf4fe39130352fc3b68e001358c394c023b4    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 28 Jul 2026 12:24:30 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 28 Jul 2026 12:24:30 +0700    

Click here for diff

YAML mappings are unordered, but shim key order determined the order the modules were included in the harness, e.g. the fd harness includes common/io/fd before common/io/fdWrite. A sequence makes that order explicit instead of relying on parser behavior.  
  
A shim entry is now either a bare module name or a map with a name and a function list, matching how harness entries are already written, and the null placeholder the map form needed is gone.  
  
The harness integration flag is also applied to its shims after the harness has been parsed, so it no longer depends on integration appearing before shim.  

M test/define.yaml
M test/src/command/test/define.c
M test/src/module/test/testTest.c

Refactor restore file module into an included module.

commit   : 902e8a01d52545205520ecc23fe69642893ce47d    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 21:31:42 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 21:31:42 +0700    

Click here for diff

The restore file module is only used internally by the restore protocol handler so move it into a file.c.inc that is included directly into protocol.c, matching the approach used for the other restore modules. This makes restoreFile() static and avoids maintaining an extra header and build entry.  
  
Move the RestoreResult enum to protocol.h since it is still needed by process.c.inc and the restore tests but file.h no longer exists to provide it.  

R090 src/command/restore/file.c src/command/restore/file.c.inc
D src/command/restore/file.h
M src/command/restore/protocol.c
M src/command/restore/protocol.h
M src/command/restore/restore.c
M src/meson.build
M test/define.yaml

Refactor backup file into an included module.

commit   : 6f47bd90ad1abfdff204a131928c2c11cfc456fe    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 21:03:01 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 21:03:01 +0700    

Click here for diff

backupFile() is only called by the backup protocol handler so include file.c into protocol.c directly, matching how the rest of the backup module is included into backup.c. This makes backupFile() static and removes the file.h header and build entry. The BackupFile/BackupFileResult structs are only used by backupFile() and the protocol handler so they become file-private as well.  
  
The BackupCopyResult enum is still needed by the backup process to interpret protocol job results so move it to protocol.h.  

M src/command/backup/backup.c
R088 src/command/backup/file.c src/command/backup/file.c.inc
D src/command/backup/file.h
M src/command/backup/protocol.c
M src/command/backup/protocol.h
M src/meson.build
M test/define.yaml

Refactor backup module into included modules.

commit   : dde4fe3cd2bdc3b9b75bb1cf4eae7673536faa34    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 20:18:45 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 20:18:45 +0700    

Click here for diff

The backup module has grown large so split it into .c.inc files that are included directly into backup.c, matching the approach used for the restore module. These functions are only used internally by backup so this avoids maintaining extra header and build files and keeps them static.  
  
Since backupProcess() is shimmed for testing and now lives in an included module, the shim uses the inc option to locate and shim it there.  

M src/command/backup/backup.c
A src/command/backup/complete.c.inc
A src/command/backup/db.c.inc
A src/command/backup/incr.c.inc
A src/command/backup/label.c.inc
A src/command/backup/option.c.inc
A src/command/backup/process.c.inc
A src/command/backup/resume.c.inc
M test/define.yaml

Allow shimmed functions to be defined in included modules.

commit   : 2bea0b65aade71ab0596b488c2aca9a92257a0af    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 20:03:22 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 20:03:22 +0700    

Click here for diff

The shim mechanism renames a function in the shim module's C file so the harness can supply an alternate implementation for testing, which required the shimmed function to be defined in the shim module itself. Now that large modules are being split into included .c.inc files a function to be shimmed may be defined outside the shim module.  
  
Add an inc option to a shim function that names the included module where the function is defined. That module is shimmed in addition to the shim module and written to the include path so the shim module finds the shimmed copy (via the unit include directory) before the repo copy.  

M test/src/command/test/build.c
M test/src/command/test/define.c
M test/src/command/test/define.h
M test/src/module/test/testTest.c

Replace Ceph with SeaweedFS for the S3 documentation and test host.

commit   : c4dfbdd65b385a23486b7328902512281dfbcfee    
  
author   : David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 19:54:53 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Fri, 24 Jul 2026 19:54:53 +0700    

Click here for diff

The Ceph RGW host was slow and unreliable to start, frequently returning connection failures and requiring long startup waits during documentation builds and integration tests.  
  
SeaweedFS starts in a few seconds, ships a much smaller multi-arch image, and supports S3 object versioning, which the repository target time feature requires. The image is pinned to 4.40 to include recent versioned-listing fixes.  
  
A single weed server command runs the master, volume, filer, and S3 gateway with TLS on 443, host-style addressing via s3.domainName, and the demo credentials in a mounted identity config, so no bootstrap script is needed. The entrypoint is overridden to run weed as root because the default drops to an unprivileged user that cannot bind 443.  
  
After a stanza delete on the versioned repository, SeaweedFS keeps listing the now-empty stanza prefix, so info reports missing stanza data rather than missing stanza path; the user guide output is updated to match.  

D doc/resource/ceph/bootstrap.sh
A doc/resource/seaweedfs/s3.config.json
M doc/xml/user-guide.xml
M test/src/harness/host.c

Cache the documentation build base images in a GHCR package.

commit   : 6916e06f1b9403ba36da1a769a30cc37633aca50    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 22 Jul 2026 20:43:27 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Wed, 22 Jul 2026 20:43:27 +0700    

Click here for diff

The documentation build builds a base host image per distribution before running the commands the docs contain, installing PostgreSQL, the Azure and AWS CLIs, and other packages from scratch on every run. On a fresh CI runner nothing is cached, so this rebuild made the doc builds the slowest jobs in CI.  
  
Cache each base image in a new ghcr.io/pgbackrest/doc registry package, keyed by a hash of the generated Dockerfile combined with a manual revision on the host-define. A run pulls the image when the key matches and only builds and pushes it on a miss, the same approach the test containers already use; forks pull from the pgbackrest cache and push to their own.  
  
The revision is set date-style (e.g. 20260721A) like the test container revisions in test/container.yaml and forces a rebuild when the Dockerfile is unchanged but the upstream packages have changed, e.g. a new PostgreSQL minor.  
  
Factor the cache logic into pgBackRestDoc::Common::DocContainer to keep doc.pl tidy, grant the doc CI job packages:write and log it in to GHCR so a build can be pushed, and prune the doc package on the same schedule as the other caches.  

M .github/workflows/ghcr-prune.yml
M .github/workflows/test-doc.yml
M .github/workflows/test.yml
M doc/doc.pl
A doc/lib/pgBackRestDoc/Common/DocContainer.pm
M doc/xml/contributing.xml
M doc/xml/dtd/doc.dtd
M doc/xml/user-guide.xml

Prefetch multi-file/range reads from object stores.

commit   : e63dec323b9e79513638b9858e4ad13b6072d060    
  
author   : David Steele <david@pgbackrest.org>    
date     : Wed, 22 Jul 2026 18:45:13 +0700    
  
committer: GitHub <noreply@github.com>    
date     : Wed, 22 Jul 2026 18:45:13 +0700    

Click here for diff

During restore, files bundled together in the repository are read back one file or range at a time, and each new read from an object store pays the latency of establishing a request. The restore copy loop worked around this by manually coalescing adjacent bundled files into a single read, but it could neither hide request latency nor skip over unneeded data within a bundle.  
  
Add StorageReadMulti, which is given the full list of files and ranges up front and reads them back in order. Requests are submitted ahead of time (prefetch) so the latency of the next request overlaps reading the current one, and ranges separated by a small gap (e.g. a file that already exists on the host) are merged into a single request by skipping over the gap ("read over") rather than closing and reopening at a new offset, which is faster and cheaper than a new request for most storage classes.  
  
To support prefetch, the S3, Azure, and GCS read drivers gain an async open. The HTTP request is submitted when the read is opened and the response is deferred until the first read via a new optional openAsync driver function, so multiple requests can be in flight at once. storageReadNew() gains an async flag; the async open is completed lazily on first read and is never combined with ignore missing.  
  
The default StorageReadMulti driver queues reads directly on the storage. When the repository is remote a dedicated remote driver is used instead so the entire request list is forwarded to the remote in a single open and prefetch and read over run there, closer to the storage. The remote read and remote read-multi share stream state and chunk handling.  
  
Two options control the behavior: repo-storage-prefetch (default 3, range 0-1024) sets how many requests to queue ahead, and repo-storage-read-over (default 64KiB, range 0B-1MiB) sets the largest gap to read over rather than issue a new request. The number of open requests per read is one greater than prefetch since the initial read cannot be prefetched.  
  
Rewrite the restore copy loop to add each file that will be copied to a StorageReadMulti and read through it, removing the manual offset and limit tracking across bundled files.

M doc/xml/release/2020s/2026/2.60.0.xml
M src/build/config/config.yaml
M src/build/help/help.xml
M src/command/restore/file.c
M src/config/config.auto.h
M src/config/parse.auto.c.inc
M src/meson.build
M src/storage/azure/helper.c
M src/storage/azure/read.c
M src/storage/azure/storage.c
M src/storage/azure/storage.h
M src/storage/gcs/helper.c
M src/storage/gcs/read.c
M src/storage/gcs/storage.c
M src/storage/gcs/storage.h
M src/storage/read.c
M src/storage/read.intern.h
A src/storage/readMulti.c
A src/storage/readMulti.h
A src/storage/readMulti.intern.h
M src/storage/remote/protocol.c
M src/storage/remote/protocol.h
M src/storage/remote/read.c
M src/storage/remote/read.h
A src/storage/remote/readMulti.c
A src/storage/remote/readMulti.h
M src/storage/remote/storage.c
M src/storage/s3/helper.c
M src/storage/s3/read.c
M src/storage/s3/storage.c
M src/storage/s3/storage.h
M src/storage/storage.c
M src/storage/storage.h
M src/storage/storage.intern.h
M test/define.yaml
M test/src/harness/host.c
M test/src/module/command/helpTest.c
M test/src/module/storage/azureTest.c
M test/src/module/storage/gcsTest.c
M test/src/module/storage/remoteTest.c
M test/src/module/storage/s3Test.c

commit   : c8f4192c4adb56f16be67071a13dd4fcd674cc23    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 21:31:15 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 21:31:15 +0700    

Click here for diff

Only same-page section links were verified, so a page/section link with a bad section or a missing leading slash would silently render a broken anchor.  
  
Require a link section to begin with a slash and, after all pages have been rendered, verify that each page/section link points at a section that exists on the target page. Links to a page that is not part of the build (e.g. a partial build) are skipped.  
  
Also fix the news link to the 2.59.0 release notes, which was missing the leading slash and pointed at a broken anchor.  

M doc/doc.pl
M doc/lib/pgBackRestDoc/Common/DocManifest.pm
M doc/lib/pgBackRestDoc/Common/DocRender.pm
M doc/xml/news.xml

Share the build package list between the user guide and DIST.md.

commit   : 5e173ee3bd971e7a73de7474f3cac56cfd1a7945    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 18:19:00 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 18:19:00 +0700    

Click here for diff

The distribution README (DIST.md) duplicated the Debian build dependency list from the user guide, so the two could drift. Move it into a shared variable used by both, drop python3-distutils, and link DIST.md to the user guide build section.  
  
The variable is split with a backslash continuation so each line stays within the command line length limit. The doc executor now keeps an existing continuation rather than adding its own.  

M doc/lib/pgBackRestDoc/Common/DocExecute.pm
M doc/xml/distribution.xml
M doc/xml/user-guide.xml

Generate DIST.md from XML like the other markdown docs.

commit   : f669d32abee6dce7f718c4c6d0508d56a58d2692    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 17:58:14 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 17:58:14 +0700    

Click here for diff

doc/DIST.md, the README shipped in the distribution tarball, was maintained by hand. Source it from doc/xml/distribution.xml and render it with the doc tool as is done for the other generated markdown, and gitignore the output like NEWS.md.  
  
release.pl --dist renders DIST.md so meson dist can copy it into the tarball as README.md.  

M doc/.gitignore
D doc/DIST.md
M doc/manifest.xml
M doc/release.pl
A doc/xml/distribution.xml
M doc/xml/user-guide.xml

Remove the blank line between list items in generated markdown.

commit   : 95c1a2318b3c00878e098a74a566ef3982655f06    
  
author   : David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 14:33:30 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Tue, 21 Jul 2026 14:33:30 +0700    

Click here for diff

List items were separated by a blank line, which reads poorly when the markdown is viewed as plain text.  
  
Regenerate README.md, CODING.md, and CONTRIBUTING.md.  

M CODING.md
M CONTRIBUTING.md
M README.md
M doc/lib/pgBackRestDoc/Markdown/DocMarkdownRender.pm

Add a blank line after code blocks in generated markdown.

commit   : 72101f4d566382329b3871cb45e0e46fd4a9e202    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 20:43:27 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 20:43:27 +0700    

Click here for diff

The paragraph following a code block was rendered directly against the closing fence, which reads poorly when the markdown is viewed as plain text.  
  
Regenerate CODING.md and CONTRIBUTING.md.  

M CODING.md
M CONTRIBUTING.md
M doc/lib/pgBackRestDoc/Markdown/DocMarkdownRender.pm

Add retry to S3 put-bucket-versioning in the user guide build.

commit   : 55ba577e00f82ac455404a5d757ec16c39884774    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 19:43:09 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 19:43:09 +0700    

Click here for diff

This command occasionally failed even though the preceding bucket creation had succeeded, presumably because the new bucket was not yet available. Retry for up to 30 seconds to give it time to appear.  
  
Also lower the bucket creation retry from 60 to 30 seconds since it normally completes well within that.  

M doc/xml/user-guide.xml

Increase the minimum meson version to 0.55.3.

commit   : ac4adf318c633ca166d0a8e2c6033822a42b7c42    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:51:11 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:51:11 +0700    

Click here for diff

Meson 0.55 and later name the unit test private build directory test-unit.p rather than test-unit@exe, so the harness no longer needs to check for both when clearing old coverage data.  
  
All supported platforms ship a newer meson (RHEL 8, the oldest, has 0.58.2), so raise the minimum to 0.55.3 and always use test-unit.p.  

M meson.build
M test/src/command/test/test.c

Replace the soon-to-be EOL Debian 11 dist build with Debian 12.

commit   : cffb7dc8a6e88dbd7e5d49c1562a05b5f0bde128    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:28:02 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:28:02 +0700    

Click here for diff

Debian 11 (bullseye) reaches end of life before the next release, so replace it with Debian 12 (bookworm) in the distribution build matrix.  
  
Debian 12 ships a newer meson that configures the Python installation without setuptools, so remove the debian:11-only setuptools workaround.  

M .github/workflows/test-dist.yml

Use the matching END macro to close MEM_CONTEXT_OBJ/PRIOR blocks.

commit   : 42699b1fff5dcdf92391aa19e20cee875a47e31b    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:17:26 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:17:26 +0700    

Click here for diff

MEM_CONTEXT_PRIOR_END() and MEM_CONTEXT_OBJ_END() are both defined as MEM_CONTEXT_END(), so closing a MEM_CONTEXT_PRIOR_BEGIN() or MEM_CONTEXT_OBJ_BEGIN() block with the base MEM_CONTEXT_END() expands identically and cannot be detected by the compiler. Eight sites had drifted to the base closer: four in info.c, one in io/session.c, and one each in the s3, azure, and gcs storage drivers.  
  
The headers document the matching END as the intended pairing, so use it. Preprocessed output was verified byte-identical before and after, so the change is a no-op.  
  
These were found while prototyping a lint check for block-macro pairing.  

M src/common/io/session.c
M src/info/info.c
M src/storage/azure/storage.c
M src/storage/gcs/storage.c
M src/storage/s3/storage.c

Prune the GHCR container caches on a schedule.

commit   : edf913761fb05cb0a438050965baf7b95fa28865    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:07:20 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 17:07:20 +0700    

Click here for diff

The test and test-ccache packages grow without bound: test gains a new tag every time a base image Dockerfile changes, and test-ccache orphans the previous digest every time a per-architecture tag is overwritten. Both are pure caches that are rebuilt on demand, so add a weekly workflow to prune the stale entries, with a dry-run input for manual runs.  
  
The test package keys base images by a hash of the Dockerfile, so a change abandons the old tag while leaving it tagged and out of reach of an untagged-only sweep. Age these out by deleting every tag older than two months; anything still in use is simply rebuilt and re-pushed on the next run. The test-ccache package overwrites its per-architecture tags on every save, leaving the previous digest untagged and referenced by nothing, so delete all untagged versions there.  
  
The owner defaults to the account the workflow runs as, so a fork prunes its own caches rather than the canonical pgbackrest ones, mirroring where each environment pushes.  

A .github/workflows/ghcr-prune.yml

Begin v2.60.0 development.

commit   : a5f4d17d9a50ab220966e429858c87118fa125fe    
  
author   : David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 16:04:00 +0700    
  
committer: David Steele <david@pgbackrest.org>    
date     : Mon, 20 Jul 2026 16:04:00 +0700    

Click here for diff

M doc/resource/git-history.cache
M doc/xml/release.xml
A doc/xml/release/2020s/2026/2.60.0.xml
M meson.build
M src/version.h