Progressive Delivery & Rollout Testing: Ship Safely at Scale
Progressive delivery is the practice of releasing software incrementally—starting with a small percentage of users and gradually expanding based on observed outcomes. It combines the best of canary deployments, feature flags, and A/B testing into a coherent strategy for reducing deployment risk.
Testing progressive delivery requires a different mindset than traditional deployment testing. You're not just checking if the release works—you're building automated gates that determine whether to continue, pause, or roll back based on real user behavior.
What Is Progressive Delivery?
Progressive delivery extends continuous delivery with feedback loops at each stage:
Internal testing → 1% rollout → 5% → 25% → 50% → 100%
↑ ↑ ↑ ↑ ↑
Validate Validate Validate ... MonitorAt each stage, automated and manual checks determine whether to proceed. If metrics degrade, the rollout pauses or rolls back. If everything looks good, it advances.
The Three Gates of Progressive Delivery
Gate 1: Pre-Release Validation (0% → Internal)
Before any users see the release, validate in a production-like environment:
class PreReleaseValidator:
def validate(self, release_id: str) -> ValidationReport:
results = []
# Functional correctness
results.append(self.run_smoke_tests(release_id))
results.append(self.run_regression_suite(release_id))
# Performance baselines
results.append(self.run_load_test(release_id, concurrent_users=100))
# Security scan
results.append(self.run_security_scan(release_id))
# Schema compatibility
results.append(self.validate_database_compatibility(release_id))
return ValidationReport(
release_id=release_id,
passed=all(r.passed for r in results),
results=results
)Gate 2: Statistical Significance (1% → 5% → 25%)
At each rollout stage, collect enough data to make statistically valid decisions:
from scipy import stats
import numpy as np
class RolloutGate:
def __init__(self, min_sample_size: int = 1000, significance_level: float = 0.05):
self.min_sample_size = min_sample_size
self.alpha = significance_level
def should_proceed(
self,
control_metrics: list[float],
treatment_metrics: list[float],
metric_name: str,
higher_is_better: bool = True
) -> GateDecision:
if len(control_metrics) < self.min_sample_size:
return GateDecision(
proceed=False,
reason=f"Insufficient sample size: {len(control_metrics)} < {self.min_sample_size}",
wait=True
)
# Two-sample t-test
t_stat, p_value = stats.ttest_ind(treatment_metrics, control_metrics)
treatment_mean = np.mean(treatment_metrics)
control_mean = np.mean(control_metrics)
relative_change = (treatment_mean - control_mean) / control_mean
# For error rates, lower is better
significant_improvement = (
p_value < self.alpha and
(relative_change > 0) == higher_is_better
)
significant_regression = (
p_value < self.alpha and
(relative_change > 0) != higher_is_better and
abs(relative_change) > 0.02 # >2% regression triggers rollback
)
if significant_regression:
return GateDecision(
proceed=False,
rollback=True,
reason=f"{metric_name} regressed by {relative_change:.1%} (p={p_value:.3f})"
)
return GateDecision(
proceed=True,
reason=f"{metric_name} {'improved' if significant_improvement else 'unchanged'} "
f"(change={relative_change:.1%}, p={p_value:.3f})"
)Gate 3: Business Impact Validation (50% → 100%)
At higher percentages, validate business outcomes:
def validate_business_metrics(release_id: str, rollout_percentage: float) -> bool:
window_hours = max(24, int(rollout_percentage * 0.5)) # Longer window for higher %
checks = [
BusinessCheck(
name="revenue_per_session",
query=f"SELECT AVG(revenue) FROM sessions WHERE release_id='{release_id}'",
baseline_query="SELECT AVG(revenue) FROM sessions WHERE release_id='stable'",
max_regression=0.01 # Max 1% decline
),
BusinessCheck(
name="checkout_conversion",
query=f"SELECT SUM(converted)/COUNT(*) FROM checkout_sessions WHERE release='{release_id}'",
baseline_query="SELECT SUM(converted)/COUNT(*) FROM checkout_sessions WHERE release='stable'",
max_regression=0.005 # Max 0.5% decline
),
]
for check in checks:
treatment_value = db.execute(check.query).scalar()
baseline_value = db.execute(check.baseline_query).scalar()
regression = (baseline_value - treatment_value) / baseline_value
if regression > check.max_regression:
logger.error(
f"Business metric {check.name} regressed by {regression:.2%} "
f"(baseline={baseline_value:.4f}, treatment={treatment_value:.4f})"
)
return False
return TrueAutomated Rollout Orchestration
Build a rollout controller that automates progression:
from enum import Enum
class RolloutStage(Enum):
INTERNAL = 0
ONE_PERCENT = 1
FIVE_PERCENT = 5
TWENTY_FIVE_PERCENT = 25
FIFTY_PERCENT = 50
FULL = 100
class ProgressiveRolloutController:
STAGE_SEQUENCE = [
RolloutStage.ONE_PERCENT,
RolloutStage.FIVE_PERCENT,
RolloutStage.TWENTY_FIVE_PERCENT,
RolloutStage.FIFTY_PERCENT,
RolloutStage.FULL
]
# Minimum time to observe at each stage
MIN_STAGE_DURATION = {
RolloutStage.ONE_PERCENT: timedelta(hours=2),
RolloutStage.FIVE_PERCENT: timedelta(hours=4),
RolloutStage.TWENTY_FIVE_PERCENT: timedelta(hours=8),
RolloutStage.FIFTY_PERCENT: timedelta(hours=12),
}
def advance_or_rollback(self, release_id: str):
current_stage = self.get_current_stage(release_id)
stage_started = self.get_stage_start_time(release_id)
# Enforce minimum stage duration
min_duration = self.MIN_STAGE_DURATION.get(current_stage)
if min_duration and datetime.now() - stage_started < min_duration:
return RolloutAction.WAIT
# Evaluate gates
gate_result = self.evaluate_gates(release_id, current_stage)
if gate_result.rollback:
self.rollback(release_id)
return RolloutAction.ROLLED_BACK
if gate_result.proceed:
next_stage = self.get_next_stage(current_stage)
if next_stage:
self.advance_to_stage(release_id, next_stage)
return RolloutAction.ADVANCED
else:
self.complete_rollout(release_id)
return RolloutAction.COMPLETED
return RolloutAction.WAITINGTesting the Rollout Infrastructure
Test your progressive delivery infrastructure itself:
def test_rollout_advances_on_healthy_metrics():
"""Rollout should advance when all metrics are within bounds."""
release_id = create_test_release()
set_rollout_percentage(release_id, 1)
# Simulate healthy metrics
inject_test_metrics(
release_id=release_id,
error_rate=0.001, # Same as baseline
latency_p95_ms=200,
conversion_rate=0.045
)
controller = ProgressiveRolloutController()
action = controller.advance_or_rollback(release_id)
assert action == RolloutAction.ADVANCED
assert get_rollout_percentage(release_id) == 5
def test_rollout_rolls_back_on_error_spike():
"""Rollout should roll back when error rate spikes."""
release_id = create_test_release()
set_rollout_percentage(release_id, 5)
# Simulate elevated error rate
inject_test_metrics(
release_id=release_id,
error_rate=0.05, # 5% vs 0.1% baseline
latency_p95_ms=200,
conversion_rate=0.045
)
controller = ProgressiveRolloutController()
action = controller.advance_or_rollback(release_id)
assert action == RolloutAction.ROLLED_BACK
assert get_rollout_percentage(release_id) == 0
def test_insufficient_sample_size_causes_wait():
"""Rollout should wait, not advance, when sample size is too small."""
release_id = create_test_release()
set_rollout_percentage(release_id, 1)
# Very few requests - not enough to decide
inject_test_metrics(release_id=release_id, request_count=50)
controller = ProgressiveRolloutController()
action = controller.advance_or_rollback(release_id)
assert action == RolloutAction.WAITINGArgo Rollouts Integration
For Kubernetes deployments, Argo Rollouts provides built-in progressive delivery:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: my-app
spec:
strategy:
canary:
steps:
- setWeight: 1
- pause: {duration: 2h}
- analysis:
templates:
- templateName: error-rate-analysis
- setWeight: 5
- pause: {duration: 4h}
- analysis:
templates:
- templateName: error-rate-analysis
- templateName: latency-analysis
- setWeight: 25
- pause: {duration: 8h}
- setWeight: 50
- pause: {duration: 12h}
- setWeight: 100
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: error-rate-analysis
spec:
metrics:
- name: error-rate
interval: 5m
successCondition: result[0] < 0.02
failureLimit: 3
provider:
prometheus:
address: http://prometheus:9090
query: |
sum(rate(http_requests_total{status=~"5..",version="canary"}[5m]))
/
sum(rate(http_requests_total{version="canary"}[5m]))Progressive Delivery Testing Checklist
Before starting a progressive rollout:
- Pre-release smoke tests pass
- Performance baselines established
- Monitoring dashboards configured
- Automated gates configured with appropriate thresholds
- Rollback tested and documented
During rollout:
- Error rates within acceptable range vs. control
- Latency p50/p95/p99 not significantly worse
- Business metrics (conversion, revenue) not declining
- Minimum sample size threshold met before advancing
- Stage minimum duration enforced
After full rollout:
- All monitoring confirms stable behavior
- Old version infrastructure decommissioned
- Release tagged and documented
- Post-mortems complete for any pauses or rollbacks
Conclusion
Progressive delivery doesn't make deployments risk-free—it makes risks manageable. By rolling out to a small percentage first, you limit the blast radius of any issue. By building automated gates, you ensure problems are caught before they affect everyone.
The key is making the automation trustworthy: test your gates themselves, validate that rollbacks work, and ensure your metrics accurately reflect user experience. An automated gate that never triggers isn't protecting you—it's giving you false confidence.
Build your progressive delivery system incrementally: start with basic percentage rollouts, add automated metric checks, then implement fully automated promotion and rollback. Each step reduces deployment risk while increasing your team's velocity.