A Git-Based Workflow for Extension Control Files
How to bring an extension’s .control file, its versioned migration scripts, and its deployment manifest under Git so every promotion is a reviewed diff, every rollback is a revert, and every node’s on-disk state is reconcilable to a commit.
Up: Version Control & Branching — this page is the concrete Git workflow for the branching discipline, under the PostgreSQL Extension Architecture & Lifecycle Fundamentals area of the site.
Context & When This Applies
An extension on disk is three coupled artifacts: a <name>.control file that declares default_version, requires, relocatable, and trusted; a set of <name>--<from>--<to>.sql migration scripts that define every reachable upgrade hop; and a deployment manifest that records which version each environment runs. When those live only on servers, a version bump is an untracked side effect and a rollback is an improvisation. Bringing them under Git makes the reachable-version graph a reviewable object and the current state a reproducible one. This applies to any fleet running first-party or vendored extensions on PostgreSQL 12–17 where promotions must be auditable.
The workflow matters most for compliance and incident response: when a node reports has no update path from A to B, a Git history of the control file and scripts tells you immediately whether a script was removed, renamed, or never deployed — turning a mystery into a diff. It underpins the manifest discipline in pinning extension versions in deployment manifests.
Concept: The Control File Is the Source of Truth for Reachability
PostgreSQL computes which versions an extension can install or reach entirely from the files present in $sharedir/extension: the .control file’s default_version and the set of --from--to-- scripts. That means the reachable-version graph — the thing the dependency tree analysis pre-flight reads from pg_available_extension_versions — is a deterministic function of the tracked files. Put those files under Git and the graph becomes reproducible: a given commit always yields the same reachability on every node that deploys it. A node that disagrees with the catalog is, by definition, out of sync with its commit, and reconciliation is a git diff against the deployed tree.
The second concept is merge-and-revert symmetry. Because a version bump is expressed as a diff (a changed default_version, an added script), its inverse is a git revert of that merge — which restores the previous control file and manifest byte for byte and redeploys the prior version. This is what makes rollback a first-class, reviewable operation rather than a hand-editing of $sharedir, and it composes directly with the artifact-level rollback in compatibility matrix synchronization.
Runnable Implementation
The repository layout and CI check below make every version bump a validated diff. The check parses the new control file and asserts the added migration script actually creates the reachable hop it claims.
#!/usr/bin/env bash
# CI gate: validate an extension version-bump commit before it can merge.
set -euo pipefail
EXT_DIR="${1:?usage: validate-ext-bump.sh <extension_dir>}" # e.g. ext/myext
NAME="$(basename "$EXT_DIR")"
CONTROL="$EXT_DIR/${NAME}.control"
# 1. Control file must declare a default_version.
default_version="$(grep -oP "default_version\s*=\s*'\K[^']+" "$CONTROL" || true)"
[[ -n "$default_version" ]] || { echo "no default_version in $CONTROL" >&2; exit 1; }
# 2. The migration script that reaches default_version must exist. Find the hop
# ending at the new default (…--<from>--<default_version>.sql).
target_script="$(ls "$EXT_DIR"/${NAME}--*--${default_version}.sql 2>/dev/null | head -1 || true)"
if [[ -z "$target_script" && ! -f "$EXT_DIR/${NAME}--${default_version}.sql" ]]; then
echo "no install or update script reaches ${default_version}" >&2
exit 1
fi
# 3. Record a deterministic checksum of the whole extension tree so the
# manifest can pin exactly what shipped.
checksum="$(find "$EXT_DIR" -type f -name '*.sql' -o -name '*.control' \
| sort | xargs sha256sum | sha256sum | cut -c1-16)"
echo "{\"extension\":\"${NAME}\",\"default_version\":\"${default_version}\",\"checksum\":\"${checksum}\"}"
Run this in CI on every pull request touching an extension directory, and record the emitted checksum in the deployment manifest so the compatibility matrix and the Git tree agree on exactly what shipped. Drive the deploy of the merged tree through ALTER EXTENSION automation.
Expected Output & Verification
A valid bump emits the pinned tuple CI archives with the deploy:
{"extension": "myext", "default_version": "3.4.2", "checksum": "a1b2c3d4e5f60718"}
After deploy, verify that what each node reports matches the committed control file — the reconciliation that catches a partial rollout:
-- On each node: the catalog's default_version must equal the committed one.
SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name = 'myext';
If a node’s default_version differs from the committed value, it is running a different tree than main — reconcile it, exactly as extension registry mapping prescribes, before promoting anything against it.
Edge Cases & Gotchas
A removed migration script silently breaks reachability. Deleting an intermediate --A--B.sql in a “cleanup” commit strands every node at version A:
ERROR: extension "myext" has no update path from version "3.4.0" to "3.4.2"
Treat migration scripts as append-only; a CI rule that fails any PR deleting a --*.sql file prevents the whole class of failure. The Git history then always contains every hop ever shipped.
Binary .so files do not belong in Git. The compiled shared object is a build output, not source; committing it bloats the repo and couples it to one toolchain. Track the source and the control/SQL files, and let CI build and publish the .so to an artifact store keyed by the same checksum, per pinning extension versions in deployment manifests.
A revert of a non-transactional upgrade needs PITR, not just Git. Reverting the control-file commit restores the files, but if the upgrade had already run a non-transactional step in the database, the catalog is ahead of the files. Pair the Git revert with a snapshot and point-in-time recovery restore so the database and the tree return to the same commit.
Line-ending or whitespace churn poisons the checksum. A control file re-saved with different line endings changes the tree checksum without changing meaning, producing spurious drift. Enforce consistent line endings with a .gitattributes rule so the checksum only moves on a real change.