name: CI
on:
push:
branches: [ main, develop ]
tags:
- 'v*'
pull_request:
branches: [ "*" ]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
MIN_COVERAGE: 90
jobs:
test:
name: Test Suite
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
rust: [stable, beta]
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
components: rustfmt, clippy
- name: Install dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev pkg-config libssl-dev
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-${{ matrix.rust }}-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.rust }}-target-
- name: Check formatting
run: cargo fmt --all -- --check
- name: Build
run: cargo build --verbose
- name: Run unit tests
run: cargo test --lib --verbose
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit
docs:
name: Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev pkg-config libssl-dev
- name: Build documentation
run: cargo doc --no-deps --all-features
env:
RUSTDOCFLAGS: "-D warnings"
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev pkg-config libssl-dev
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-coverage-
- name: Generate coverage report (lcov for Codecov)
run: cargo llvm-cov --lib --lcov --output-path lcov.info
- name: Generate coverage report (json for threshold check)
run: cargo llvm-cov --lib --json --output-path coverage.json
- name: Check coverage threshold (minimum ${{ env.MIN_COVERAGE }}%)
run: |
COVERAGE=$(cat coverage.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
totals = data['data'][0]['totals']
lines = totals['lines']
pct = lines['percent']
print(f'{pct:.2f}')
")
echo "Line coverage: ${COVERAGE}%"
if python3 -c "exit(0 if float('${COVERAGE}') >= ${{ env.MIN_COVERAGE }} else 1)"; then
echo "Coverage ${COVERAGE}% meets minimum threshold of ${{ env.MIN_COVERAGE }}%"
else
echo "Coverage ${COVERAGE}% is below minimum threshold of ${{ env.MIN_COVERAGE }}%"
exit 1
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
integration:
name: Integration Tests (PG ${{ matrix.pg_version }})
runs-on: ubuntu-latest
needs: test
strategy:
fail-fast: false
matrix:
pg_version: [15, 16, 17, 18]
services:
postgres:
image: postgres:${{ matrix.pg_version }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_walstream
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev pkg-config libssl-dev
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-integration-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-integration-
- name: Configure PostgreSQL for logical replication
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET wal_level = 'logical';"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET max_replication_slots = 16;"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET max_wal_senders = 16;"
# Restart PostgreSQL container to apply wal_level change (requires restart)
docker restart ${{ job.services.postgres.id }}
# Wait for PostgreSQL to be ready after restart
for i in $(seq 1 30); do
pg_isready -h localhost -p 5432 -U postgres && break
echo "Waiting for PostgreSQL after restart... ($i/30)"
sleep 2
done
- name: Verify PostgreSQL logical replication config
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -d test_walstream -c "SHOW wal_level;"
psql -h localhost -U postgres -d test_walstream -c "SHOW max_replication_slots;"
psql -h localhost -U postgres -d test_walstream -c "SHOW max_wal_senders;"
- name: Run integration tests
env:
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/test_walstream?replication=database"
DATABASE_URL_REGULAR: "postgresql://postgres:postgres@localhost:5432/test_walstream"
RUST_BACKTRACE: 1
run: |
cargo test --test snapshot_export -- --ignored --nocapture --test-threads=1
# Clean up any lingering replication slots between test suites
PGPASSWORD=postgres psql -h localhost -U postgres -d test_walstream \
-c "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_type = 'logical';" || true
cargo test --test rate_limited_streaming -- --ignored --nocapture --test-threads=1
PGPASSWORD=postgres psql -h localhost -U postgres -d test_walstream \
-c "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_type = 'logical';" || true
cargo test --test safe_transaction_consumer -- --ignored --nocapture --test-threads=1
PGPASSWORD=postgres psql -h localhost -U postgres -d test_walstream \
-c "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_type = 'logical';" || true
cargo test --test complex_types -- --ignored --nocapture --test-threads=1
PGPASSWORD=postgres psql -h localhost -U postgres -d test_walstream \
-c "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_type = 'logical';" || true
cargo test --test typed_deserialization -- --ignored --nocapture --test-threads=1
integration-ssl:
name: SSL Integration Tests (PG ${{ matrix.pg_version }})
runs-on: ubuntu-latest
needs: test
strategy:
fail-fast: false
matrix:
pg_version: [15, 16, 17, 18]
services:
postgres:
image: postgres:${{ matrix.pg_version }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_walstream
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake pkg-config libssl-dev postgresql-client
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-ssl-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-ssl-
- name: Generate SSL certificates
run: |
mkdir -p /tmp/pg-ssl
# Generate CA key and self-signed certificate
openssl req -new -x509 -days 365 -nodes \
-newkey rsa:2048 \
-keyout /tmp/pg-ssl/ca.key \
-out /tmp/pg-ssl/ca.crt \
-subj "/CN=pg-walstream-test-ca"
# Generate server key and CSR
openssl req -new -nodes \
-newkey rsa:2048 \
-keyout /tmp/pg-ssl/server.key \
-out /tmp/pg-ssl/server.csr \
-subj "/CN=localhost"
# Write SAN extension config to a file (avoids process substitution issues)
echo "subjectAltName=DNS:localhost" > /tmp/pg-ssl/server.ext
# Sign server cert with CA (SAN=DNS:localhost only, no IP SAN)
# This allows verify-full to succeed with localhost but fail with 127.0.0.1
openssl x509 -req -days 365 \
-in /tmp/pg-ssl/server.csr \
-CA /tmp/pg-ssl/ca.crt \
-CAkey /tmp/pg-ssl/ca.key \
-CAcreateserial \
-out /tmp/pg-ssl/server.crt \
-extfile /tmp/pg-ssl/server.ext
# Generate a separate "wrong" CA for negative tests
openssl req -new -x509 -days 365 -nodes \
-newkey rsa:2048 \
-keyout /tmp/pg-ssl/wrong-ca.key \
-out /tmp/pg-ssl/wrong-ca.crt \
-subj "/CN=wrong-ca"
- name: Verify generated certificates
run: |
echo "=== CA Certificate ==="
openssl x509 -in /tmp/pg-ssl/ca.crt -noout -subject -issuer
echo "=== Server Certificate ==="
openssl x509 -in /tmp/pg-ssl/server.crt -noout -subject -issuer
echo "=== Server Certificate SAN ==="
openssl x509 -in /tmp/pg-ssl/server.crt -noout -ext subjectAltName
echo "=== Verify server cert against CA ==="
openssl verify -CAfile /tmp/pg-ssl/ca.crt /tmp/pg-ssl/server.crt
- name: Configure PostgreSQL with SSL and logical replication
env:
PGPASSWORD: postgres
run: |
CONTAINER_ID=${{ job.services.postgres.id }}
# Copy certificates into the PostgreSQL container
docker cp /tmp/pg-ssl/server.crt $CONTAINER_ID:/var/lib/postgresql/server.crt
docker cp /tmp/pg-ssl/server.key $CONTAINER_ID:/var/lib/postgresql/server.key
docker cp /tmp/pg-ssl/ca.crt $CONTAINER_ID:/var/lib/postgresql/ca.crt
# Set proper ownership and permissions
docker exec $CONTAINER_ID chown postgres:postgres \
/var/lib/postgresql/server.crt \
/var/lib/postgresql/server.key \
/var/lib/postgresql/ca.crt
docker exec $CONTAINER_ID chmod 0600 /var/lib/postgresql/server.key
# Configure SSL
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET ssl = 'on';"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET ssl_cert_file = '/var/lib/postgresql/server.crt';"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET ssl_key_file = '/var/lib/postgresql/server.key';"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET ssl_ca_file = '/var/lib/postgresql/ca.crt';"
# Configure logical replication
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET wal_level = 'logical';"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET max_replication_slots = 16;"
psql -h localhost -U postgres -d test_walstream -c "ALTER SYSTEM SET max_wal_senders = 16;"
# Restart PostgreSQL to apply changes
docker restart $CONTAINER_ID
# Wait for PostgreSQL to be ready after restart
for i in $(seq 1 30); do
pg_isready -h localhost -p 5432 -U postgres && break
echo "Waiting for PostgreSQL after restart... ($i/30)"
sleep 2
done
- name: Verify PostgreSQL SSL and replication config
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -d test_walstream -c "SHOW ssl;"
psql -h localhost -U postgres -d test_walstream -c "SHOW wal_level;"
psql -h localhost -U postgres -d test_walstream -c "SHOW max_replication_slots;"
psql -h localhost -U postgres -d test_walstream -c "SHOW max_wal_senders;"
# Verify SSL is actually working from the client side
PGSSLMODE=require psql -h localhost -U postgres -d test_walstream \
-c "SELECT ssl, version FROM pg_stat_ssl WHERE pid = pg_backend_pid();"
- name: Build SSL test binary
run: cargo test --test ssl_connections --no-default-features --features rustls-tls --no-run
- name: Run SSL integration tests
env:
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/test_walstream?replication=database"
DATABASE_URL_REGULAR: "postgresql://postgres:postgres@localhost:5432/test_walstream"
SSL_CA_CERT_PATH: "/tmp/pg-ssl/ca.crt"
SSL_WRONG_CA_CERT_PATH: "/tmp/pg-ssl/wrong-ca.crt"
RUST_BACKTRACE: 1
run: |
cargo test --test ssl_connections --no-default-features --features rustls-tls -- --ignored --nocapture --test-threads=1
# Clean up any lingering replication slots between runs
PGPASSWORD=postgres psql -h localhost -U postgres -d test_walstream \
-c "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_type = 'logical';" || true
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
needs: [test, integration, integration-ssl, coverage]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify tag is on main branch
run: |
git fetch origin main
if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "Tag commit is on main branch — proceeding with publish."
else
echo "Error: Tag is NOT on the main branch. Publishing is only allowed from main."
exit 1
fi
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev pkg-config libssl-dev
- name: Extract version from tag
id: get_version
run: |
TAG=${GITHUB_REF#refs/tags/}
VERSION=${TAG#v}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Verify tag matches Cargo.toml version
run: |
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*= "//' | sed 's/".*//')
TAG_VERSION="${{ steps.get_version.outputs.version }}"
echo "Cargo.toml version: $CARGO_VERSION"
echo "Git tag version: $TAG_VERSION"
if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then
echo "Error: Version mismatch between Cargo.toml ($CARGO_VERSION) and git tag ($TAG_VERSION)"
exit 1
fi
- name: Dry run publish
run: cargo publish --dry-run
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Publish to crates.io
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}