diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 1c48ce7..324cc47 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -8,6 +8,8 @@ jobs: sync-dev-to-main: runs-on: ubuntu-latest if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -33,17 +35,79 @@ jobs: git merge --ff-only origin/dev || { echo "❌ Fast-forward merge failed. Manual conflict resolution required." echo "Please ensure dev branch is ahead of main with no conflicts." - git merge --abort exit 1 } - name: Push updated main branch run: git push origin main + # Bump minor version on main + bump-minor-version: + runs-on: ubuntu-latest + needs: sync-dev-to-main + if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: main + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump minor version + run: | + pip install uv + python3 scripts/bump_version.py minor + uv lock + + - name: Commit and push + run: | + git add pyproject.toml uv.lock + git diff --cached --quiet && exit 0 + git commit -m "chore: bump minor version [skip ci]" + git push origin main + + # Sync minor version bump back to dev so dev and main stay on the same commit + sync-minor-to-dev: + runs-on: ubuntu-latest + needs: bump-minor-version + if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} + permissions: + contents: write + steps: + - name: Checkout dev + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: dev + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fast-forward dev to main + run: | + git fetch origin main + git merge --ff-only origin/main || { + echo "❌ Fast-forward merge of main into dev failed. dev has diverged from main." + echo "Please manually fast-forward dev to main before retrying the production deployment." + exit 1 + } + git push origin dev + # Build and push Docker images push-to-dockerhub: runs-on: ubuntu-latest - needs: sync-dev-to-main + needs: bump-minor-version if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} @@ -84,7 +148,7 @@ jobs: # Push to GitHub Container Registry push-to-ghcr: runs-on: ubuntu-latest - needs: sync-dev-to-main + needs: bump-minor-version if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} permissions: contents: read @@ -119,7 +183,7 @@ jobs: # Deploy to Staging deploy-to-staging: runs-on: ubuntu-latest - needs: [sync-dev-to-main, push-to-dockerhub, push-to-ghcr] + needs: [bump-minor-version, push-to-dockerhub, push-to-ghcr, sync-minor-to-dev] if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} env: RENDER_DEPLOY_HOOK_URL_DEV: ${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }} @@ -143,7 +207,7 @@ jobs: # Deploy to Production deploy-to-prod: runs-on: ubuntu-latest - needs: [sync-dev-to-main, push-to-dockerhub, push-to-ghcr, deploy-to-staging] + needs: [bump-minor-version, push-to-dockerhub, push-to-ghcr, deploy-to-staging, sync-minor-to-dev] if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }} env: RENDER_DEPLOY_HOOK_URL_PROD: ${{ secrets.RENDER_DEPLOY_HOOK_URL_PROD }} diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 10813b8..555489d 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -6,11 +6,17 @@ on: types: - completed +concurrency: + group: deploy-staging-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + jobs: # Deploy to staging environment deploy-staging: runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev' }} + if: "${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev' && !startsWith(github.event.workflow_run.head_commit.message, 'chore: bump') }}" + permissions: + contents: write env: RENDER_DEPLOY_HOOK_URL_DEV: ${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }} steps: @@ -21,6 +27,41 @@ jobs: exit 1 fi + - name: Checkout validated commit + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Verify dev HEAD matches validated SHA + run: | + git fetch origin dev + DEV_HEAD=$(git rev-parse origin/dev) + VALIDATED_SHA="${{ github.event.workflow_run.head_sha }}" + if [ "$DEV_HEAD" != "$VALIDATED_SHA" ]; then + echo "⚠️ dev has advanced to $DEV_HEAD beyond the validated SHA $VALIDATED_SHA. Skipping." + exit 0 + fi + + - name: Bump patch version + run: | + pip install uv + python3 scripts/bump_version.py patch + uv lock + + - name: Commit and push + run: | + git add pyproject.toml uv.lock + git diff --cached --quiet && exit 0 + git commit -m "chore: bump patch version [skip ci]" + git push origin HEAD:dev + - name: Deploy to Staging Environment run: | echo "🚀 Deploying to Staging..." diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..6e1d1ff --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Bump the project version in pyproject.toml. Usage: bump_version.py [patch|minor].""" + +import re +import sys + +if len(sys.argv) != 2 or sys.argv[1] not in ("patch", "minor"): + raise SystemExit("Usage: bump_version.py [patch|minor]") + +bump_type = sys.argv[1] + +with open("pyproject.toml") as f: + content = f.read() + +match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"', content, re.MULTILINE) +if not match: + raise SystemExit('version = "x.y.z" not found in pyproject.toml') + +major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + +if bump_type == "patch": + new_version = f"{major}.{minor}.{patch + 1}" +else: + new_version = f"{major}.{minor + 1}.0" + +new_content = re.sub( + r'^version = "\d+\.\d+\.\d+"', + f'version = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, +) + +with open("pyproject.toml", "w") as f: + f.write(new_content) + +print(f"Bumped: {major}.{minor}.{patch} -> {new_version}")