Recommended design
This proposal defines how to build and publish a CloudNativePG (CNPG) ImageVolume extension image from a Rust pgrx project using GitHub Actions, with clear dependency inventory, license documentation, vulnerability traceability, provenance, and attestations.
The release source is selected by a GitHub release version tag. The source tree includes a committed Cargo.lock.
The build and release process should make it easy to answer:
- What source produced this image?
- Which exact Rust dependencies were selected for the build?
- What licenses are declared by those Rust dependencies?
- Which PostgreSQL/CNPG environment was the extension built and tested against?
- What files are actually present in the published extension image?
- Which released image digests are affected when a new dependency vulnerability is disclosed?
- Which dependency or build-input changes require a rebuild?
- Which GitHub workflow produced and attested a particular image digest?
The design should avoid treating vulnerability-scanner output or license-policy decisions as part of the SBOM itself. The SBOM is an inventory; vulnerability and license analysis are consumers of that inventory.
This proposal does not:
- enforce an allow/deny policy for dependency licenses;
- require vendoring Rust dependencies into the source archive;
- require a custom PostgreSQL runtime image;
- treat container tags as immutable artifact identities;
- run vulnerability scanning as a required build-time gate.
The primary release artifact is a CNPG-compatible OCI extension image.
The final image is intentionally minimal:
/
├── lib/
│ └── <extension>.so
└── share/
└── extension/
├── <extension>.control
├── <extension>--<version>.sql
└── <extension>--<old>--<new>.sql
A scratch final image is preferred when the extension does not need to carry additional shared libraries.
CloudNativePG mounts the extension image read-only and, for the standard layout, adds the mounted share directory to PostgreSQL's extension control path and the mounted lib directory to its dynamic library path.
The extension shared library must be compatible with the PostgreSQL operand with respect to:
- PostgreSQL major version;
- operating-system distribution / ABI;
- CPU architecture.
The immutable release identity is the OCI digest:
ghcr.io/<org>/<extension>@sha256:<digest>
Tags such as the following are convenience aliases only:
v1.4.0-pg18
v1.4.0-pg18-trixie
A GitHub release version tag selects the source revision.
For example:
v1.4.0
↓
resolved Git commit
↓
GitHub-generated source archive
The source archive must contain the committed Cargo.lock.
The workflow should resolve the release tag to its Git commit SHA and record that SHA as the durable source identity.
The recommended sequence is:
release tag
↓
resolve tag → commit SHA
↓
download GitHub-generated archive for that commit
↓
extract source
↓
verify Cargo.lock is present
This preserves the version tag as the human release identifier while avoiding dependence on a movable tag during the rest of the build.
GitHub-generated source archives are snapshots of repository contents, but the outer compressed archive is not a durable byte-for-byte identity: tags can be moved, and GitHub may change compression details while preserving extracted contents. For provenance and rebuild decisions, the resolved commit SHA is therefore canonical.
The workflow may also record the SHA-256 of the downloaded archive as evidence about the exact bytes consumed by that particular run, but the archive hash should not replace the commit SHA as the long-term source identity.
Cargo.lock is the authoritative version lock for the Rust dependency resolution used by the release.
The build must not run an unconstrained dependency update.
All Cargo operations used for validation and compilation should use the committed lockfile and fail rather than rewriting it.
Conceptually:
source archive
├── Cargo.toml
├── Cargo.lock ← exact dependency versions/checksums
├── rust-toolchain.toml
├── src/
├── sql/
└── <extension>.control
The following inputs should be explicit and recorded by the workflow.
| Input | Role | Rebuild when changed? |
|---|---|---|
| Release tag | Human release identifier | Yes |
| Resolved Git commit SHA | Canonical source identity | Yes |
Cargo.lock |
Exact Rust dependency resolution | Yes |
| Cargo manifests | Dependency/features/build configuration | Yes |
| Rust toolchain | Compiler and Cargo version | Yes |
pgrx crate version |
PostgreSQL Rust integration | Yes |
cargo-pgrx version |
Packaging/build tooling | Yes |
| PostgreSQL major | Native compatibility boundary | New artifact family |
| Target architecture | Native compatibility boundary | New platform artifact |
| Builder image digest | Compiler/system ABI environment | Yes |
| CNPG/PostgreSQL operand digest | Runtime compatibility target | Yes |
| Build scripts / Dockerfile | Build definition | Yes |
| Pinned GitHub Action revisions | CI implementation | Applies to subsequent builds |
| SBOM generator version | Inventory-generation implementation | SBOM refresh; image rebuild not inherently required |
| Vulnerability database version | Current analysis data | No |
The builder and test environment should match the PostgreSQL/CNPG operand's distribution and architecture closely enough that the produced shared library is ABI-compatible.
GitHub Actions receives a release version tag.
The workflow:
- resolves the tag to a Git commit SHA;
- downloads the GitHub-generated archive corresponding to that immutable commit;
- extracts the archive;
- verifies that
Cargo.lockexists; - records the tag, commit SHA, and source archive hash in build metadata.
Before SBOM generation or compilation:
cargo metadata \
--locked \
--no-default-features \
--features pg18 \
--filter-platform "${RUST_TARGET}" \
--format-version 1 \
>/dev/nullThe exact feature set must match the feature set used by the pgrx build.
For a PostgreSQL 18 extension this will normally include the corresponding pg18 pgrx feature.
The build should also verify that Cargo operations did not modify Cargo.lock.
Cargo may fetch crates from crates.io or an approved registry/mirror, but dependency selection is constrained by the committed Cargo.lock.
The lockfile records exact versions and registry checksums. Dependency fetching must therefore use locked resolution rather than updating dependency versions during the release build.
Vendoring can be added later if stronger hermetic/offline builds are required, but it is not required by this proposal.
Generate a CycloneDX SBOM using cargo-cyclonedx.
Inputs are:
Cargo.toml / workspace manifests
+
Cargo.lock
+
cargo metadata
+
selected Cargo features
+
target triple
For example:
cargo cyclonedx \
--format json \
--describe binaries \
--no-default-features \
--features pg18 \
--target "${RUST_TARGET}" \
--target-in-filenameFor a pgrx project, --describe binaries is useful because the PostgreSQL extension is a Rust cdylib.
The resulting document is the authoritative Rust dependency and license inventory for the selected build configuration.
It should contain, where available:
- crate/package name;
- exact version;
- dependency relationships;
- package URL identifiers;
- declared license expression or license metadata;
- target-specific/build-specific dependency selection.
This SBOM is generated from Cargo's dependency model, not by scanning the compiled .so.
The release build does not need to run cargo audit.
The build's responsibility is to produce and attest an accurate dependency inventory. Vulnerability analysis is performed later by consuming the published Rust SBOM and current advisory databases.
The relationship is:
Cargo manifests + Cargo.lock
↓
cargo-cyclonedx
↓
attested Rust SBOM
↓
continuous / on-demand vulnerability analysis
+
current RustSec / OSV / CVE data
This keeps vulnerability knowledge out of the artifact-generation path.
A newly published advisory can therefore be evaluated against already-released image digests without rebuilding them merely to discover whether they are affected. A rebuild is required only when remediation changes a build input, normally Cargo.lock or source code.
Use an exact, pinned cargo-pgrx version compatible with the project's pgrx dependency.
Register or provide the matching PostgreSQL pg_config, then package the extension using cargo pgrx package.
Conceptually:
cargo pgrx package \
--pg-config "${PG_CONFIG}" \
--no-default-features \
--features pg18 \
--out-dir ./packageThe resulting package contains the shared object, control file, generated SQL, and any extension upgrade SQL.
Convert the pgrx package output into a stable CNPG root filesystem:
rootfs/
├── lib/
│ └── <extension>.so
└── share/
└── extension/
├── <extension>.control
├── <extension>--<version>.sql
└── ...
The normalized layout decouples the published extension-image contract from distribution-specific PostgreSQL installation paths such as /usr/lib/postgresql/18/lib.
Do not create a separate PostgreSQL/CNPG integration harness for this release process.
The CNPG extension build process already provides infrastructure for end-to-end extension smoke testing. The release workflow should reuse that infrastructure and make successful E2E execution a promotion gate for the built extension image.
Conceptually:
cargo pgrx package
↓
normalize CNPG rootfs
↓
build candidate extension image
↓
existing CNPG extension-build E2E infrastructure
│
├── deploy matching PostgreSQL/CNPG environment
├── load candidate extension image
├── CREATE EXTENSION <extension>
└── run existing smoke checks
↓
promotion allowed only on success
The E2E job should run against the intended PostgreSQL major, operating-system/ABI family, architecture, and CNPG/PostgreSQL operand combination supported by the extension build process.
Where the existing E2E infrastructure supports upgrade-path testing, the release workflow should use that rather than introducing a second bespoke upgrade harness.
The tested PostgreSQL/CNPG operand identity or other compatibility inputs exposed by the existing infrastructure should be captured in build metadata/provenance where practical. The operand image is not embedded into the final extension image.
The final Dockerfile should be minimal:
FROM scratch
COPY rootfs/lib/ /lib/
COPY rootfs/share/ /share/Optional OCI labels may document:
- extension version;
- Git commit SHA;
- source repository;
- PostgreSQL major;
- expected distribution;
- build revision.
The image is pushed to GHCR and identified by its resulting digest.
Two SBOM views are retained because they answer different questions.
Generator: cargo-cyclonedx
Primary inputs:
Cargo.lock;- Cargo manifests;
cargo metadata;- exact feature selection;
- target triple.
Purpose:
- exact Rust crate/version inventory;
- dependency graph;
- declared license inventory;
- future vulnerability matching.
This is the important SBOM for Rust licensing because a compiled .so does not preserve enough generic filesystem metadata for a scanner to reliably reconstruct every dependency's declared license.
Generator: BuildKit's SBOM integration, using its Syft-based scanner by default.
Input: the final OCI filesystem.
For the proposed scratch image, the scanner sees approximately:
/lib/<extension>.so
/share/extension/<extension>.control
/share/extension/<extension>--<version>.sql
It does not treat the builder's Rust toolchain, compiler, Cargo registry cache, PostgreSQL development headers, or other build-only packages as components of the shipped extension image.
This is desirable: those dependencies belong in build provenance, not in the runtime filesystem inventory.
The OCI SBOM may discover additional metadata from the .so if supported metadata is embedded in the binary, but the design does not depend on binary reverse-engineering for the authoritative Rust dependency/license inventory.
The two documents describe different evidence:
Rust CycloneDX SBOM
= what Cargo selected to build this extension
OCI SPDX SBOM
= what the final OCI image actually contains
Both are useful and should be associated with the same final OCI digest.
The final image digest is the subject of the release attestations.
For example:
ghcr.io/<org>/<extension>@sha256:ABC
│
├── build provenance
├── Rust CycloneDX SBOM attestation
└── OCI/BuildKit SBOM attestation
The Buildx build should enable detailed provenance:
provenance: mode=max
BuildKit provenance records build-system information and OCI build inputs such as base/build images and build configuration.
GitHub Actions should create a GitHub artifact attestation whose subject is the exact pushed image digest.
Required workflow permissions include:
permissions:
contents: read
packages: write
id-token: write
attestations: writeAfter Buildx returns the digest, GitHub's attestation action should bind provenance to:
subject-name = ghcr.io/<org>/<extension>
subject-digest = sha256:<image digest>
The generated Rust CycloneDX JSON should also be attached as an SBOM attestation for the same image digest.
This gives a cryptographically verifiable relationship between:
GitHub repository / workflow identity
+
resolved source commit
+
build execution
+
Rust dependency/license inventory
+
published OCI digest
Actions used by the release workflow should be pinned by full commit SHA and updated deliberately.
The release should rely on GitHub's OIDC-backed artifact attestation/Sigstore model rather than a long-lived private signing key stored as a repository secret.
Consumers should verify the image digest's attestation against the expected GitHub organization/repository and trusted release workflow before deployment.
The deployment reference should use the digest:
image:
reference: ghcr.io/<org>/<extension>@sha256:<digest>rather than relying only on a mutable tag.
The SBOM is generated once for a release, but vulnerability knowledge changes over time.
The desired flow is:
published image digest
+
attested Rust SBOM
↓
continuous or on-demand SBOM scanning
+
new RustSec/OSV/CVE advisory data
↓
identify affected image digest(s)
↓
update dependency / Cargo.lock when remediation is needed
↓
rebuild, retest, re-attest
A new advisory database entry does not itself change the image and therefore does not require a rebuild merely to determine whether the existing image is affected.
A rebuild is triggered when remediation changes a build input, normally Cargo.lock.
Rebuild and produce a new image digest when any of the following changes:
- source commit;
Cargo.lock;- Cargo manifests or selected features;
- Rust toolchain;
pgrxorcargo-pgrx;- build Dockerfile/scripts;
- target architecture;
- builder image digest;
- target PostgreSQL/CNPG operand digest used for compatibility;
- PostgreSQL major version.
A PostgreSQL major change is a compatibility boundary.
For example:
<extension>:1.4.0-pg18
<extension>:1.4.0-pg19
should represent distinct build/test lines even when the Rust extension version is otherwise unchanged.
The following can change without changing the compiled extension:
- vulnerability advisory database;
- vulnerability-scanner version;
- SBOM viewer/reporting software;
- license-reporting software;
- SBOM generator version.
An organization may choose to regenerate or re-attest metadata after tooling changes, but those changes do not inherently mean the .so itself must be rebuilt.
When the CNPG/PostgreSQL operand image digest changes within the same PostgreSQL major and compatible distribution, this proposal recommends rebuilding and retesting the extension.
For example:
extension 1.4.0
+
PG18 operand digest A
↓
extension image digest X
extension 1.4.0
+
PG18 operand digest B
↓
extension image digest Y
The extension semantic version does not need to change merely because it was rebuilt against a newer compatible PostgreSQL operand.
A build revision tag may be used for convenience, but the OCI digest remains authoritative.
This provides explicit evidence that a particular extension image was tested against a particular PostgreSQL operand rather than relying only on an assumption of minor-version compatibility.
The release workflow should roughly follow this sequence:
GitHub release tag
│
▼
resolve tag → immutable commit SHA
│
▼
download/extract GitHub source archive
│
├── verify Cargo.lock
└── record source/archive metadata
│
▼
cargo metadata --locked
│
▼
cargo-cyclonedx
│
▼
Rust CycloneDX SBOM
│
▼
cargo pgrx package
│
▼
normalize CNPG rootfs
│
▼
build candidate scratch extension image
│
▼
existing CNPG extension-build E2E smoke tests
│
▼
promote/push tested image
│
├── OCI SBOM
└── BuildKit provenance
│
▼
push to GHCR
│
▼
image@sha256:<digest>
│
├── GitHub provenance attestation
└── GitHub Rust-SBOM attestation
For multiple CPU architectures, run the build/test/SBOM process independently for each architecture, then publish a multi-platform OCI index only after each platform artifact passes its tests.
For each published image digest, retain or make queryable:
extension name
extension version
release tag
resolved Git commit SHA
source archive SHA-256 from this build
Cargo.lock SHA-256
Rust toolchain version
pgrx version
cargo-pgrx version
cargo-cyclonedx version
PostgreSQL major
target architecture
builder image digest
tested CNPG/PostgreSQL operand digest
GitHub workflow identity
GitHub workflow commit/revision
final OCI digest
Rust SBOM attestation
OCI SBOM attestation
build provenance attestation
This metadata does not all need to be duplicated as OCI labels. Provenance and attestations are the preferred structured evidence.
The final system should allow a consumer to start with an image digest and trace backward:
OCI image digest
│
├── actual extension files
│
├── Rust dependency + license SBOM
│ └── exact crate versions
│
├── build provenance
│ ├── trusted GitHub workflow
│ ├── source commit
│ └── build environment
│
└── compatibility evidence
└── tested PostgreSQL/CNPG operand digest
It should also allow the reverse query:
crate foo 1.2.3
↓
Rust SBOM inventory
↓
affected extension release(s)
↓
affected OCI digest(s)
That is the primary operational goal of the design: dependency, license, vulnerability, source, and build provenance should all be traceable without depending on mutable image tags or attempting to infer the original Rust dependency graph from the compiled shared library alone.
| Tool | Responsibility |
|---|---|
| GitHub release/tag | Select release version |
| Git commit SHA | Durable source identity |
| GitHub source archive | Source transport |
Cargo.lock |
Exact Rust dependency resolution |
cargo metadata --locked |
Validate locked dependency graph/build selection |
cargo-cyclonedx |
Rust dependency + license SBOM |
cargo-pgrx |
Build/package PostgreSQL extension |
| CNPG extension-build E2E infrastructure | Run the existing end-to-end extension smoke tests and gate promotion |
| Buildx/BuildKit | Build OCI image, OCI SBOM, build provenance |
| Syft via BuildKit | Scan final OCI filesystem |
| GHCR | OCI distribution |
| GitHub artifact attestations | Bind provenance/SBOM claims to image digest |
| CNPG | Mount immutable extension image into PostgreSQL |
This proposal is based on the current behavior and recommendations documented by:
- GitHub Docs — Downloading source code archives
- GitHub Docs — Using artifact attestations to establish provenance for builds
- GitHub Docs — Artifact attestations
- CloudNativePG Docs — Image Volume Extensions
cargo-pgrxdocumentation — Building an Installation Packagecargo-cyclonedxdocumentation — Cargo metadata, feature/target selection, binary/cdylib SBOM generation, and license metadata- Docker Docs — Build attestations and SBOM attestations