
Release Orchestrator
- 73 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Release Orchestrator is a Claude skill that runs pre-flight release validation, semantic-version bumping, changelog generation and deployment-readiness scoring with a GO/CONDITIONAL/NO-GO decision.
About
Release Orchestrator runs pre-flight validation, generates changelogs from conventional commits, auto-bumps semantic versions and scores deployment readiness with a GO/CONDITIONAL/NO-GO decision. A developer uses it to gate a release: check branch sync, scan for secrets, verify conventional commits and lock files, then produce a version bump, changelog and readiness score. It ships four CI-friendly Python tools and chains them into an end-to-end pipeline.
- 7-check pre-flight: branch sync, conflicts, secrets, commits, deps
- Semver auto-bump from conventional commits and Keep-a-Changelog generation
- Deployment readiness score with GO / CONDITIONAL / NO-GO gating
Release Orchestrator by the numbers
- 73 all-time installs (skills.sh)
- Ranked #129 of 248 Release Management skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
release-orchestrator capabilities & compatibility
- Capabilities
- release notes · pr review expert · ci cd pipeline
- Works with
- github
- Use cases
- ci cd · devops · security audit
- Pricing
- Free
What release-orchestrator says it does
Use when running pre-release validation, generating changelogs, bumping semantic versions, scoring deployment readiness, or orchestrating end-to-end release pipelines.
The agent runs seven automated checks:
Any single category below 40 triggers a mandatory blocker regardless of overall score.
npx skills add https://github.com/borghei/claude-skills --skill release-orchestratorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Run pre-flight checks, auto-bump semver, generate changelogs and score deployment readiness with a GO/CONDITIONAL/NO-GO decision.
Who is it for?
Gating a release with pre-flight checks, semver bump, changelog and a readiness decision.
Skip if: Writing customer-facing release copy (use release-notes) or reviewing an individual PR.
When should I use this skill?
Running pre-release validation, bumping versions, generating a changelog, or scoring deployment readiness.
What you get
A validated, versioned release with a changelog and a scored GO/CONDITIONAL/NO-GO deployment decision.
- pre-flight report
- version bump
- changelog
By the numbers
- 7 pre-flight checks
- 4 Python tools
- 7 weighted readiness categories
Files
Release Orchestrator
The agent runs pre-flight validation, generates changelogs from conventional commits, auto-bumps semantic versions, and scores deployment readiness with a GO/CONDITIONAL/NO-GO decision.
---
Quick Start
# Pre-flight: branch sync, secrets, conflicts, commits, deps
python scripts/preflight_checker.py --repo . --base main --verbose
# Changelog from conventional commits
python scripts/changelog_generator.py --repo . --from v1.2.0 --to HEAD
# Auto-detect version bump from commit history
python scripts/version_bumper.py --repo . --dry-run
# Score deployment readiness (7 categories, weighted)
python scripts/release_readiness_scorer.py --input release_data.json --jsonTools Overview
| Tool | Input | Output |
|---|---|---|
preflight_checker.py | Repo path + base branch | Pass/fail on 7 checks (sync, conflicts, secrets, commits, deps) |
changelog_generator.py | Git repo + ref range | Keep a Changelog markdown with commit grouping |
version_bumper.py | Repo path | Next semver from commit analysis; updates version files |
release_readiness_scorer.py | Release data JSON | Score 0-100, GO/CONDITIONAL/NO-GO decision |
All tools support --json for machine output. Exit code 0 = pass, 1 = fail (CI-friendly).
---
Workflow 1: Pre-Flight Validation
python scripts/preflight_checker.py --repo . --base main --jsonThe agent runs seven automated checks:
1. Branch sync -- local branch up to date with remote base 2. Merge conflicts -- dry-run merge to detect conflicts 3. Uncommitted changes -- fail if working tree is dirty 4. Secret scanning -- pattern-match for API keys, tokens, passwords (AWS, GCP, GitHub, Stripe, JWT) 5. Gitignore validation -- .env, credential files covered 6. Conventional commits -- recent commits follow type(scope): description 7. Dependency audit -- lock file consistency (package-lock.json, poetry.lock, etc.)
Validation checkpoint: All 7 checks pass. Exit code 0.
---
Workflow 2: Version Management and Changelog
Step 1 -- Auto-detect version bump.
python scripts/version_bumper.py --repo . --dry-run --json| Commit Type | Bump | Example |
|---|---|---|
fix: | PATCH (0.0.x) | fix(auth): handle expired tokens |
feat: | MINOR (0.x.0) | feat(api): add pagination |
feat!: or BREAKING CHANGE | MAJOR (x.0.0) | feat!: redesign auth flow |
docs:, chore:, test: | No bump | docs: update README |
Reads from: package.json, pyproject.toml, setup.py, setup.cfg, Cargo.toml, VERSION file. Pre-release support: --pre alpha|beta|rc produces 1.3.0-rc.1.
Step 2 -- Generate changelog.
python scripts/changelog_generator.py --repo . --from latest --to HEAD --output CHANGELOG.md --fullGroups commits by type (Added, Changed, Fixed, Security, Breaking Changes) with hashes and @author attribution.
Step 3 -- Apply version bump.
python scripts/version_bumper.py --repo . # writes to all discovered version filesValidation checkpoint: --dry-run shows expected version. Changelog covers 100% of commits.
---
Workflow 3: Deployment Readiness
python scripts/release_readiness_scorer.py --input release_data.json --jsonThe agent scores across 7 weighted categories:
| Category | Weight | Measures |
|---|---|---|
| Tests | 25% | Pass rate, coverage, flaky count |
| Code Quality | 20% | Lint errors, type errors, complexity, duplication |
| Documentation | 15% | README, API docs, changelog, migration guide |
| Security | 15% | No secrets, no critical CVEs, SAST clean |
| Breaking Changes | 10% | Documented, migration path, deprecation notices |
| Dependencies | 10% | Lock files consistent, no yanked packages |
| Rollback Plan | 5% | Procedure documented, DB migration reversible, feature flags |
Decision thresholds:
| Score | Decision | Action |
|---|---|---|
| 80-100 | GO | Proceed with deployment |
| 60-79 | CONDITIONAL | Proceed with mitigations documented |
| 0-59 | NO-GO | Address blockers first |
Any single category below 40 triggers a mandatory blocker regardless of overall score.
Validation checkpoint: Score >= 80 (GO). Zero category blockers.
---
End-to-End Release Pipeline
Chain all workflows into a single automated pipeline:
#!/bin/bash
set -e
# Phase 1: Pre-flight
python scripts/preflight_checker.py --repo . --base main --json > /tmp/preflight.json
# Phase 2: Tests (project-specific)
python -m pytest --cov=src --cov-report=json:coverage.json -v
# Phase 3: Version bump (dry-run)
python scripts/version_bumper.py --repo . --dry-run --json > /tmp/version.json
# Phase 4: Changelog
python scripts/changelog_generator.py --repo . --from latest --to HEAD
# Phase 5: Readiness assessment
python scripts/release_readiness_scorer.py --input release_data.json --json > /tmp/readiness.json
DECISION=$(python -c "import json; print(json.load(open('/tmp/readiness.json'))['decision'])")
echo "Decision: $DECISION"Non-interactive by default. Blocks on: pre-flight failure, test failure, or NO-GO readiness.
---
Release Types
| Type | Branch Pattern | Bump | Notes |
|---|---|---|---|
| Hotfix | hotfix/v1.2.1 from tag | PATCH | Minimal fix, branches from release tag |
| Patch | Standard flow | PATCH | Accumulated bug fixes |
| Minor | Standard flow | MINOR | New features, backward compatible |
| Major | Standard flow | MAJOR | Breaking changes, needs migration docs |
| Pre-release | Standard flow | `--pre alpha\ | beta\ |
---
CI/CD Integration
- name: Pre-flight Check
run: python scripts/preflight_checker.py --repo . --base main --json > preflight.json
- name: Version Bump
run: python scripts/version_bumper.py --repo . --dry-run --json > version.json
- name: Changelog
run: python scripts/changelog_generator.py --repo . --from latest --to HEAD --output CHANGELOG.md
- name: Readiness Score
run: python scripts/release_readiness_scorer.py --input release_data.jsonGit hook: python scripts/preflight_checker.py --repo . --base main in .git/hooks/pre-push.
---
Anti-Patterns
1. Skipping pre-flight -- secrets ship to production. Always run pre-flight before any release work. 2. Manual version bumping -- leads to inconsistencies. Let commit history drive the version. 3. No rollback plan -- every release needs documented rollback (git revert, feature flags, or DB migration down). 4. Ignoring single-category blockers -- a 95 overall score with 35 Security = NO-GO. 5. Changelog after release -- generate before tagging so reviewers can validate.
---
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Pre-flight "HEAD is detached" | CI checked out specific commit | Check out a named branch first |
| Changelog "No commits found" | --from ref does not exist | Verify tag with git tag -l; use --since date range |
| Version bumper cannot parse version | Non-semver format (e.g., 1.0) | Use MAJOR.MINOR.PATCH in all manifest files |
| Readiness scorer exits 1 despite high score | Single category below 40-point blocker | Check BLOCKERS section; fix failing category |
| Secret scan false positives on test fixtures | Pattern matches example tokens | Lines with "example"/"placeholder" are skipped; move fixtures to non-tracked dir |
---
References
| Guide | Path |
|---|---|
| Release Engineering Guide | references/release_engineering_guide.md |
| Rollback Strategies | references/rollback_strategies.md |
| CI/CD Best Practices | references/ci_cd_best_practices.md |
---
Integration Points
| Skill | Integration |
|---|---|
senior-devops | Pipeline stages consume pre-flight and readiness JSON as gates |
senior-qa | Test results feed Tests category (25% weight) |
senior-secops | Secret scan and CVE counts feed Security category (15%) |
code-reviewer | Code quality metrics feed Code Quality category (20%) |
devops-workflow-engineer | Workflow YAML calls tools as pipeline steps |
---
Last Updated: April 2026 Version: 2.1.0
Release Checklist: v{VERSION}
Release Date: {DATE} Release Manager: {NAME} Release Type: {Hotfix | Patch | Minor | Major | Pre-release}
---
Pre-Flight
- [ ] Branch is up to date with base branch
- [ ] No merge conflicts with base branch
- [ ] No uncommitted changes in working tree
- [ ] No secrets detected in codebase
- [ ] .gitignore covers all sensitive file patterns
- [ ] All commits follow conventional commit format
- [ ] Dependency lock files are consistent
Tests
- [ ] All unit tests pass
- [ ] All integration tests pass
- [ ] All E2E tests pass
- [ ] Code coverage meets threshold (target: 80%+)
- [ ] No new flaky tests introduced
- [ ] Coverage has not regressed from previous release
Code Quality
- [ ] Linter passes with zero errors
- [ ] Type checker passes
- [ ] No high-complexity functions introduced
- [ ] No significant code duplication added
- [ ] Dead code removed
Security
- [ ] No secrets in codebase (preflight_checker.py clean)
- [ ] Dependency vulnerabilities resolved (zero critical/high CVEs)
- [ ] SAST scan clean
Documentation
- [ ] README updated for new features
- [ ] API documentation current
- [ ] Changelog generated
- [ ] Migration guide provided (if breaking changes)
Breaking Changes
- [ ] Breaking changes documented in release notes
- [ ] Migration path provided for consumers
- [ ] Deprecation notices issued in prior release
Dependencies
- [ ] Lock files regenerated and committed
- [ ] No yanked packages in dependency tree
- [ ] Major dependency upgrades reviewed and tested
Deployment
- [ ] Version bumped (version_bumper.py)
- [ ] Changelog generated (changelog_generator.py)
- [ ] Release readiness score >= 80 (release_readiness_scorer.py)
- [ ] Rollback plan documented
- [ ] Database migrations reversible
- [ ] Feature flags in place for new features
- [ ] Monitoring and alerting configured
Post-Deploy
- [ ] Smoke tests pass in production
- [ ] Key metrics within baseline (error rate, latency)
- [ ] Stakeholders notified
- [ ] Release notes published
---
Sign-Off
| Role | Name | Approved | Date |
|---|---|---|---|
| Engineering Lead | [ ] | ||
| QA Lead | [ ] | ||
| Product Owner | [ ] |
Notes
{Add any release-specific notes, known issues, or follow-up items here.}
{
"version": "1.3.0",
"tests": {
"total_tests": 342,
"passed_tests": 340,
"coverage_percent": 82,
"flaky_tests": 1,
"coverage_delta": -0.5
},
"code_quality": {
"lint_errors": 0,
"type_errors": 2,
"complexity_violations": 1,
"duplication_percent": 3.2,
"dead_code_count": 0
},
"documentation": {
"readme_updated": true,
"api_docs_current": true,
"changelog_generated": true,
"migration_guide": false
},
"security": {
"secrets_found": 0,
"critical_cves": 0,
"high_cves": 0,
"medium_cves": 2,
"sast_clean": true
},
"breaking_changes": {
"breaking_changes_count": 1,
"breaking_changes_documented": true,
"migration_path_provided": true,
"deprecation_notice_given": false
},
"dependencies": {
"lock_files_consistent": true,
"yanked_packages": 0,
"major_upgrades_pending": 1,
"major_upgrades_reviewed": true,
"outdated_dependencies": 8
},
"rollback_plan": {
"rollback_documented": true,
"db_migration_reversible": true,
"feature_flags_in_place": true,
"monitoring_configured": true
}
}
CI/CD Best Practices
Pipeline Design Patterns
Stage-Gate Pipeline
Organize the pipeline into sequential stages. Each stage must pass before the next begins.
Build -> Unit Tests -> Integration Tests -> Security Scan -> Deploy Staging -> E2E Tests -> Deploy ProductionRules:
- Fast stages first (lint, compile, unit tests)
- Expensive stages later (E2E, load tests)
- Every stage must be independently retriable
- Fail fast: stop the pipeline on first failure
Fan-Out / Fan-In
Run independent jobs in parallel, then converge for dependent stages.
+--> Unit Tests ----+
Build --> Lint -+--> Type Check ----+--> Integration Tests --> Deploy
+--> Security Scan -+This reduces total pipeline time by running independent checks concurrently.
Pipeline as Code
Define pipelines in version-controlled files alongside the application code.
- GitHub Actions:
.github/workflows/*.yml - GitLab CI:
.gitlab-ci.yml - Jenkins:
Jenkinsfile
Benefits: Review pipeline changes in PRs, audit history, branch-specific pipelines.
Environment Promotion
Build Artifact --> Dev --> Staging --> Production- Build the artifact ONCE
- Promote the same artifact through environments
- Never rebuild for production (eliminates "works on my machine" issues)
- Use environment-specific configuration, not environment-specific builds
---
Parallel Test Execution
Test Splitting Strategies
By file: Distribute test files evenly across runners. Simple but may produce uneven load.
By timing: Use historical test durations to split evenly. Produces the most balanced distribution.
By test suite: Run unit, integration, and E2E in parallel pipelines.
Parallelization Patterns
# GitHub Actions: Matrix strategy
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pytest --shard-id=${{ matrix.shard }} --num-shards=4Guidelines:
- Start with 4 shards, increase if pipeline > 10 minutes
- Monitor for flaky tests that appear only in parallel (shared state issues)
- Use separate databases per shard for integration tests
- Report aggregated coverage from all shards
Test Ordering
1. Lint and type check (seconds) - catches syntax and type errors immediately 2. Unit tests (seconds to minutes) - fast feedback on logic 3. Integration tests (minutes) - API contracts, database interactions 4. E2E tests (minutes to tens of minutes) - user workflows 5. Performance tests (minutes) - regression detection
---
Caching Strategies
Dependency Caching
Cache package manager downloads to avoid re-fetching on every build.
# GitHub Actions
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-Cache keys: Always include the lock file hash. Use restore-keys for partial matches.
Build Caching
- Docker layer caching: Order Dockerfile instructions from least to most frequently changing
- Compilation caching: Use ccache (C/C++), sccache (Rust), or Turborepo (JavaScript)
- Test result caching: Skip tests for unchanged modules
Cache Invalidation
- Lock file change = full dependency cache invalidation
- Source file change = incremental build cache invalidation
- CI config change = consider full cache invalidation
- Set maximum cache age (7-14 days) to prevent stale caches
What NOT to Cache
- Security scan databases (must be current)
- Secrets or credentials
- Build artifacts intended for deployment (build fresh for reproducibility)
- Test fixtures that must reflect current data
---
Artifact Management
Build Artifacts
- Tag artifacts with commit SHA and build number
- Store in a registry (Docker Hub, ECR, GCR, Artifactory)
- Retain production artifacts for at least 30 days (rollback window)
- Sign artifacts for integrity verification
Artifact Naming Convention
{project}-{version}-{commit_sha_short}-{timestamp}Example: my-app-1.3.0-a1b2c3d-20260318T1423
Artifact Lifecycle
| Stage | Retention | Example |
|---|---|---|
| Feature branch | 7 days | Auto-delete after branch merge |
| Staging | 14 days | Keep for debugging |
| Production | 90 days | Rollback window |
| Release tags | Permanent | Audit trail |
Container Image Best Practices
- Use multi-stage builds to minimize image size
- Pin base image versions (not
latest) - Scan images for vulnerabilities before pushing
- Use immutable tags (SHA-based) for production deployments
- Never run containers as root
---
Environment Promotion
Promotion Flow
Developer -> Feature Branch CI -> Dev Environment -> QA/Staging -> ProductionEnvironment Parity
Keep environments as similar as possible:
- Same OS, runtime versions, and configurations
- Same infrastructure (containers, orchestration)
- Same monitoring and logging
- Different only in: scale, data, secrets, external integrations
Promotion Gates
| Gate | Checks | Auto/Manual |
|---|---|---|
| Dev -> Staging | All tests pass, no critical vulnerabilities | Automatic |
| Staging -> Production | E2E tests pass, performance baseline met, approval | Manual approval |
| Production canary -> Full | Error rate < 0.1%, latency within baseline | Automatic with manual override |
Environment-Specific Configuration
Use environment variables or config services, never bake configuration into artifacts:
# Good: runtime configuration
DATABASE_URL=${ENV_DATABASE_URL}
# Bad: build-time configuration
DATABASE_URL=postgres://prod-server:5432/mydbSecrets Management
- Never store secrets in code, environment files, or CI configuration
- Use a secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager)
- Rotate secrets regularly
- Audit secret access
- Different secrets per environment (never share production secrets with staging)
Release Engineering Guide
Release Strategies
Rolling Deployment
Deploy new versions incrementally across server instances. At any point, both old and new versions serve traffic. No downtime.
When to use: Stateless services, horizontally scaled applications.
Process: 1. Deploy to 1 instance, monitor for errors 2. If healthy after 5 minutes, deploy to 25% of fleet 3. Monitor key metrics (error rate, latency p99, CPU) 4. Continue rolling at 25% increments 5. Full rollout after all cohorts are stable
Rollback: Stop the roll and redeploy the previous version to affected instances.
Blue-Green Deployment
Maintain two identical environments. Route all traffic from blue (current) to green (new) in a single switch.
When to use: Zero-downtime requirements, database schema compatibility between versions.
Process: 1. Deploy new version to the idle (green) environment 2. Run smoke tests against green 3. Switch load balancer / DNS to green 4. Monitor for 15-30 minutes 5. Decommission blue or keep as rollback target
Rollback: Switch traffic back to blue. Instant, sub-second recovery.
Canary Deployment
Route a small percentage of traffic to the new version. Compare metrics against the baseline before expanding.
When to use: High-risk changes, user-facing features, performance-sensitive paths.
Process: 1. Deploy canary alongside production 2. Route 1-5% of traffic to canary 3. Compare error rates, latencies, business metrics 4. If canary is healthy for 30+ minutes, expand to 25%, then 50%, then 100% 5. Kill canary infrastructure after full rollout
Metrics to compare: Error rate, p50/p95/p99 latency, CPU/memory, business KPIs.
Feature Flag Deployment
Deploy code to production with features behind flags. Enable features independently of deployment.
When to use: Long-lived feature branches, A/B testing, progressive rollouts, kill switches.
Process: 1. Merge feature behind a flag (default: OFF) 2. Deploy to production (no user impact) 3. Enable flag for internal users / beta testers 4. Gradually roll out: 1% -> 10% -> 50% -> 100% 5. Remove flag and dead code after full rollout
Rollback: Disable the flag. Instant, no deployment required.
---
Semantic Versioning Deep Dive
Semantic Versioning (SemVer) uses the format MAJOR.MINOR.PATCH:
| Component | When to Increment | Example |
|---|---|---|
| MAJOR | Incompatible API changes | Removing an endpoint, changing auth flow |
| MINOR | Backward-compatible new functionality | New endpoint, new optional parameter |
| PATCH | Backward-compatible bug fixes | Fix null pointer, correct calculation |
Pre-release Versions
Pre-release identifiers follow the patch: 1.2.0-alpha.1, 1.2.0-beta.3, 1.2.0-rc.1.
Precedence: 1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
Build Metadata
Build metadata follows +: 1.2.0+build.42. Build metadata does NOT affect version precedence.
Version Zero
0.x.y is for initial development. Anything may change at any time. The public API is not stable.
Rules
- Once a versioned package is released, that version MUST NOT be modified
- MAJOR version zero is for rapid iteration; treat every minor bump as potentially breaking
- Deprecate before removing: announce deprecation in MINOR, remove in next MAJOR
---
Conventional Commits Specification
Format: <type>[optional scope][!]: <description>
Types
| Type | Purpose | SemVer Impact |
|---|---|---|
feat | New feature | MINOR |
fix | Bug fix | PATCH |
docs | Documentation only | None |
style | Formatting, missing semicolons | None |
refactor | Code change that neither fixes a bug nor adds a feature | None |
perf | Performance improvement | PATCH |
test | Adding or correcting tests | None |
build | Build system or external dependencies | None |
ci | CI configuration | None |
chore | Other changes that don't modify src or test files | None |
revert | Reverts a previous commit | Depends on reverted commit |
Breaking Changes
Two ways to indicate breaking changes:
1. Exclamation mark: feat!: redesign authentication or feat(auth)!: require API keys 2. Footer: Include BREAKING CHANGE: description in the commit body
Both result in a MAJOR version bump.
Scope
Optional, in parentheses: feat(parser): add support for arrays. Use module names, component names, or feature areas.
---
Release Branch Management
Git Flow
main- production-ready code, tagged with versionsdevelop- integration branch for featuresfeature/*- individual feature branchesrelease/*- release preparationhotfix/*- production fixes
Trunk-Based Development
main- single source of truth, always deployable- Short-lived feature branches (< 2 days)
- Feature flags for incomplete work
- Release branches cut from main for stabilization
Release Branch Workflow
1. Cut release/1.3.0 from develop (or main in trunk-based) 2. Only bug fixes and release prep on the branch 3. No new features after cut 4. Bump version, generate changelog 5. Merge to main, tag, deploy 6. Back-merge to develop
---
Hotfix Workflows
Standard Hotfix
1. Branch from latest release tag: git checkout -b hotfix/1.2.1 v1.2.0 2. Apply the minimal fix (smallest possible change) 3. Bump PATCH version 4. Generate changelog entry 5. Run full test suite 6. Merge to main, tag v1.2.1 7. Back-merge to develop / current release branch
Emergency Hotfix
Same as standard but with abbreviated testing: 1. Run only affected test suites 2. Deploy to canary (1% traffic) for 15 minutes 3. If stable, roll to 100% 4. Post-deploy: run full regression suite 5. If regression found, revert and apply proper fix
Hotfix Rules
- Never include unrelated changes in a hotfix
- Always branch from the release tag, not from HEAD
- Hotfixes bypass feature freeze windows
- Document the incident that triggered the hotfix
Rollback Strategies
Database Migration Rollbacks
Reversible Migrations
Every up() migration must have a corresponding down() migration. Test both directions before release.
Safe operations (easily reversible):
- Add column (rollback: drop column)
- Add index (rollback: drop index)
- Add table (rollback: drop table)
- Add constraint with default (rollback: drop constraint)
Dangerous operations (require careful planning):
- Drop column - data loss, must backup first
- Rename column - both old and new code must work during transition
- Change column type - may lose precision or fail for existing data
- Drop table - irreversible without backup
Expand-Contract Pattern
For schema changes that cannot be reversed atomically:
1. Expand: Add the new column/table alongside the old one 2. Migrate: Backfill data from old to new 3. Transition: Application reads from new, writes to both 4. Contract: Remove the old column/table after verification
This allows rollback at any stage without data loss.
Migration Rollback Checklist
- [ ] Every migration has a tested
down()function - [ ] Data backups taken before destructive migrations
- [ ] Expand-contract pattern used for column renames/type changes
- [ ] Migration tested against production-sized dataset
- [ ] Rollback tested in staging environment
- [ ] Migration timeout configured (kill long-running migrations)
---
Feature Flag Rollbacks
Instant Disable
The simplest rollback: flip the flag to OFF. No deployment, no code change.
# Before rollback
new_checkout_flow: true
# After rollback
new_checkout_flow: falseGradual Rollback
If issues appear at high percentages, reduce gradually:
1. Reduce from 100% to 50% 2. Monitor for 10 minutes 3. Reduce to 10% 4. Monitor for 10 minutes 5. Disable completely 6. Investigate root cause
Flag Rollback Considerations
- Ensure flag evaluation is cached appropriately (avoid thundering herd on disable)
- Log flag state changes with timestamps for debugging
- Have a "kill all flags" emergency procedure
- Test the OFF path regularly (it may have rotted)
---
Git Revert Strategies
Single Commit Revert
git revert <commit-hash>
git push origin mainCreates a new commit that undoes the specified commit. Safe and traceable.
Range Revert
# Revert commits C, D, E (from oldest to newest)
git revert --no-commit C^..E
git commit -m "revert: undo commits C through E due to regression"Use --no-commit to combine multiple reverts into a single commit.
Merge Commit Revert
# -m 1 means keep the first parent (usually main)
git revert -m 1 <merge-commit-hash>Reverting a merge commit requires specifying which parent to keep.
Revert Best Practices
- Always revert in production branch first, then propagate
- Write descriptive revert messages explaining WHY
- After reverting, create a fix branch from the reverted code
- Never force-push to shared branches as a rollback mechanism
---
Infrastructure Rollback
Container/Kubernetes Rollback
# Kubernetes: roll back to previous revision
kubectl rollout undo deployment/my-app
# Roll back to specific revision
kubectl rollout undo deployment/my-app --to-revision=3
# Check rollout history
kubectl rollout history deployment/my-appServerless Rollback
# AWS Lambda: point alias to previous version
aws lambda update-alias \
--function-name my-function \
--name production \
--function-version 42
# Revert to previous traffic split
aws lambda update-alias \
--function-name my-function \
--name production \
--routing-config '{"AdditionalVersionWeights":{}}'Load Balancer Rollback
Switch target group to point at the old deployment:
1. Keep the old target group registered for 30 minutes post-deploy 2. If rollback needed, update listener rules to old target group 3. Drain connections gracefully (deregistration delay)
---
Communication Templates
Rollback Initiated
Subject: [ROLLBACK] v{version} rollback initiated
Team,
We are rolling back release v{version} due to:
- Issue: {brief description}
- Impact: {affected users/services}
- Detection: {how it was detected}
Current status:
- Rollback started at {time}
- Expected completion: {time}
- Incident commander: {name}
We will send an update when rollback is complete.Rollback Complete
Subject: [RESOLVED] v{version} rollback complete
Team,
Rollback of v{version} is complete.
Timeline:
- {time}: Issue detected
- {time}: Rollback initiated
- {time}: Rollback verified
- {time}: Services restored to v{previous_version}
Next steps:
- Root cause analysis in progress
- Fix branch: {branch name}
- Expected re-release: {date/time}
Post-incident review scheduled for {date/time}.Customer-Facing Communication
Subject: Service Update - {date}
We identified an issue with a recent update that affected {service}.
The issue has been resolved and all systems are operating normally.
What happened: {user-friendly description}
Impact: {what users experienced}
Duration: {start time} to {end time}
Resolution: We reverted the update and restored service.
We apologize for any inconvenience and are taking steps to prevent
similar issues in the future.#!/usr/bin/env python3
"""
Changelog Generator for Release Orchestration.
Parses git log for conventional commits, groups by type, detects breaking
changes, and generates Keep a Changelog formatted output.
Usage:
python changelog_generator.py --repo . --from v1.0.0 --to HEAD
python changelog_generator.py --repo . --from latest --to HEAD
python changelog_generator.py --repo . --since 2026-01-01 --until 2026-03-18
python changelog_generator.py --repo . --from v1.0.0 --to v1.1.0 --output CHANGELOG.md
python changelog_generator.py --repo . --from v1.0.0 --to HEAD --json
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Conventional commit parsing
# ---------------------------------------------------------------------------
CONVENTIONAL_COMMIT_RE = re.compile(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
r"(?:\((?P<scope>[^)]+)\))?"
r"(?P<breaking>!)?"
r":\s+"
r"(?P<description>.+)"
)
BREAKING_CHANGE_FOOTER_RE = re.compile(
r"^BREAKING[ -]CHANGE:\s*(?P<description>.+)", re.MULTILINE
)
# Map conventional commit types to Keep a Changelog sections
TYPE_TO_SECTION: Dict[str, str] = {
"feat": "Added",
"fix": "Fixed",
"docs": "Documentation",
"style": "Changed",
"refactor": "Changed",
"perf": "Performance",
"test": "Testing",
"build": "Build",
"ci": "CI/CD",
"chore": "Maintenance",
"revert": "Reverted",
}
# Primary sections in display order
SECTION_ORDER = [
"Breaking Changes",
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
"Performance",
"Documentation",
"Testing",
"Build",
"CI/CD",
"Maintenance",
"Reverted",
"Other",
]
@dataclass
class CommitEntry:
"""Parsed conventional commit."""
hash: str
short_hash: str
type: str
scope: Optional[str]
description: str
body: str
author: str
author_email: str
date: str
is_breaking: bool
breaking_description: Optional[str]
raw_subject: str
@property
def section(self) -> str:
return TYPE_TO_SECTION.get(self.type, "Other")
@property
def scope_prefix(self) -> str:
if self.scope:
return f"**{self.scope}:** "
return ""
def to_dict(self) -> Dict:
return {
"hash": self.hash,
"short_hash": self.short_hash,
"type": self.type,
"scope": self.scope,
"description": self.description,
"author": self.author,
"date": self.date,
"is_breaking": self.is_breaking,
"breaking_description": self.breaking_description,
}
@dataclass
class ChangelogRelease:
"""A single release entry in the changelog."""
version: Optional[str]
date: str
entries: List[CommitEntry] = field(default_factory=list)
sections: Dict[str, List[CommitEntry]] = field(default_factory=lambda: defaultdict(list))
breaking_changes: List[CommitEntry] = field(default_factory=list)
def add_entry(self, entry: CommitEntry) -> None:
self.entries.append(entry)
self.sections[entry.section].append(entry)
if entry.is_breaking:
self.breaking_changes.append(entry)
@property
def contributors(self) -> List[str]:
seen = set()
result = []
for e in self.entries:
if e.author not in seen:
seen.add(e.author)
result.append(e.author)
return result
def to_dict(self) -> Dict:
return {
"version": self.version,
"date": self.date,
"total_commits": len(self.entries),
"breaking_changes": len(self.breaking_changes),
"contributors": self.contributors,
"sections": {
section: [e.to_dict() for e in entries]
for section, entries in self.sections.items()
},
}
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: List[str], cwd: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git"] + args, cwd=cwd, capture_output=True, text=True
)
def get_latest_tag(repo: str) -> Optional[str]:
"""Return the most recent tag or None."""
result = run_git(["describe", "--tags", "--abbrev=0"], cwd=repo)
if result.returncode == 0:
return result.stdout.strip()
return None
def get_all_tags(repo: str) -> List[str]:
"""Return list of tags sorted by date (newest first)."""
result = run_git(
["tag", "--sort=-creatordate", "--format=%(refname:short)"],
cwd=repo,
)
if result.returncode != 0:
return []
return [t for t in result.stdout.strip().split("\n") if t]
def resolve_ref(repo: str, ref: str) -> Optional[str]:
"""Resolve a ref to its full hash."""
if ref == "latest":
tag = get_latest_tag(repo)
if tag is None:
return None
ref = tag
result = run_git(["rev-parse", ref], cwd=repo)
if result.returncode == 0:
return result.stdout.strip()
return None
def get_version_from_tag(tag: Optional[str]) -> Optional[str]:
"""Extract version number from a tag like v1.2.3."""
if tag is None:
return None
if tag.startswith("v"):
return tag[1:]
return tag
def get_commits_between(
repo: str,
from_ref: Optional[str],
to_ref: str,
since: Optional[str] = None,
until: Optional[str] = None,
) -> List[CommitEntry]:
"""Get commits between two refs or within a date range."""
# Build git log command
# Format: hash|short_hash|author|email|date|subject
separator = "---COMMIT_END---"
log_format = f"%H|%h|%an|%ae|%Y-%m-%d|%s%n%b{separator}"
args = ["log", f"--pretty=format:{log_format}"]
if since:
args.append(f"--since={since}")
if until:
args.append(f"--until={until}")
if from_ref and to_ref:
from_resolved = from_ref
if from_ref == "latest":
tag = get_latest_tag(repo)
if tag:
from_resolved = tag
else:
# No tags, get all commits
from_resolved = None
if from_resolved:
args.append(f"{from_resolved}..{to_ref}")
else:
args.append(to_ref)
elif to_ref:
args.append(to_ref)
args.append("--no-merges")
result = run_git(args, cwd=repo)
if result.returncode != 0:
return []
entries: List[CommitEntry] = []
raw_commits = result.stdout.split(separator)
for raw in raw_commits:
raw = raw.strip()
if not raw:
continue
lines = raw.split("\n")
if not lines:
continue
header = lines[0]
body = "\n".join(lines[1:]).strip()
parts = header.split("|", 5)
if len(parts) < 6:
continue
full_hash, short_hash, author, email, date, subject = parts
# Parse conventional commit
match = CONVENTIONAL_COMMIT_RE.match(subject)
if match:
commit_type = match.group("type")
scope = match.group("scope")
is_breaking = match.group("breaking") is not None
description = match.group("description")
else:
commit_type = "other"
scope = None
is_breaking = False
description = subject
# Check for BREAKING CHANGE in body
breaking_desc = None
if body:
bc_match = BREAKING_CHANGE_FOOTER_RE.search(body)
if bc_match:
is_breaking = True
breaking_desc = bc_match.group("description")
if is_breaking and not breaking_desc:
breaking_desc = description
entries.append(CommitEntry(
hash=full_hash,
short_hash=short_hash,
type=commit_type,
scope=scope,
description=description,
body=body,
author=author,
author_email=email,
date=date,
is_breaking=is_breaking,
breaking_description=breaking_desc,
raw_subject=subject,
))
return entries
# ---------------------------------------------------------------------------
# Changelog rendering
# ---------------------------------------------------------------------------
def render_markdown(release: ChangelogRelease) -> str:
"""Render a release as Keep a Changelog markdown."""
lines: List[str] = []
# Header
version_str = release.version or "Unreleased"
lines.append(f"## [{version_str}] - {release.date}")
lines.append("")
# Breaking changes first (always)
if release.breaking_changes:
lines.append("### Breaking Changes")
lines.append("")
for entry in release.breaking_changes:
desc = entry.breaking_description or entry.description
lines.append(f"- {entry.scope_prefix}{desc} ({entry.short_hash}) - @{entry.author}")
lines.append("")
# Remaining sections in order
for section in SECTION_ORDER:
if section == "Breaking Changes":
continue # Already rendered
if section not in release.sections:
continue
section_entries = release.sections[section]
if not section_entries:
continue
lines.append(f"### {section}")
lines.append("")
for entry in section_entries:
lines.append(f"- {entry.scope_prefix}{entry.description} ({entry.short_hash}) - @{entry.author}")
lines.append("")
# Contributors
if release.contributors:
lines.append("### Contributors")
lines.append("")
for contrib in release.contributors:
lines.append(f"- @{contrib}")
lines.append("")
# Stats
lines.append(f"**Total commits:** {len(release.entries)} | "
f"**Breaking changes:** {len(release.breaking_changes)} | "
f"**Contributors:** {len(release.contributors)}")
lines.append("")
return "\n".join(lines)
def render_full_changelog(releases: List[ChangelogRelease], repo_name: str = "") -> str:
"""Render a full changelog document."""
lines: List[str] = []
lines.append("# Changelog")
lines.append("")
lines.append("All notable changes to this project will be documented in this file.")
lines.append("")
lines.append("The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),")
lines.append("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).")
lines.append("")
for release in releases:
lines.append(render_markdown(release))
return "\n".join(lines)
def render_text_summary(release: ChangelogRelease) -> str:
"""Render a compact text summary."""
lines: List[str] = []
lines.append("=" * 60)
lines.append(" CHANGELOG")
lines.append("=" * 60)
version_str = release.version or "Unreleased"
lines.append(f" Version: {version_str}")
lines.append(f" Date: {release.date}")
lines.append(f" Commits: {len(release.entries)}")
lines.append(f" Breaking Changes: {len(release.breaking_changes)}")
lines.append(f" Contributors: {', '.join(release.contributors)}")
lines.append("-" * 60)
for section in SECTION_ORDER:
if section not in release.sections or not release.sections[section]:
continue
lines.append(f"\n {section}:")
for entry in release.sections[section]:
lines.append(f" - {entry.scope_prefix}{entry.description} ({entry.short_hash})")
if release.breaking_changes:
lines.append(f"\n Breaking Changes:")
for entry in release.breaking_changes:
desc = entry.breaking_description or entry.description
lines.append(f" - {entry.scope_prefix}{desc}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Detect version for upcoming release
# ---------------------------------------------------------------------------
def detect_next_version(entries: List[CommitEntry], current_version: Optional[str]) -> Optional[str]:
"""Detect the next version based on commit types."""
if current_version is None:
return None
parts = current_version.split(".")
if len(parts) < 3:
return None
try:
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2].split("-")[0])
except ValueError:
return None
has_breaking = any(e.is_breaking for e in entries)
has_feat = any(e.type == "feat" for e in entries)
has_fix = any(e.type == "fix" for e in entries)
if has_breaking:
return f"{major + 1}.0.0"
elif has_feat:
return f"{major}.{minor + 1}.0"
elif has_fix:
return f"{major}.{minor}.{patch + 1}"
else:
return f"{major}.{minor}.{patch + 1}"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate changelog from conventional commits",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --repo . --from v1.0.0 --to HEAD
%(prog)s --repo . --from latest --to HEAD
%(prog)s --repo . --since 2026-01-01 --until 2026-03-18
%(prog)s --repo . --from v1.0.0 --to v1.1.0 --output CHANGELOG.md
""",
)
parser.add_argument("--repo", default=".", help="Path to git repository")
parser.add_argument("--from", dest="from_ref", help="Start ref (tag, commit, or 'latest')")
parser.add_argument("--to", dest="to_ref", default="HEAD", help="End ref (default: HEAD)")
parser.add_argument("--since", help="Start date (YYYY-MM-DD)")
parser.add_argument("--until", help="End date (YYYY-MM-DD)")
parser.add_argument("--output", "-o", help="Write changelog to file")
parser.add_argument("--version", help="Override version label for this release")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
parser.add_argument("--format", choices=["markdown", "text"], default="markdown", dest="fmt", help="Output format")
parser.add_argument("--full", action="store_true", help="Generate full changelog document with header")
args = parser.parse_args()
repo = os.path.abspath(args.repo)
if not os.path.isdir(repo):
print(f"Error: {repo} is not a directory", file=sys.stderr)
sys.exit(2)
# Get commits
entries = get_commits_between(
repo,
from_ref=args.from_ref,
to_ref=args.to_ref,
since=args.since,
until=args.until,
)
if not entries:
print("No commits found in the specified range.", file=sys.stderr)
sys.exit(1)
# Determine version
version = args.version
if version is None and args.from_ref:
from_tag = args.from_ref
if from_tag == "latest":
from_tag = get_latest_tag(repo)
if from_tag:
current_ver = get_version_from_tag(from_tag)
version = detect_next_version(entries, current_ver)
# Build release
release = ChangelogRelease(
version=version,
date=datetime.now().strftime("%Y-%m-%d"),
)
for entry in entries:
release.add_entry(entry)
# Output
if args.json_output:
output = json.dumps(release.to_dict(), indent=2)
elif args.fmt == "text":
output = render_text_summary(release)
else:
if args.full:
output = render_full_changelog([release])
else:
output = render_markdown(release)
if args.output:
output_path = args.output
if not os.path.isabs(output_path):
output_path = os.path.join(repo, output_path)
# If file exists and we're appending, insert after header
if os.path.isfile(output_path) and not args.full:
with open(output_path, "r") as f:
existing = f.read()
# Insert new release after the header lines
header_end = "adheres to [Semantic Versioning]"
if header_end in existing:
idx = existing.index(header_end)
idx = existing.index("\n", idx) + 1
# Skip blank lines
while idx < len(existing) and existing[idx] == "\n":
idx += 1
new_content = existing[:idx] + "\n" + render_markdown(release) + "\n" + existing[idx:]
else:
new_content = existing + "\n" + render_markdown(release)
with open(output_path, "w") as f:
f.write(new_content)
print(f"Updated {output_path}")
else:
content = render_full_changelog([release]) if not args.full else output
with open(output_path, "w") as f:
f.write(content)
print(f"Written to {output_path}")
else:
print(output)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Pre-Flight Checker for Release Orchestration.
Validates repository state before release:
- Branch sync with remote base
- Merge conflict detection (dry-run)
- Secret scanning across staged/committed files
- Gitignore validation for sensitive files
- Uncommitted changes detection
- Conventional commit format validation
- Dependency lock file consistency
Usage:
python preflight_checker.py --repo /path/to/repo --base main
python preflight_checker.py --repo . --base main --verbose
python preflight_checker.py --repo . --base main --json
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Secret patterns
# ---------------------------------------------------------------------------
SECRET_PATTERNS: List[Tuple[str, str]] = [
# AWS
(r"AKIA[0-9A-Z]{16}", "AWS Access Key ID"),
(r"(?i)aws[_\-]?secret[_\-]?access[_\-]?key\s*[=:]\s*['\"]?[A-Za-z0-9/+=]{40}", "AWS Secret Access Key"),
# GitHub
(r"ghp_[A-Za-z0-9_]{36}", "GitHub Personal Access Token"),
(r"gho_[A-Za-z0-9_]{36}", "GitHub OAuth Token"),
(r"ghs_[A-Za-z0-9_]{36}", "GitHub Server Token"),
(r"github_pat_[A-Za-z0-9_]{22,}", "GitHub Fine-Grained PAT"),
# Generic tokens / keys
(r"(?i)(api[_\-]?key|apikey)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{20,}", "Generic API Key"),
(r"(?i)(secret|token|password|passwd|pwd)\s*[=:]\s*['\"]?[^\s'\"]{8,}", "Generic Secret/Token"),
# Stripe
(r"sk_live_[A-Za-z0-9]{24,}", "Stripe Live Secret Key"),
(r"rk_live_[A-Za-z0-9]{24,}", "Stripe Restricted Key"),
# Slack
(r"xoxb-[0-9A-Za-z\-]{50,}", "Slack Bot Token"),
(r"xoxp-[0-9A-Za-z\-]{50,}", "Slack User Token"),
(r"xoxs-[0-9A-Za-z\-]{50,}", "Slack Session Token"),
# JWT
(r"eyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}", "JSON Web Token"),
# Private keys
(r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", "Private Key"),
# GCP
(r"(?i)\"type\"\s*:\s*\"service_account\"", "GCP Service Account JSON"),
# Connection strings
(r"(?i)(mongodb|postgres|mysql|redis|amqp)://[^\s'\"]{10,}", "Database Connection String"),
# Heroku
(r"(?i)heroku[_\-]?api[_\-]?key\s*[=:]\s*['\"]?[A-Fa-f0-9\-]{36}", "Heroku API Key"),
# Twilio
(r"SK[0-9a-fA-F]{32}", "Twilio API Key"),
# SendGrid
(r"SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}", "SendGrid API Key"),
# npm
(r"npm_[A-Za-z0-9]{36}", "npm Access Token"),
]
COMPILED_SECRET_PATTERNS = [(re.compile(p), name) for p, name in SECRET_PATTERNS]
# Conventional commit pattern
CONVENTIONAL_COMMIT_RE = re.compile(
r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
r"(\(.+\))?!?:\s.+"
)
# Sensitive files that should be in .gitignore
SENSITIVE_FILES = [
".env",
".env.local",
".env.production",
".env.staging",
".env.development",
"credentials.json",
"service-account.json",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"id_rsa",
"id_ed25519",
".npmrc",
".pypirc",
]
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class CheckResult:
"""Result of a single pre-flight check."""
name: str
passed: bool
message: str
details: List[str] = field(default_factory=list)
severity: str = "error" # error, warning, info
@dataclass
class PreFlightReport:
"""Aggregate pre-flight report."""
repo_path: str
base_branch: str
timestamp: str
checks: List[CheckResult] = field(default_factory=list)
@property
def all_passed(self) -> bool:
return all(c.passed for c in self.checks if c.severity == "error")
@property
def passed_count(self) -> int:
return sum(1 for c in self.checks if c.passed)
@property
def failed_count(self) -> int:
return sum(1 for c in self.checks if not c.passed and c.severity == "error")
@property
def warning_count(self) -> int:
return sum(1 for c in self.checks if not c.passed and c.severity == "warning")
def to_dict(self) -> Dict:
return {
"repo_path": self.repo_path,
"base_branch": self.base_branch,
"timestamp": self.timestamp,
"all_passed": self.all_passed,
"passed": self.passed_count,
"failed": self.failed_count,
"warnings": self.warning_count,
"checks": [
{
"name": c.name,
"passed": c.passed,
"message": c.message,
"severity": c.severity,
"details": c.details,
}
for c in self.checks
],
}
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: List[str], cwd: str, check: bool = False) -> subprocess.CompletedProcess:
"""Run a git command and return the result."""
cmd = ["git"] + args
try:
return subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, check=check, timeout=30
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr="Command timed out")
def get_current_branch(repo: str) -> Optional[str]:
"""Return the current branch name or None if detached."""
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo)
if result.returncode != 0:
return None
branch = result.stdout.strip()
return None if branch == "HEAD" else branch
def get_tracked_files(repo: str) -> List[str]:
"""Return list of tracked files in the repo."""
result = run_git(["ls-files"], cwd=repo)
if result.returncode != 0:
return []
return [f for f in result.stdout.strip().split("\n") if f]
def get_recent_commits(repo: str, count: int = 20) -> List[Tuple[str, str]]:
"""Return recent (hash, subject) tuples."""
result = run_git(
["log", f"-{count}", "--pretty=format:%h|%s"],
cwd=repo,
)
if result.returncode != 0:
return []
commits = []
for line in result.stdout.strip().split("\n"):
if "|" in line:
hash_, subject = line.split("|", 1)
commits.append((hash_.strip(), subject.strip()))
return commits
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
def check_branch_sync(repo: str, base: str) -> CheckResult:
"""Check if the current branch is up to date with the remote base."""
# Fetch latest
run_git(["fetch", "origin", base], cwd=repo)
current = get_current_branch(repo)
if current is None:
return CheckResult(
name="Branch Sync",
passed=False,
message="HEAD is detached; cannot determine branch sync status",
severity="error",
)
# Count commits behind and ahead
result = run_git(
["rev-list", "--left-right", "--count", f"origin/{base}...HEAD"],
cwd=repo,
)
if result.returncode != 0:
return CheckResult(
name="Branch Sync",
passed=False,
message=f"Could not compare with origin/{base}: {result.stderr.strip()}",
severity="error",
)
parts = result.stdout.strip().split()
behind = int(parts[0]) if len(parts) >= 1 else 0
ahead = int(parts[1]) if len(parts) >= 2 else 0
details = [f"Current branch: {current}", f"Behind origin/{base}: {behind}", f"Ahead of origin/{base}: {ahead}"]
if behind > 0:
return CheckResult(
name="Branch Sync",
passed=False,
message=f"Branch is {behind} commit(s) behind origin/{base}. Pull or rebase before release.",
details=details,
severity="error",
)
return CheckResult(
name="Branch Sync",
passed=True,
message=f"Branch is up to date with origin/{base} ({ahead} commit(s) ahead)",
details=details,
)
def check_merge_conflicts(repo: str, base: str) -> CheckResult:
"""Dry-run merge to detect conflicts."""
# Get the merge base
result = run_git(["merge-base", "HEAD", f"origin/{base}"], cwd=repo)
if result.returncode != 0:
return CheckResult(
name="Merge Conflicts",
passed=False,
message=f"Could not find merge base with origin/{base}",
severity="error",
)
# Try merge with --no-commit --no-ff
result = run_git(
["merge", "--no-commit", "--no-ff", f"origin/{base}"],
cwd=repo,
)
# Abort the merge regardless of outcome
run_git(["merge", "--abort"], cwd=repo)
if result.returncode != 0:
conflict_files = []
for line in result.stdout.split("\n"):
if "CONFLICT" in line:
conflict_files.append(line.strip())
return CheckResult(
name="Merge Conflicts",
passed=False,
message=f"Merge conflicts detected with origin/{base}",
details=conflict_files or [result.stderr.strip()],
severity="error",
)
return CheckResult(
name="Merge Conflicts",
passed=True,
message=f"No merge conflicts with origin/{base}",
)
def check_uncommitted_changes(repo: str) -> CheckResult:
"""Check for uncommitted staged or unstaged changes."""
result = run_git(["status", "--porcelain"], cwd=repo)
if result.returncode != 0:
return CheckResult(
name="Uncommitted Changes",
passed=False,
message="Could not determine working tree status",
severity="error",
)
changes = [line for line in result.stdout.strip().split("\n") if line.strip()]
if changes:
staged = [l for l in changes if l[0] in "MADRC"]
unstaged = [l for l in changes if len(l) > 1 and l[1] in "MADRC"]
untracked = [l for l in changes if l.startswith("??")]
details = []
if staged:
details.append(f"Staged changes: {len(staged)}")
if unstaged:
details.append(f"Unstaged changes: {len(unstaged)}")
if untracked:
details.append(f"Untracked files: {len(untracked)}")
details.extend(changes[:10])
if len(changes) > 10:
details.append(f"... and {len(changes) - 10} more")
return CheckResult(
name="Uncommitted Changes",
passed=False,
message=f"Working tree has {len(changes)} uncommitted change(s)",
details=details,
severity="error",
)
return CheckResult(
name="Uncommitted Changes",
passed=True,
message="Working tree is clean",
)
def check_secrets(repo: str) -> CheckResult:
"""Scan tracked files for secrets."""
tracked = get_tracked_files(repo)
findings: List[str] = []
# Only scan text-like files
text_extensions = {
".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
".rb", ".php", ".sh", ".bash", ".zsh", ".yml", ".yaml",
".json", ".toml", ".ini", ".cfg", ".conf", ".env", ".md",
".txt", ".xml", ".html", ".css", ".scss", ".sql", ".tf",
".hcl", ".dockerfile", "", ".gitignore", ".env.example",
}
for filepath in tracked:
ext = Path(filepath).suffix.lower()
name = Path(filepath).name.lower()
# Skip binary-like and large files
if ext not in text_extensions and name not in {".env", ".env.example", "dockerfile", "makefile"}:
continue
full_path = os.path.join(repo, filepath)
if not os.path.isfile(full_path):
continue
try:
with open(full_path, "r", errors="ignore") as f:
for line_no, line in enumerate(f, 1):
# Skip comments that are clearly examples/docs
stripped = line.strip()
if stripped.startswith("#") and ("example" in stripped.lower() or "placeholder" in stripped.lower()):
continue
for pattern, secret_name in COMPILED_SECRET_PATTERNS:
if pattern.search(line):
finding = f"{filepath}:{line_no} - {secret_name}"
findings.append(finding)
break # One finding per line is enough
except (OSError, UnicodeDecodeError):
continue
if findings:
return CheckResult(
name="Secret Scanning",
passed=False,
message=f"Found {len(findings)} potential secret(s) in tracked files",
details=findings[:20] + ([f"... and {len(findings) - 20} more"] if len(findings) > 20 else []),
severity="error",
)
return CheckResult(
name="Secret Scanning",
passed=True,
message="No secrets detected in tracked files",
)
def check_gitignore(repo: str) -> CheckResult:
"""Validate that .gitignore covers sensitive file patterns."""
gitignore_path = os.path.join(repo, ".gitignore")
missing: List[str] = []
if not os.path.isfile(gitignore_path):
return CheckResult(
name="Gitignore Validation",
passed=False,
message="No .gitignore file found",
details=["Create a .gitignore file to protect sensitive files"],
severity="warning",
)
with open(gitignore_path, "r") as f:
gitignore_content = f.read()
gitignore_lines = set()
for line in gitignore_content.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
gitignore_lines.add(line)
for sensitive in SENSITIVE_FILES:
# Check if the pattern or a broader version is covered
covered = False
for gi_line in gitignore_lines:
if sensitive == gi_line:
covered = True
break
# Check if a wildcard covers it: e.g., *.pem covers id_rsa.pem
if gi_line.startswith("*") and sensitive.endswith(gi_line[1:]):
covered = True
break
# Check if .env* covers .env.local etc.
if gi_line.endswith("*") and sensitive.startswith(gi_line[:-1]):
covered = True
break
# Check broader patterns like .env*
if sensitive.startswith(".env") and ".env" in gi_line:
covered = True
break
if not covered:
# Only flag if the file actually exists
full = os.path.join(repo, sensitive.replace("*", ""))
if "*" in sensitive:
# For glob patterns, just flag as advisory
missing.append(f"{sensitive} (recommended)")
elif os.path.exists(full):
missing.append(f"{sensitive} (exists in repo!)")
else:
missing.append(f"{sensitive} (not present, but recommended)")
if any("exists in repo" in m for m in missing):
return CheckResult(
name="Gitignore Validation",
passed=False,
message="Sensitive files exist in repo without gitignore coverage",
details=missing,
severity="error",
)
if missing:
return CheckResult(
name="Gitignore Validation",
passed=True,
message="Gitignore exists but could cover more patterns",
details=missing,
severity="warning",
)
return CheckResult(
name="Gitignore Validation",
passed=True,
message=".gitignore covers all recommended sensitive file patterns",
)
def check_conventional_commits(repo: str, count: int = 20) -> CheckResult:
"""Validate that recent commits follow conventional commit format."""
commits = get_recent_commits(repo, count)
if not commits:
return CheckResult(
name="Conventional Commits",
passed=True,
message="No commits to validate",
severity="info",
)
non_conforming: List[str] = []
merge_skipped = 0
for hash_, subject in commits:
# Skip merge commits
if subject.startswith("Merge "):
merge_skipped += 1
continue
if not CONVENTIONAL_COMMIT_RE.match(subject):
non_conforming.append(f"{hash_} {subject}")
total_checked = len(commits) - merge_skipped
conforming = total_checked - len(non_conforming)
if non_conforming:
pct = (conforming / total_checked * 100) if total_checked > 0 else 0
severity = "error" if pct < 50 else "warning"
return CheckResult(
name="Conventional Commits",
passed=False,
message=f"{len(non_conforming)}/{total_checked} recent commits do not follow conventional format ({pct:.0f}% compliant)",
details=non_conforming[:10],
severity=severity,
)
return CheckResult(
name="Conventional Commits",
passed=True,
message=f"All {total_checked} recent commits follow conventional commit format",
)
def check_dependency_locks(repo: str) -> CheckResult:
"""Check for dependency lock file consistency."""
issues: List[str] = []
found_any = False
# Node.js
pkg_json = os.path.join(repo, "package.json")
pkg_lock = os.path.join(repo, "package-lock.json")
yarn_lock = os.path.join(repo, "yarn.lock")
pnpm_lock = os.path.join(repo, "pnpm-lock.yaml")
if os.path.isfile(pkg_json):
found_any = True
has_lock = any(os.path.isfile(f) for f in [pkg_lock, yarn_lock, pnpm_lock])
if not has_lock:
issues.append("package.json exists but no lock file found (package-lock.json, yarn.lock, or pnpm-lock.yaml)")
# Python - pyproject.toml
pyproject = os.path.join(repo, "pyproject.toml")
poetry_lock = os.path.join(repo, "poetry.lock")
if os.path.isfile(pyproject):
found_any = True
# Check if it uses poetry
try:
with open(pyproject, "r") as f:
content = f.read()
if "[tool.poetry]" in content and not os.path.isfile(poetry_lock):
issues.append("pyproject.toml uses Poetry but poetry.lock is missing")
except OSError:
pass
# Python - requirements.txt
req_txt = os.path.join(repo, "requirements.txt")
if os.path.isfile(req_txt):
found_any = True
try:
with open(req_txt, "r") as f:
for line_no, line in enumerate(f, 1):
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
# Check if version is pinned
if "==" not in line and ">=" not in line and "<=" not in line and "~=" not in line:
if "@" not in line: # Skip URL-based requirements
issues.append(f"requirements.txt:{line_no} - unpinned dependency: {line}")
except OSError:
pass
# Rust
cargo_toml = os.path.join(repo, "Cargo.toml")
cargo_lock = os.path.join(repo, "Cargo.lock")
if os.path.isfile(cargo_toml):
found_any = True
if not os.path.isfile(cargo_lock):
issues.append("Cargo.toml exists but Cargo.lock is missing (required for binaries)")
# Go
go_mod = os.path.join(repo, "go.mod")
go_sum = os.path.join(repo, "go.sum")
if os.path.isfile(go_mod):
found_any = True
if not os.path.isfile(go_sum):
issues.append("go.mod exists but go.sum is missing")
if not found_any:
return CheckResult(
name="Dependency Locks",
passed=True,
message="No dependency manifests detected",
severity="info",
)
if issues:
return CheckResult(
name="Dependency Locks",
passed=False,
message=f"Found {len(issues)} dependency lock issue(s)",
details=issues[:15],
severity="warning",
)
return CheckResult(
name="Dependency Locks",
passed=True,
message="All dependency lock files are present and consistent",
)
# ---------------------------------------------------------------------------
# Report rendering
# ---------------------------------------------------------------------------
def render_text_report(report: PreFlightReport, verbose: bool = False) -> str:
"""Render the report as a human-readable text."""
lines: List[str] = []
lines.append("=" * 60)
lines.append(" RELEASE PRE-FLIGHT CHECK")
lines.append("=" * 60)
lines.append(f" Repository: {report.repo_path}")
lines.append(f" Base Branch: {report.base_branch}")
lines.append(f" Timestamp: {report.timestamp}")
lines.append("-" * 60)
lines.append("")
for check in report.checks:
icon = "PASS" if check.passed else ("WARN" if check.severity == "warning" else "FAIL")
marker = f"[{icon}]"
lines.append(f" {marker:8s} {check.name}")
lines.append(f" {check.message}")
if verbose and check.details:
for detail in check.details:
lines.append(f" - {detail}")
lines.append("")
lines.append("-" * 60)
status = "ALL CHECKS PASSED" if report.all_passed else "CHECKS FAILED"
lines.append(f" Result: {status}")
lines.append(f" Passed: {report.passed_count} Failed: {report.failed_count} Warnings: {report.warning_count}")
lines.append("=" * 60)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_preflight(repo: str, base: str) -> PreFlightReport:
"""Run all pre-flight checks and return a report."""
report = PreFlightReport(
repo_path=os.path.abspath(repo),
base_branch=base,
timestamp=datetime.now().isoformat(),
)
# Verify this is a git repo
result = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo)
if result.returncode != 0:
report.checks.append(CheckResult(
name="Git Repository",
passed=False,
message=f"{repo} is not a git repository",
severity="error",
))
return report
report.checks.append(CheckResult(
name="Git Repository",
passed=True,
message="Valid git repository",
))
report.checks.append(check_uncommitted_changes(repo))
report.checks.append(check_branch_sync(repo, base))
report.checks.append(check_merge_conflicts(repo, base))
report.checks.append(check_secrets(repo))
report.checks.append(check_gitignore(repo))
report.checks.append(check_conventional_commits(repo))
report.checks.append(check_dependency_locks(repo))
return report
def main() -> None:
parser = argparse.ArgumentParser(
description="Pre-flight checker for release orchestration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --repo . --base main
%(prog)s --repo /path/to/repo --base develop --verbose
%(prog)s --repo . --base main --json
""",
)
parser.add_argument(
"--repo",
default=".",
help="Path to the git repository (default: current directory)",
)
parser.add_argument(
"--base",
default="main",
help="Base branch to check against (default: main)",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed output for each check",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--commits",
type=int,
default=20,
help="Number of recent commits to validate (default: 20)",
)
args = parser.parse_args()
repo = os.path.abspath(args.repo)
if not os.path.isdir(repo):
print(f"Error: {repo} is not a directory", file=sys.stderr)
sys.exit(2)
# Verify git is available
try:
subprocess.run(["git", "--version"], capture_output=True, timeout=5)
except FileNotFoundError:
print("Error: git is not installed or not in PATH", file=sys.stderr)
sys.exit(2)
except subprocess.TimeoutExpired:
print("Error: git command timed out", file=sys.stderr)
sys.exit(2)
if not os.path.isdir(os.path.join(repo, ".git")):
print(f"Error: {repo} is not a git repository", file=sys.stderr)
sys.exit(2)
report = run_preflight(repo, args.base)
if args.json_output:
print(json.dumps(report.to_dict(), indent=2))
else:
print(render_text_report(report, verbose=args.verbose))
sys.exit(0 if report.all_passed else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Release Readiness Scorer for Release Orchestration.
Computes a weighted readiness score (0-100) from release checklist data
with 7 categories. Provides detailed breakdown, recommendations, and
trend tracking across releases.
Gate Logic:
80-100 = GO (proceed with deployment)
60-79 = CONDITIONAL (proceed with documented mitigations)
0-59 = NO-GO (address blockers before release)
Usage:
python release_readiness_scorer.py --input release_data.json
python release_readiness_scorer.py --input release_data.json --history history.json
python release_readiness_scorer.py --input release_data.json --json
python release_readiness_scorer.py --input release_data.json --format summary
"""
import argparse
import json
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Scoring configuration
# ---------------------------------------------------------------------------
CATEGORY_WEIGHTS: Dict[str, float] = {
"tests": 0.25,
"code_quality": 0.20,
"documentation": 0.15,
"security": 0.15,
"breaking_changes": 0.10,
"dependencies": 0.10,
"rollback_plan": 0.05,
}
CATEGORY_DISPLAY_NAMES: Dict[str, str] = {
"tests": "Tests",
"code_quality": "Code Quality",
"documentation": "Documentation",
"security": "Security",
"breaking_changes": "Breaking Changes",
"dependencies": "Dependencies",
"rollback_plan": "Rollback Plan",
}
DECISION_THRESHOLDS = {
"go": 80,
"conditional": 60,
}
CATEGORY_BLOCKER_THRESHOLD = 40
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class CategoryScore:
"""Score for a single category."""
name: str
display_name: str
score: float # 0-100
weight: float
weighted_score: float
checks: Dict[str, Any] = field(default_factory=dict)
recommendations: List[str] = field(default_factory=list)
is_blocker: bool = False
def to_dict(self) -> Dict:
return {
"name": self.name,
"display_name": self.display_name,
"score": round(self.score, 1),
"weight": self.weight,
"weighted_score": round(self.weighted_score, 1),
"checks": self.checks,
"recommendations": self.recommendations,
"is_blocker": self.is_blocker,
}
@dataclass
class ReadinessReport:
"""Complete readiness assessment."""
version: Optional[str]
timestamp: str
overall_score: float
decision: str # GO, CONDITIONAL, NO-GO
categories: List[CategoryScore]
blockers: List[str]
recommendations: List[str]
has_category_blocker: bool
def to_dict(self) -> Dict:
return {
"version": self.version,
"timestamp": self.timestamp,
"overall_score": round(self.overall_score, 1),
"decision": self.decision,
"has_category_blocker": self.has_category_blocker,
"blockers": self.blockers,
"recommendations": self.recommendations,
"categories": [c.to_dict() for c in self.categories],
}
@dataclass
class TrendEntry:
"""Historical release score entry."""
version: str
date: str
score: float
decision: str
# ---------------------------------------------------------------------------
# Scoring functions per category
# ---------------------------------------------------------------------------
def score_tests(data: Dict) -> CategoryScore:
"""Score the Tests category (25% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
# Test pass rate
total_tests = data.get("total_tests", 0)
passed_tests = data.get("passed_tests", 0)
if total_tests > 0:
pass_rate = (passed_tests / total_tests) * 100
checks["pass_rate"] = f"{pass_rate:.1f}%"
if pass_rate < 100:
score -= (100 - pass_rate) * 2 # Heavy penalty for failures
recommendations.append(f"Fix {total_tests - passed_tests} failing test(s)")
else:
checks["pass_rate"] = "No tests found"
score -= 50
recommendations.append("Add test suite to the project")
# Coverage
coverage = data.get("coverage_percent", None)
if coverage is not None:
checks["coverage"] = f"{coverage}%"
if coverage < 80:
score -= (80 - coverage) * 0.5
recommendations.append(f"Increase test coverage from {coverage}% to 80%+")
else:
checks["coverage"] = "Not measured"
score -= 15
recommendations.append("Configure code coverage reporting")
# Flaky tests
flaky_count = data.get("flaky_tests", 0)
checks["flaky_tests"] = flaky_count
if flaky_count > 0:
score -= flaky_count * 5
recommendations.append(f"Investigate {flaky_count} flaky test(s)")
# Coverage delta
coverage_delta = data.get("coverage_delta", None)
if coverage_delta is not None:
checks["coverage_delta"] = f"{coverage_delta:+.1f}%"
if coverage_delta < -2:
score -= abs(coverage_delta) * 2
recommendations.append(f"Coverage dropped by {abs(coverage_delta):.1f}% since last release")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["tests"]
return CategoryScore(
name="tests",
display_name="Tests",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_code_quality(data: Dict) -> CategoryScore:
"""Score the Code Quality category (20% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
# Lint errors
lint_errors = data.get("lint_errors", 0)
checks["lint_errors"] = lint_errors
if lint_errors > 0:
score -= min(lint_errors * 3, 40)
recommendations.append(f"Fix {lint_errors} lint error(s)")
# Type errors
type_errors = data.get("type_errors", 0)
checks["type_errors"] = type_errors
if type_errors > 0:
score -= min(type_errors * 5, 30)
recommendations.append(f"Fix {type_errors} type error(s)")
# Complexity violations
complexity_violations = data.get("complexity_violations", 0)
checks["complexity_violations"] = complexity_violations
if complexity_violations > 0:
score -= min(complexity_violations * 4, 25)
recommendations.append(f"Refactor {complexity_violations} function(s) with high cyclomatic complexity")
# Code duplication
duplication_percent = data.get("duplication_percent", 0)
checks["duplication"] = f"{duplication_percent}%"
if duplication_percent > 5:
score -= min((duplication_percent - 5) * 2, 20)
recommendations.append(f"Reduce code duplication from {duplication_percent}% to under 5%")
# Dead code
dead_code_count = data.get("dead_code_count", 0)
checks["dead_code"] = dead_code_count
if dead_code_count > 0:
score -= min(dead_code_count * 2, 15)
recommendations.append(f"Remove {dead_code_count} dead code instance(s)")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["code_quality"]
return CategoryScore(
name="code_quality",
display_name="Code Quality",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_documentation(data: Dict) -> CategoryScore:
"""Score the Documentation category (15% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
checklist = {
"readme_updated": ("README updated", 20),
"api_docs_current": ("API docs current", 25),
"changelog_generated": ("Changelog generated", 30),
"migration_guide": ("Migration guide present", 25),
}
for key, (label, penalty) in checklist.items():
value = data.get(key, False)
checks[key] = value
if not value:
score -= penalty
recommendations.append(f"{label}")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["documentation"]
return CategoryScore(
name="documentation",
display_name="Documentation",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_security(data: Dict) -> CategoryScore:
"""Score the Security category (15% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
# Secrets in code
secrets_found = data.get("secrets_found", 0)
checks["secrets_found"] = secrets_found
if secrets_found > 0:
score -= 50 # Hard penalty
recommendations.append(f"Remove {secrets_found} secret(s) from codebase immediately")
# Dependency CVEs
critical_cves = data.get("critical_cves", 0)
high_cves = data.get("high_cves", 0)
medium_cves = data.get("medium_cves", 0)
checks["critical_cves"] = critical_cves
checks["high_cves"] = high_cves
checks["medium_cves"] = medium_cves
if critical_cves > 0:
score -= critical_cves * 25
recommendations.append(f"Resolve {critical_cves} critical CVE(s) before release")
if high_cves > 0:
score -= high_cves * 10
recommendations.append(f"Resolve {high_cves} high-severity CVE(s)")
if medium_cves > 0:
score -= medium_cves * 3
if medium_cves > 3:
recommendations.append(f"Review {medium_cves} medium-severity CVE(s)")
# SAST clean
sast_clean = data.get("sast_clean", True)
checks["sast_clean"] = sast_clean
if not sast_clean:
score -= 20
recommendations.append("Address SAST findings before release")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["security"]
return CategoryScore(
name="security",
display_name="Security",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_breaking_changes(data: Dict) -> CategoryScore:
"""Score the Breaking Changes category (10% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
breaking_count = data.get("breaking_changes_count", 0)
checks["breaking_changes"] = breaking_count
if breaking_count > 0:
# Breaking changes need documentation
documented = data.get("breaking_changes_documented", False)
checks["documented"] = documented
if not documented:
score -= 40
recommendations.append("Document all breaking changes in release notes")
migration_provided = data.get("migration_path_provided", False)
checks["migration_path"] = migration_provided
if not migration_provided:
score -= 30
recommendations.append("Provide migration path for breaking changes")
deprecation_notice = data.get("deprecation_notice_given", False)
checks["deprecation_notice"] = deprecation_notice
if not deprecation_notice:
score -= 20
recommendations.append("Issue deprecation notices before breaking changes")
# Penalty scales with number of breaking changes
if breaking_count > 3:
score -= 10
recommendations.append(f"Consider splitting {breaking_count} breaking changes across releases")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["breaking_changes"]
return CategoryScore(
name="breaking_changes",
display_name="Breaking Changes",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_dependencies(data: Dict) -> CategoryScore:
"""Score the Dependencies category (10% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
# Lock file consistency
lock_consistent = data.get("lock_files_consistent", True)
checks["lock_consistent"] = lock_consistent
if not lock_consistent:
score -= 30
recommendations.append("Regenerate lock files to ensure consistency")
# Yanked packages
yanked_packages = data.get("yanked_packages", 0)
checks["yanked_packages"] = yanked_packages
if yanked_packages > 0:
score -= yanked_packages * 15
recommendations.append(f"Replace {yanked_packages} yanked package(s)")
# Major upgrades reviewed
major_upgrades = data.get("major_upgrades_pending", 0)
major_reviewed = data.get("major_upgrades_reviewed", True)
checks["major_upgrades_pending"] = major_upgrades
checks["major_upgrades_reviewed"] = major_reviewed
if major_upgrades > 0 and not major_reviewed:
score -= 20
recommendations.append(f"Review {major_upgrades} pending major dependency upgrade(s)")
# Outdated dependencies
outdated_count = data.get("outdated_dependencies", 0)
checks["outdated"] = outdated_count
if outdated_count > 10:
score -= 10
recommendations.append(f"Update {outdated_count} outdated dependencies")
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["dependencies"]
return CategoryScore(
name="dependencies",
display_name="Dependencies",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
def score_rollback_plan(data: Dict) -> CategoryScore:
"""Score the Rollback Plan category (5% weight)."""
checks: Dict[str, Any] = {}
recommendations: List[str] = []
score = 100.0
checklist = {
"rollback_documented": ("Rollback procedure documented", 35),
"db_migration_reversible": ("Database migrations reversible", 30),
"feature_flags_in_place": ("Feature flags in place for new features", 20),
"monitoring_configured": ("Monitoring and alerting configured", 15),
}
for key, (label, penalty) in checklist.items():
value = data.get(key, False)
checks[key] = value
if not value:
score -= penalty
recommendations.append(label)
score = max(0, min(100, score))
weight = CATEGORY_WEIGHTS["rollback_plan"]
return CategoryScore(
name="rollback_plan",
display_name="Rollback Plan",
score=score,
weight=weight,
weighted_score=score * weight,
checks=checks,
recommendations=recommendations,
is_blocker=score < CATEGORY_BLOCKER_THRESHOLD,
)
# ---------------------------------------------------------------------------
# Scoring engine
# ---------------------------------------------------------------------------
SCORERS = {
"tests": score_tests,
"code_quality": score_code_quality,
"documentation": score_documentation,
"security": score_security,
"breaking_changes": score_breaking_changes,
"dependencies": score_dependencies,
"rollback_plan": score_rollback_plan,
}
def compute_readiness(data: Dict) -> ReadinessReport:
"""Compute the full readiness report from input data."""
version = data.get("version", None)
categories: List[CategoryScore] = []
all_recommendations: List[str] = []
blockers: List[str] = []
for cat_name, scorer in SCORERS.items():
cat_data = data.get(cat_name, {})
cat_score = scorer(cat_data)
categories.append(cat_score)
if cat_score.is_blocker:
blockers.append(
f"{cat_score.display_name} scored {cat_score.score:.0f}/100 "
f"(below {CATEGORY_BLOCKER_THRESHOLD} threshold)"
)
all_recommendations.extend(
f"{cat_score.display_name}: {r}" for r in cat_score.recommendations
)
overall = sum(c.weighted_score for c in categories)
has_category_blocker = any(c.is_blocker for c in categories)
if has_category_blocker:
decision = "NO-GO"
elif overall >= DECISION_THRESHOLDS["go"]:
decision = "GO"
elif overall >= DECISION_THRESHOLDS["conditional"]:
decision = "CONDITIONAL"
else:
decision = "NO-GO"
return ReadinessReport(
version=version,
timestamp=datetime.now().isoformat(),
overall_score=overall,
decision=decision,
categories=categories,
blockers=blockers,
recommendations=all_recommendations,
has_category_blocker=has_category_blocker,
)
# ---------------------------------------------------------------------------
# Trend tracking
# ---------------------------------------------------------------------------
def load_history(filepath: str) -> List[TrendEntry]:
"""Load release score history from file."""
if not os.path.isfile(filepath):
return []
try:
with open(filepath, "r") as f:
data = json.load(f)
return [
TrendEntry(
version=e["version"],
date=e["date"],
score=e["score"],
decision=e["decision"],
)
for e in data.get("releases", [])
]
except (json.JSONDecodeError, KeyError, OSError):
return []
def save_history(filepath: str, history: List[TrendEntry], report: ReadinessReport) -> None:
"""Append current release to history file."""
entries = [
{"version": e.version, "date": e.date, "score": e.score, "decision": e.decision}
for e in history
]
entries.append({
"version": report.version or "unreleased",
"date": datetime.now().strftime("%Y-%m-%d"),
"score": round(report.overall_score, 1),
"decision": report.decision,
})
with open(filepath, "w") as f:
json.dump({"releases": entries}, f, indent=2)
def render_trend(history: List[TrendEntry]) -> str:
"""Render a trend summary."""
if not history:
return " No release history available."
lines: List[str] = []
lines.append(" Release Score Trend:")
lines.append(" " + "-" * 50)
for entry in history[-10:]: # Last 10 releases
bar_len = int(entry.score / 2)
bar = "#" * bar_len
lines.append(
f" {entry.version:12s} {entry.date} {entry.score:5.1f} [{entry.decision:11s}] {bar}"
)
if len(history) >= 2:
delta = history[-1].score - history[-2].score
direction = "UP" if delta > 0 else "DOWN" if delta < 0 else "FLAT"
lines.append(f"\n Trend: {direction} ({delta:+.1f} from previous release)")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def render_text_report(report: ReadinessReport, history: Optional[List[TrendEntry]] = None) -> str:
"""Render the report as human-readable text."""
lines: List[str] = []
lines.append("=" * 60)
lines.append(" RELEASE READINESS REPORT")
lines.append("=" * 60)
if report.version:
lines.append(f" Version: {report.version}")
lines.append(f" Timestamp: {report.timestamp}")
lines.append(f" Overall Score: {report.overall_score:.0f}/100 - {report.decision}")
lines.append("")
# Category breakdown
lines.append(" Category Breakdown:")
lines.append(" " + "-" * 50)
for cat in report.categories:
blocker_mark = " [BLOCKER]" if cat.is_blocker else ""
lines.append(
f" {cat.display_name:20s} {cat.score:5.0f}/100 ({cat.weight * 100:.0f}%) "
f"-> {cat.weighted_score:5.1f}{blocker_mark}"
)
lines.append("")
# Blockers
if report.blockers:
lines.append(" BLOCKERS:")
for b in report.blockers:
lines.append(f" [!] {b}")
lines.append("")
# Recommendations
if report.recommendations:
lines.append(" Recommendations:")
for i, r in enumerate(report.recommendations, 1):
lines.append(f" {i:2d}. {r}")
lines.append("")
# Trend
if history:
lines.append(render_trend(history))
lines.append("")
# Decision box
lines.append("-" * 60)
if report.decision == "GO":
lines.append(" DECISION: GO - Proceed with deployment")
elif report.decision == "CONDITIONAL":
lines.append(" DECISION: CONDITIONAL - Proceed with documented mitigations")
else:
lines.append(" DECISION: NO-GO - Address blockers before release")
lines.append("=" * 60)
return "\n".join(lines)
def render_summary(report: ReadinessReport) -> str:
"""Render a one-line summary suitable for notifications."""
version = report.version or "unreleased"
rec_count = len(report.recommendations)
rec_text = f" - {rec_count} recommendation(s)" if rec_count > 0 else ""
return f"Release {version} scored {report.overall_score:.0f}/100 ({report.decision}){rec_text}"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Release readiness scorer",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --input release_data.json
%(prog)s --input release_data.json --history history.json
%(prog)s --input release_data.json --json
%(prog)s --input release_data.json --format summary
""",
)
parser.add_argument("--input", "-i", required=True, help="Path to release data JSON file")
parser.add_argument("--history", help="Path to release history JSON file for trend tracking")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
parser.add_argument("--format", choices=["full", "summary"], default="full", dest="fmt", help="Output format")
args = parser.parse_args()
# Load input data
input_path = args.input
if not os.path.isfile(input_path):
print(f"Error: {input_path} not found", file=sys.stderr)
sys.exit(2)
try:
with open(input_path, "r") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {input_path}: {e}", file=sys.stderr)
sys.exit(2)
# Compute readiness
report = compute_readiness(data)
# Load history
history: Optional[List[TrendEntry]] = None
if args.history:
history = load_history(args.history)
save_history(args.history, history, report)
# Output
if args.json_output:
output = report.to_dict()
if history:
output["trend"] = [
{"version": e.version, "date": e.date, "score": e.score, "decision": e.decision}
for e in history
]
print(json.dumps(output, indent=2))
elif args.fmt == "summary":
print(render_summary(report))
else:
print(render_text_report(report, history))
# Exit code based on decision
if report.decision == "GO":
sys.exit(0)
elif report.decision == "CONDITIONAL":
sys.exit(0) # Conditional is still a pass
else:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Semantic Version Bumper for Release Orchestration.
Reads current version from multiple sources, auto-determines bump level
from conventional commits, and updates version across all discovered files.
Supported version sources:
- package.json
- pyproject.toml
- setup.py / setup.cfg
- Cargo.toml
- VERSION file
Usage:
python version_bumper.py --repo . --dry-run
python version_bumper.py --repo . --bump minor
python version_bumper.py --repo . --bump major --pre rc
python version_bumper.py --repo . --json
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# SemVer
# ---------------------------------------------------------------------------
SEMVER_RE = re.compile(
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<pre>[0-9A-Za-z\-.]+))?"
r"(?:\+(?P<build>[0-9A-Za-z\-.]+))?"
)
CONVENTIONAL_COMMIT_RE = re.compile(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
r"(?:\([^)]+\))?"
r"(?P<breaking>!)?"
r":\s+"
)
BREAKING_CHANGE_FOOTER_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE)
@dataclass
class SemVer:
"""Semantic version representation."""
major: int
minor: int
patch: int
pre: Optional[str] = None
build: Optional[str] = None
def __str__(self) -> str:
version = f"{self.major}.{self.minor}.{self.patch}"
if self.pre:
version += f"-{self.pre}"
if self.build:
version += f"+{self.build}"
return version
@classmethod
def parse(cls, version_str: str) -> Optional["SemVer"]:
match = SEMVER_RE.search(version_str)
if not match:
return None
return cls(
major=int(match.group("major")),
minor=int(match.group("minor")),
patch=int(match.group("patch")),
pre=match.group("pre"),
build=match.group("build"),
)
def bump_major(self) -> "SemVer":
return SemVer(self.major + 1, 0, 0)
def bump_minor(self) -> "SemVer":
return SemVer(self.major, self.minor + 1, 0)
def bump_patch(self) -> "SemVer":
return SemVer(self.major, self.minor, self.patch + 1)
def with_pre(self, pre_tag: str, pre_number: int = 1) -> "SemVer":
return SemVer(self.major, self.minor, self.patch, pre=f"{pre_tag}.{pre_number}")
def increment_pre(self) -> "SemVer":
"""Increment pre-release number."""
if not self.pre:
return self
parts = self.pre.rsplit(".", 1)
if len(parts) == 2:
try:
num = int(parts[1])
return SemVer(self.major, self.minor, self.patch, pre=f"{parts[0]}.{num + 1}")
except ValueError:
pass
return SemVer(self.major, self.minor, self.patch, pre=f"{self.pre}.1")
@dataclass
class VersionSource:
"""A file that contains a version string."""
filepath: str
current_version: str
line_number: int
pattern: str # For display
@dataclass
class BumpResult:
"""Result of a version bump operation."""
current: str
next: str
bump_type: str
sources_found: List[VersionSource]
sources_updated: List[str]
dry_run: bool
auto_detected: bool
commit_analysis: Dict[str, int]
def to_dict(self) -> Dict:
return {
"current_version": self.current,
"next_version": self.next,
"bump_type": self.bump_type,
"dry_run": self.dry_run,
"auto_detected": self.auto_detected,
"commit_analysis": self.commit_analysis,
"sources": [
{"file": s.filepath, "version": s.current_version, "line": s.line_number}
for s in self.sources_found
],
"updated_files": self.sources_updated,
}
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: List[str], cwd: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git"] + args, cwd=cwd, capture_output=True, text=True
)
def get_latest_tag(repo: str) -> Optional[str]:
result = run_git(["describe", "--tags", "--abbrev=0"], cwd=repo)
if result.returncode == 0:
return result.stdout.strip()
return None
def get_commits_since_tag(repo: str, tag: Optional[str]) -> List[Tuple[str, str]]:
"""Return (subject, body) tuples for commits since tag."""
if tag:
args = ["log", f"{tag}..HEAD", "--pretty=format:%s|||%b---END---"]
else:
args = ["log", "--pretty=format:%s|||%b---END---"]
result = run_git(args, cwd=repo)
if result.returncode != 0:
return []
commits = []
for block in result.stdout.split("---END---"):
block = block.strip()
if not block:
continue
parts = block.split("|||", 1)
subject = parts[0].strip()
body = parts[1].strip() if len(parts) > 1 else ""
commits.append((subject, body))
return commits
def detect_bump_from_commits(repo: str) -> Tuple[str, Dict[str, int]]:
"""Analyze commits since last tag to determine bump level."""
tag = get_latest_tag(repo)
commits = get_commits_since_tag(repo, tag)
analysis = {
"total": len(commits),
"feat": 0,
"fix": 0,
"breaking": 0,
"docs": 0,
"chore": 0,
"refactor": 0,
"perf": 0,
"test": 0,
"other": 0,
}
has_breaking = False
has_feat = False
has_fix = False
for subject, body in commits:
match = CONVENTIONAL_COMMIT_RE.match(subject)
if match:
commit_type = match.group("type")
is_breaking = match.group("breaking") is not None
if commit_type in analysis:
analysis[commit_type] += 1
else:
analysis["other"] += 1
if is_breaking:
has_breaking = True
analysis["breaking"] += 1
if commit_type == "feat":
has_feat = True
if commit_type == "fix":
has_fix = True
else:
analysis["other"] += 1
# Check body for BREAKING CHANGE footer
if BREAKING_CHANGE_FOOTER_RE.search(body):
has_breaking = True
analysis["breaking"] += 1
if has_breaking:
return "major", analysis
elif has_feat:
return "minor", analysis
elif has_fix:
return "patch", analysis
else:
return "patch", analysis
# ---------------------------------------------------------------------------
# Version source discovery
# ---------------------------------------------------------------------------
def find_version_sources(repo: str) -> List[VersionSource]:
"""Discover all files containing version strings."""
sources: List[VersionSource] = []
# package.json
pkg_json = os.path.join(repo, "package.json")
if os.path.isfile(pkg_json):
try:
with open(pkg_json, "r") as f:
data = json.load(f)
if "version" in data:
# Find line number
with open(pkg_json, "r") as f:
for i, line in enumerate(f, 1):
if '"version"' in line:
sources.append(VersionSource(
filepath=pkg_json,
current_version=data["version"],
line_number=i,
pattern="package.json",
))
break
except (json.JSONDecodeError, OSError):
pass
# pyproject.toml
pyproject = os.path.join(repo, "pyproject.toml")
if os.path.isfile(pyproject):
try:
with open(pyproject, "r") as f:
for i, line in enumerate(f, 1):
# Match version = "x.y.z" in [project] or [tool.poetry] section
match = re.match(r'^version\s*=\s*["\'](.+?)["\']', line)
if match:
sources.append(VersionSource(
filepath=pyproject,
current_version=match.group(1),
line_number=i,
pattern="pyproject.toml",
))
break
except OSError:
pass
# setup.py
setup_py = os.path.join(repo, "setup.py")
if os.path.isfile(setup_py):
try:
with open(setup_py, "r") as f:
content = f.read()
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if match:
# Find line number
line_num = content[:match.start()].count("\n") + 1
sources.append(VersionSource(
filepath=setup_py,
current_version=match.group(1),
line_number=line_num,
pattern="setup.py",
))
except OSError:
pass
# setup.cfg
setup_cfg = os.path.join(repo, "setup.cfg")
if os.path.isfile(setup_cfg):
try:
with open(setup_cfg, "r") as f:
for i, line in enumerate(f, 1):
match = re.match(r'^version\s*=\s*(.+)', line)
if match:
sources.append(VersionSource(
filepath=setup_cfg,
current_version=match.group(1).strip(),
line_number=i,
pattern="setup.cfg",
))
break
except OSError:
pass
# Cargo.toml
cargo_toml = os.path.join(repo, "Cargo.toml")
if os.path.isfile(cargo_toml):
try:
in_package = False
with open(cargo_toml, "r") as f:
for i, line in enumerate(f, 1):
if line.strip() == "[package]":
in_package = True
continue
if line.strip().startswith("[") and in_package:
break
if in_package:
match = re.match(r'^version\s*=\s*"(.+?)"', line)
if match:
sources.append(VersionSource(
filepath=cargo_toml,
current_version=match.group(1),
line_number=i,
pattern="Cargo.toml",
))
break
except OSError:
pass
# VERSION file
version_file = os.path.join(repo, "VERSION")
if os.path.isfile(version_file):
try:
with open(version_file, "r") as f:
ver = f.read().strip()
if SEMVER_RE.match(ver):
sources.append(VersionSource(
filepath=version_file,
current_version=ver,
line_number=1,
pattern="VERSION",
))
except OSError:
pass
return sources
def update_version_in_file(filepath: str, old_version: str, new_version: str) -> bool:
"""Replace old_version with new_version in the given file."""
try:
with open(filepath, "r") as f:
content = f.read()
if old_version not in content:
return False
# For JSON files, be more precise
if filepath.endswith(".json"):
# Replace only in "version": "x.y.z" pattern
pattern = f'"version"\\s*:\\s*"{re.escape(old_version)}"'
replacement = f'"version": "{new_version}"'
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
return False
content = new_content
else:
content = content.replace(old_version, new_version, 1)
with open(filepath, "w") as f:
f.write(content)
return True
except OSError:
return False
# ---------------------------------------------------------------------------
# Main logic
# ---------------------------------------------------------------------------
def bump_version(
repo: str,
bump_type: Optional[str] = None,
pre_tag: Optional[str] = None,
dry_run: bool = False,
) -> BumpResult:
"""Execute the version bump."""
sources = find_version_sources(repo)
if not sources:
# Try to get version from git tag
tag = get_latest_tag(repo)
if tag:
ver = tag.lstrip("v")
sources = [VersionSource(
filepath="(git tag)",
current_version=ver,
line_number=0,
pattern="git tag",
)]
if not sources:
return BumpResult(
current="0.0.0",
next="0.1.0",
bump_type=bump_type or "minor",
sources_found=[],
sources_updated=[],
dry_run=dry_run,
auto_detected=False,
commit_analysis={},
)
# Use first source as canonical version
current_version_str = sources[0].current_version
current = SemVer.parse(current_version_str)
if current is None:
print(f"Error: Could not parse version '{current_version_str}'", file=sys.stderr)
sys.exit(1)
# Auto-detect bump type from commits if not specified
auto_detected = bump_type is None
commit_analysis: Dict[str, int] = {}
if bump_type is None:
bump_type, commit_analysis = detect_bump_from_commits(repo)
else:
_, commit_analysis = detect_bump_from_commits(repo)
# Calculate next version
if bump_type == "major":
next_ver = current.bump_major()
elif bump_type == "minor":
next_ver = current.bump_minor()
else:
next_ver = current.bump_patch()
# Apply pre-release tag
if pre_tag:
# Check if current is already a pre-release of the same base version
if (current.pre and current.pre.startswith(pre_tag)
and current.major == next_ver.major
and current.minor == next_ver.minor
and current.patch == next_ver.patch):
next_ver = current.increment_pre()
else:
next_ver = next_ver.with_pre(pre_tag)
# Update files
updated_files: List[str] = []
if not dry_run:
for source in sources:
if source.filepath == "(git tag)":
continue
if update_version_in_file(source.filepath, source.current_version, str(next_ver)):
updated_files.append(source.filepath)
return BumpResult(
current=str(current),
next=str(next_ver),
bump_type=bump_type,
sources_found=sources,
sources_updated=updated_files,
dry_run=dry_run,
auto_detected=auto_detected,
commit_analysis=commit_analysis,
)
def render_text(result: BumpResult) -> str:
lines: List[str] = []
lines.append("=" * 60)
lines.append(" SEMANTIC VERSION BUMP")
lines.append("=" * 60)
if result.dry_run:
lines.append(" Mode: DRY RUN (no files modified)")
else:
lines.append(" Mode: LIVE")
lines.append("")
lines.append(f" Current Version: {result.current}")
lines.append(f" Next Version: {result.next}")
lines.append(f" Bump Type: {result.bump_type.upper()}")
lines.append(f" Auto-Detected: {'Yes' if result.auto_detected else 'No'}")
lines.append("")
if result.commit_analysis:
lines.append(" Commit Analysis:")
for key, count in sorted(result.commit_analysis.items()):
if count > 0:
lines.append(f" {key:12s}: {count}")
lines.append("")
lines.append("-" * 60)
lines.append(" Version Sources Found:")
for source in result.sources_found:
lines.append(f" {source.pattern:20s} {source.filepath}")
lines.append(f" {'':20s} version={source.current_version} (line {source.line_number})")
lines.append("")
if not result.dry_run and result.sources_updated:
lines.append(" Files Updated:")
for f in result.sources_updated:
lines.append(f" - {f}")
elif result.dry_run:
lines.append(" Files that would be updated:")
for source in result.sources_found:
if source.filepath != "(git tag)":
lines.append(f" - {source.filepath}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(
description="Semantic version bumper",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --repo . --dry-run
%(prog)s --repo . --bump minor
%(prog)s --repo . --bump major --pre rc
%(prog)s --repo . --json
""",
)
parser.add_argument("--repo", default=".", help="Path to repository")
parser.add_argument("--bump", choices=["major", "minor", "patch"], help="Bump type (auto-detected if omitted)")
parser.add_argument("--pre", help="Pre-release tag (alpha, beta, rc)")
parser.add_argument("--dry-run", action="store_true", help="Show what would change without modifying files")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
args = parser.parse_args()
repo = os.path.abspath(args.repo)
if not os.path.isdir(repo):
print(f"Error: {repo} is not a directory", file=sys.stderr)
sys.exit(2)
result = bump_version(
repo=repo,
bump_type=args.bump,
pre_tag=args.pre,
dry_run=args.dry_run,
)
if args.json_output:
print(json.dumps(result.to_dict(), indent=2))
else:
print(render_text(result))
if __name__ == "__main__":
main()
Related skills
FAQ
What does the pre-flight checker validate?
Seven checks: branch sync, merge conflicts, uncommitted changes, secret scanning, gitignore validation, conventional commits and dependency audit.
How is the deployment readiness decision made?
A 0-100 score across 7 weighted categories maps to GO (80-100), CONDITIONAL (60-79) or NO-GO (0-59), with any category below 40 triggering a mandatory blocker.