From c299c3f04e86dca20eac9d87ac043a4967b45cc6 Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 13:53:55 +0300 Subject: [PATCH 1/9] feat: add deploy-promote workflow for one-click branch promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub Actions workflow_dispatch that automates creating PRs for masterβ†’preprod and preprodβ†’prod promotions with auto-generated changelogs and Slack notifications for prod deploys. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-promote.yml | 155 +++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 .github/workflows/deploy-promote.yml diff --git a/.github/workflows/deploy-promote.yml b/.github/workflows/deploy-promote.yml new file mode 100644 index 0000000000..47c6eee317 --- /dev/null +++ b/.github/workflows/deploy-promote.yml @@ -0,0 +1,155 @@ +name: Deploy Promote + +on: + workflow_dispatch: + inputs: + target: + description: 'Target environment' + required: true + type: choice + options: + - preprod + - prod + dry_run: + description: 'Dry run (preview only)' + required: false + type: boolean + default: false + +jobs: + promote: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine source branch + id: source + run: | + if [ "${{ inputs.target }}" = "preprod" ]; then + echo "branch=master" >> $GITHUB_OUTPUT + elif [ "${{ inputs.target }}" = "prod" ]; then + echo "branch=preprod" >> $GITHUB_OUTPUT + fi + + - name: Generate changelog + id: changelog + run: | + SOURCE="${{ steps.source.outputs.branch }}" + TARGET="${{ inputs.target }}" + TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + + # Get commits between branches + COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges) + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + CHORE_COUNT=$(echo "$COMMITS" | grep -c "^chore" || true) + TOTAL=$(echo "$COMMITS" | grep -c . || true) + + # Build PR body + cat > /tmp/pr_body.md << EOF + ## πŸš€ Deployment to \`${TARGET}\` + + | Field | Value | + |-------|-------| + | **Source** | \`${SOURCE}\` | + | **Target** | \`${TARGET}\` | + | **Date** | ${TIMESTAMP} | + | **Total Commits** | ${TOTAL} | + | **Features** | ${FEAT_COUNT} | + | **Bug Fixes** | ${FIX_COUNT} | + | **Chores** | ${CHORE_COUNT} | + + ### Changes + + $(echo "$COMMITS" | sed 's/^/- /') + + --- + _Auto-generated by Deploy Promote workflow_ + EOF + + echo "title=Deploy to ${TARGET} β€” ${TIMESTAMP}" >> $GITHUB_OUTPUT + echo "total=${TOTAL}" >> $GITHUB_OUTPUT + echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT + echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT + + - name: Preview (dry run) + if: ${{ inputs.dry_run }} + run: | + echo "=== DRY RUN ===" + echo "Would create PR:" + echo " Base: ${{ inputs.target }}" + echo " Head: ${{ steps.source.outputs.branch }}" + echo " Title: ${{ steps.changelog.outputs.title }}" + echo "" + echo "=== PR Body ===" + cat /tmp/pr_body.md + + - name: Create PR + if: ${{ !inputs.dry_run }} + id: create_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_URL=$(gh pr create \ + --base "${{ inputs.target }}" \ + --head "${{ steps.source.outputs.branch }}" \ + --title "${{ steps.changelog.outputs.title }}" \ + --body-file /tmp/pr_body.md \ + --label "deployment,${{ inputs.target }}") + echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "βœ… PR created: ${PR_URL}" + + - name: Post to Slack (prod only) + if: ${{ inputs.target == 'prod' && !inputs.dry_run }} + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "πŸš€ Production Deployment PR Created" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Commits:* ${{ steps.changelog.outputs.total }}" + }, + { + "type": "mrkdwn", + "text": "*Features:* ${{ steps.changelog.outputs.feat_count }}" + }, + { + "type": "mrkdwn", + "text": "*Fixes:* ${{ steps.changelog.outputs.fix_count }}" + }, + { + "type": "mrkdwn", + "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "_Triggered by ${{ github.actor }}_" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} From 668cd8ea0794b08b495617d432fc7152595303db Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 13:57:17 +0300 Subject: [PATCH 2/9] feat: add deploy promotion jobs to Continuous workflow Removes standalone deploy-promote.yml and adds two manual jobs (promote-changelog, promote-create-pr) to the existing Continuous workflow via workflow_dispatch. Supports preprod and prod targets with auto-changelog and Slack notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/continuous.yaml | 152 ++++++++++++++++++++++++++ .github/workflows/deploy-promote.yml | 155 --------------------------- 2 files changed, 152 insertions(+), 155 deletions(-) delete mode 100644 .github/workflows/deploy-promote.yml diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index f39925a1ab..b7bd216372 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -10,6 +10,20 @@ on: - preprod - prod merge_group: + workflow_dispatch: + inputs: + deploy_target: + description: 'Promote to environment' + required: true + type: choice + options: + - preprod + - prod + dry_run: + description: 'Dry run (preview only, no PR created)' + required: false + type: boolean + default: false concurrency: group: ${{ github.ref }} @@ -430,3 +444,141 @@ jobs: fi fi fi + ####### + # Manual deploy promotion jobs (triggered via workflow_dispatch) + ####### + promote-changelog: + name: "Deploy: Generate Changelog" + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + title: ${{ steps.changelog.outputs.title }} + total: ${{ steps.changelog.outputs.total }} + feat_count: ${{ steps.changelog.outputs.feat_count }} + fix_count: ${{ steps.changelog.outputs.fix_count }} + source_branch: ${{ steps.source.outputs.branch }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Determine source branch + id: source + run: | + if [ "${{ inputs.deploy_target }}" = "preprod" ]; then + echo "branch=master" >> $GITHUB_OUTPUT + elif [ "${{ inputs.deploy_target }}" = "prod" ]; then + echo "branch=preprod" >> $GITHUB_OUTPUT + fi + - name: Generate changelog + id: changelog + run: | + SOURCE="${{ steps.source.outputs.branch }}" + TARGET="${{ inputs.deploy_target }}" + TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + + COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + TOTAL=$(echo "$COMMITS" | grep -c . || true) + + cat > /tmp/pr_body.md << EOF + ## Deployment to \`${TARGET}\` + + | Field | Value | + |-------|-------| + | **Source** | \`${SOURCE}\` | + | **Target** | \`${TARGET}\` | + | **Date** | ${TIMESTAMP} | + | **Total Commits** | ${TOTAL} | + | **Features** | ${FEAT_COUNT} | + | **Bug Fixes** | ${FIX_COUNT} | + + ### Changes + + $(echo "$COMMITS" | sed 's/^/- /') + + --- + _Auto-generated by Continuous workflow (deploy promotion)_ + EOF + + echo "title=Deploy to ${TARGET} β€” ${TIMESTAMP}" >> $GITHUB_OUTPUT + echo "total=${TOTAL}" >> $GITHUB_OUTPUT + echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT + echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT + + - name: Preview (dry run) + if: ${{ inputs.dry_run }} + run: | + echo "=== DRY RUN ===" + echo "Would create PR: ${{ steps.source.outputs.branch }} β†’ ${{ inputs.deploy_target }}" + echo "Title: ${{ steps.changelog.outputs.title }}" + echo "Commits: ${{ steps.changelog.outputs.total }}" + echo "" + cat /tmp/pr_body.md + + - name: Upload PR body + if: ${{ !inputs.dry_run }} + uses: actions/upload-artifact@v4 + with: + name: pr-body + path: /tmp/pr_body.md + + promote-create-pr: + name: "Deploy: Create Promotion PR" + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }} + needs: promote-changelog + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: pr-body + path: /tmp + - name: Create PR + id: create_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_URL=$(gh pr create \ + --base "${{ inputs.deploy_target }}" \ + --head "${{ needs.promote-changelog.outputs.source_branch }}" \ + --title "${{ needs.promote-changelog.outputs.title }}" \ + --body-file /tmp/pr_body.md \ + --label "deployment,${{ inputs.deploy_target }}") + echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "PR created: ${PR_URL}" + + - name: Post to Slack (prod only) + if: ${{ inputs.deploy_target == 'prod' }} + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "Production Deployment PR Created" } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Commits:* ${{ needs.promote-changelog.outputs.total }}" }, + { "type": "mrkdwn", "text": "*Features:* ${{ needs.promote-changelog.outputs.feat_count }}" }, + { "type": "mrkdwn", "text": "*Fixes:* ${{ needs.promote-changelog.outputs.fix_count }}" }, + { "type": "mrkdwn", "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" } + ] + }, + { "type": "divider" }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "_Triggered by ${{ github.actor }}_" } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} diff --git a/.github/workflows/deploy-promote.yml b/.github/workflows/deploy-promote.yml deleted file mode 100644 index 47c6eee317..0000000000 --- a/.github/workflows/deploy-promote.yml +++ /dev/null @@ -1,155 +0,0 @@ -name: Deploy Promote - -on: - workflow_dispatch: - inputs: - target: - description: 'Target environment' - required: true - type: choice - options: - - preprod - - prod - dry_run: - description: 'Dry run (preview only)' - required: false - type: boolean - default: false - -jobs: - promote: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine source branch - id: source - run: | - if [ "${{ inputs.target }}" = "preprod" ]; then - echo "branch=master" >> $GITHUB_OUTPUT - elif [ "${{ inputs.target }}" = "prod" ]; then - echo "branch=preprod" >> $GITHUB_OUTPUT - fi - - - name: Generate changelog - id: changelog - run: | - SOURCE="${{ steps.source.outputs.branch }}" - TARGET="${{ inputs.target }}" - TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') - - # Get commits between branches - COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges) - FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) - FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) - CHORE_COUNT=$(echo "$COMMITS" | grep -c "^chore" || true) - TOTAL=$(echo "$COMMITS" | grep -c . || true) - - # Build PR body - cat > /tmp/pr_body.md << EOF - ## πŸš€ Deployment to \`${TARGET}\` - - | Field | Value | - |-------|-------| - | **Source** | \`${SOURCE}\` | - | **Target** | \`${TARGET}\` | - | **Date** | ${TIMESTAMP} | - | **Total Commits** | ${TOTAL} | - | **Features** | ${FEAT_COUNT} | - | **Bug Fixes** | ${FIX_COUNT} | - | **Chores** | ${CHORE_COUNT} | - - ### Changes - - $(echo "$COMMITS" | sed 's/^/- /') - - --- - _Auto-generated by Deploy Promote workflow_ - EOF - - echo "title=Deploy to ${TARGET} β€” ${TIMESTAMP}" >> $GITHUB_OUTPUT - echo "total=${TOTAL}" >> $GITHUB_OUTPUT - echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT - echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT - - - name: Preview (dry run) - if: ${{ inputs.dry_run }} - run: | - echo "=== DRY RUN ===" - echo "Would create PR:" - echo " Base: ${{ inputs.target }}" - echo " Head: ${{ steps.source.outputs.branch }}" - echo " Title: ${{ steps.changelog.outputs.title }}" - echo "" - echo "=== PR Body ===" - cat /tmp/pr_body.md - - - name: Create PR - if: ${{ !inputs.dry_run }} - id: create_pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_URL=$(gh pr create \ - --base "${{ inputs.target }}" \ - --head "${{ steps.source.outputs.branch }}" \ - --title "${{ steps.changelog.outputs.title }}" \ - --body-file /tmp/pr_body.md \ - --label "deployment,${{ inputs.target }}") - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT - echo "βœ… PR created: ${PR_URL}" - - - name: Post to Slack (prod only) - if: ${{ inputs.target == 'prod' && !inputs.dry_run }} - uses: slackapi/slack-github-action@v1.27.0 - with: - payload: | - { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "πŸš€ Production Deployment PR Created" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commits:* ${{ steps.changelog.outputs.total }}" - }, - { - "type": "mrkdwn", - "text": "*Features:* ${{ steps.changelog.outputs.feat_count }}" - }, - { - "type": "mrkdwn", - "text": "*Fixes:* ${{ steps.changelog.outputs.fix_count }}" - }, - { - "type": "mrkdwn", - "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "_Triggered by ${{ github.actor }}_" - } - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} From cf5e9103008a522ca1138fc88cfe36d04563166d Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 14:02:19 +0300 Subject: [PATCH 3/9] fix: address review issues in deploy promotion jobs - Fix concurrency group to avoid cancelling promote jobs on concurrent pushes - Guard jest-tests from running on workflow_dispatch events - Fix PR body heredoc indentation that broke markdown rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/continuous.yaml | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index b7bd216372..f9ad66387a 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -26,8 +26,8 @@ on: default: false concurrency: - group: ${{ github.ref }} - cancel-in-progress: true + group: ${{ github.event_name == 'workflow_dispatch' && format('deploy-{0}', inputs.deploy_target) || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} jobs: build-generic: @@ -177,7 +177,7 @@ jobs: jest-tests: name: "Continuous Testing: Jest" # This name is referenced when slacking status runs-on: ubuntu-latest - if: github.event.pull_request.draft == false + if: ${{ github.event_name != 'workflow_dispatch' && github.event.pull_request.draft == false }} steps: - name: Checkout Code uses: actions/checkout@v4 @@ -483,25 +483,25 @@ jobs: FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) TOTAL=$(echo "$COMMITS" | grep -c . || true) - cat > /tmp/pr_body.md << EOF - ## Deployment to \`${TARGET}\` - - | Field | Value | - |-------|-------| - | **Source** | \`${SOURCE}\` | - | **Target** | \`${TARGET}\` | - | **Date** | ${TIMESTAMP} | - | **Total Commits** | ${TOTAL} | - | **Features** | ${FEAT_COUNT} | - | **Bug Fixes** | ${FIX_COUNT} | - - ### Changes - - $(echo "$COMMITS" | sed 's/^/- /') - - --- - _Auto-generated by Continuous workflow (deploy promotion)_ - EOF + { + echo "## Deployment to \`${TARGET}\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`${SOURCE}\` |" + echo "| **Target** | \`${TARGET}\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Continuous workflow (deploy promotion)_" + } > /tmp/pr_body.md echo "title=Deploy to ${TARGET} β€” ${TIMESTAMP}" >> $GITHUB_OUTPUT echo "total=${TOTAL}" >> $GITHUB_OUTPUT From 9ca880f47fec725f79d64d1082bf3fad80713a89 Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 14:08:43 +0300 Subject: [PATCH 4/9] feat: replace workflow_dispatch with environment-gated promotion jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two manual jobs now appear in every push-to-master workflow run: - "Promote: master β†’ preprod" (gated by promote-preprod environment) - "Promote: preprod β†’ prod" (gated by promote-prod environment) Developers click "Review deployments" in the Actions UI to approve. Each job generates a changelog and creates a PR automatically. Prod promotions also post to Slack. Requires creating "promote-preprod" and "promote-prod" environments in repo Settings β†’ Environments with required reviewers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/continuous.yaml | 160 +++++++++++++++--------------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index f9ad66387a..0e73e55c94 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -10,24 +10,13 @@ on: - preprod - prod merge_group: - workflow_dispatch: - inputs: - deploy_target: - description: 'Promote to environment' - required: true - type: choice - options: - - preprod - - prod - dry_run: - description: 'Dry run (preview only, no PR created)' - required: false - type: boolean - default: false + push: + branches: + - master concurrency: - group: ${{ github.event_name == 'workflow_dispatch' && format('deploy-{0}', inputs.deploy_target) || github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + group: ${{ github.ref }} + cancel-in-progress: true jobs: build-generic: @@ -177,7 +166,7 @@ jobs: jest-tests: name: "Continuous Testing: Jest" # This name is referenced when slacking status runs-on: ubuntu-latest - if: ${{ github.event_name != 'workflow_dispatch' && github.event.pull_request.draft == false }} + if: github.event.pull_request.draft == false steps: - name: Checkout Code uses: actions/checkout@v4 @@ -445,37 +434,30 @@ jobs: fi fi ####### - # Manual deploy promotion jobs (triggered via workflow_dispatch) + # Manual promotion jobs β€” appear after CI on push to master. + # Developers click "Review deployments" in the Actions UI to trigger. + # Requires GitHub environments "promote-preprod" and "promote-prod" + # configured with required reviewers in repo Settings β†’ Environments. ####### - promote-changelog: - name: "Deploy: Generate Changelog" - if: ${{ github.event_name == 'workflow_dispatch' }} + promote-to-preprod: + name: "Promote: master β†’ preprod" + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + needs: [build-generic, build-derived] runs-on: ubuntu-latest + environment: promote-preprod permissions: contents: read - outputs: - title: ${{ steps.changelog.outputs.title }} - total: ${{ steps.changelog.outputs.total }} - feat_count: ${{ steps.changelog.outputs.feat_count }} - fix_count: ${{ steps.changelog.outputs.fix_count }} - source_branch: ${{ steps.source.outputs.branch }} + pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Determine source branch - id: source - run: | - if [ "${{ inputs.deploy_target }}" = "preprod" ]; then - echo "branch=master" >> $GITHUB_OUTPUT - elif [ "${{ inputs.deploy_target }}" = "prod" ]; then - echo "branch=preprod" >> $GITHUB_OUTPUT - fi - - name: Generate changelog - id: changelog + - name: Generate changelog and create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - SOURCE="${{ steps.source.outputs.branch }}" - TARGET="${{ inputs.deploy_target }}" + SOURCE="master" + TARGET="preprod" TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") @@ -500,61 +482,79 @@ jobs: echo "$COMMITS" | sed 's/^/- /' echo "" echo "---" - echo "_Auto-generated by Continuous workflow (deploy promotion)_" + echo "_Auto-generated by Continuous workflow (promote to preprod)_" } > /tmp/pr_body.md - echo "title=Deploy to ${TARGET} β€” ${TIMESTAMP}" >> $GITHUB_OUTPUT - echo "total=${TOTAL}" >> $GITHUB_OUTPUT - echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT - echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT - - - name: Preview (dry run) - if: ${{ inputs.dry_run }} - run: | - echo "=== DRY RUN ===" - echo "Would create PR: ${{ steps.source.outputs.branch }} β†’ ${{ inputs.deploy_target }}" - echo "Title: ${{ steps.changelog.outputs.title }}" - echo "Commits: ${{ steps.changelog.outputs.total }}" - echo "" - cat /tmp/pr_body.md + TITLE="Deploy to ${TARGET} β€” ${TIMESTAMP}" - - name: Upload PR body - if: ${{ !inputs.dry_run }} - uses: actions/upload-artifact@v4 - with: - name: pr-body - path: /tmp/pr_body.md + PR_URL=$(gh pr create \ + --base "${TARGET}" \ + --head "${SOURCE}" \ + --title "${TITLE}" \ + --body-file /tmp/pr_body.md) || echo "PR may already exist" + echo "PR: ${PR_URL:-already exists}" - promote-create-pr: - name: "Deploy: Create Promotion PR" - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }} - needs: promote-changelog + promote-to-prod: + name: "Promote: preprod β†’ prod" + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + needs: [build-generic, build-derived] runs-on: ubuntu-latest + environment: promote-prod permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 with: - name: pr-body - path: /tmp - - name: Create PR + fetch-depth: 0 + - name: Generate changelog and create PR id: create_pr env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + SOURCE="preprod" + TARGET="prod" + TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + + COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + TOTAL=$(echo "$COMMITS" | grep -c . || true) + + { + echo "## Deployment to \`${TARGET}\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`${SOURCE}\` |" + echo "| **Target** | \`${TARGET}\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Continuous workflow (promote to prod)_" + } > /tmp/pr_body.md + + TITLE="Deploy to ${TARGET} β€” ${TIMESTAMP}" + PR_URL=$(gh pr create \ - --base "${{ inputs.deploy_target }}" \ - --head "${{ needs.promote-changelog.outputs.source_branch }}" \ - --title "${{ needs.promote-changelog.outputs.title }}" \ - --body-file /tmp/pr_body.md \ - --label "deployment,${{ inputs.deploy_target }}") - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT - echo "PR created: ${PR_URL}" + --base "${TARGET}" \ + --head "${SOURCE}" \ + --title "${TITLE}" \ + --body-file /tmp/pr_body.md) || echo "PR may already exist" + echo "pr_url=${PR_URL:-none}" >> $GITHUB_OUTPUT + echo "total=${TOTAL}" >> $GITHUB_OUTPUT + echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT + echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT - - name: Post to Slack (prod only) - if: ${{ inputs.deploy_target == 'prod' }} + - name: Post to Slack + if: ${{ steps.create_pr.outputs.pr_url != 'none' }} uses: slackapi/slack-github-action@v1.27.0 with: payload: | @@ -567,9 +567,9 @@ jobs: { "type": "section", "fields": [ - { "type": "mrkdwn", "text": "*Commits:* ${{ needs.promote-changelog.outputs.total }}" }, - { "type": "mrkdwn", "text": "*Features:* ${{ needs.promote-changelog.outputs.feat_count }}" }, - { "type": "mrkdwn", "text": "*Fixes:* ${{ needs.promote-changelog.outputs.fix_count }}" }, + { "type": "mrkdwn", "text": "*Commits:* ${{ steps.create_pr.outputs.total }}" }, + { "type": "mrkdwn", "text": "*Features:* ${{ steps.create_pr.outputs.feat_count }}" }, + { "type": "mrkdwn", "text": "*Fixes:* ${{ steps.create_pr.outputs.fix_count }}" }, { "type": "mrkdwn", "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" } ] }, From 9e8f93699929354c3a52c1c4c9b83ed9b3972550 Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 15:35:28 +0300 Subject: [PATCH 5/9] feat: move promote jobs to deploy-static.yaml, revert continuous.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotion jobs belong in deploy-static.yaml since that's the workflow that runs on push to master/preprod/prod. After the staging deploy completes (detect β†’ release β†’ update-state β†’ trigger-deploy), two environment-gated jobs appear: - "Promote: master β†’ preprod" (gated by promote-preprod env) - "Promote: preprod β†’ prod" (gated by promote-prod env) Developers click "Review deployments" to approve, then the job creates a PR with auto-generated changelog. Prod also sends Slack. Reverts continuous.yaml to its original state. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/continuous.yaml | 152 --------------------------- .github/workflows/deploy-static.yaml | 145 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 152 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 0e73e55c94..f39925a1ab 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -10,9 +10,6 @@ on: - preprod - prod merge_group: - push: - branches: - - master concurrency: group: ${{ github.ref }} @@ -433,152 +430,3 @@ jobs: fi fi fi - ####### - # Manual promotion jobs β€” appear after CI on push to master. - # Developers click "Review deployments" in the Actions UI to trigger. - # Requires GitHub environments "promote-preprod" and "promote-prod" - # configured with required reviewers in repo Settings β†’ Environments. - ####### - promote-to-preprod: - name: "Promote: master β†’ preprod" - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} - needs: [build-generic, build-derived] - runs-on: ubuntu-latest - environment: promote-preprod - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Generate changelog and create PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - SOURCE="master" - TARGET="preprod" - TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') - - COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") - FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) - FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) - TOTAL=$(echo "$COMMITS" | grep -c . || true) - - { - echo "## Deployment to \`${TARGET}\`" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| **Source** | \`${SOURCE}\` |" - echo "| **Target** | \`${TARGET}\` |" - echo "| **Date** | ${TIMESTAMP} |" - echo "| **Total Commits** | ${TOTAL} |" - echo "| **Features** | ${FEAT_COUNT} |" - echo "| **Bug Fixes** | ${FIX_COUNT} |" - echo "" - echo "### Changes" - echo "" - echo "$COMMITS" | sed 's/^/- /' - echo "" - echo "---" - echo "_Auto-generated by Continuous workflow (promote to preprod)_" - } > /tmp/pr_body.md - - TITLE="Deploy to ${TARGET} β€” ${TIMESTAMP}" - - PR_URL=$(gh pr create \ - --base "${TARGET}" \ - --head "${SOURCE}" \ - --title "${TITLE}" \ - --body-file /tmp/pr_body.md) || echo "PR may already exist" - echo "PR: ${PR_URL:-already exists}" - - promote-to-prod: - name: "Promote: preprod β†’ prod" - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} - needs: [build-generic, build-derived] - runs-on: ubuntu-latest - environment: promote-prod - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Generate changelog and create PR - id: create_pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - SOURCE="preprod" - TARGET="prod" - TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') - - COMMITS=$(git log ${TARGET}..${SOURCE} --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") - FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) - FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) - TOTAL=$(echo "$COMMITS" | grep -c . || true) - - { - echo "## Deployment to \`${TARGET}\`" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| **Source** | \`${SOURCE}\` |" - echo "| **Target** | \`${TARGET}\` |" - echo "| **Date** | ${TIMESTAMP} |" - echo "| **Total Commits** | ${TOTAL} |" - echo "| **Features** | ${FEAT_COUNT} |" - echo "| **Bug Fixes** | ${FIX_COUNT} |" - echo "" - echo "### Changes" - echo "" - echo "$COMMITS" | sed 's/^/- /' - echo "" - echo "---" - echo "_Auto-generated by Continuous workflow (promote to prod)_" - } > /tmp/pr_body.md - - TITLE="Deploy to ${TARGET} β€” ${TIMESTAMP}" - - PR_URL=$(gh pr create \ - --base "${TARGET}" \ - --head "${SOURCE}" \ - --title "${TITLE}" \ - --body-file /tmp/pr_body.md) || echo "PR may already exist" - echo "pr_url=${PR_URL:-none}" >> $GITHUB_OUTPUT - echo "total=${TOTAL}" >> $GITHUB_OUTPUT - echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT - echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT - - - name: Post to Slack - if: ${{ steps.create_pr.outputs.pr_url != 'none' }} - uses: slackapi/slack-github-action@v1.27.0 - with: - payload: | - { - "blocks": [ - { - "type": "header", - "text": { "type": "plain_text", "text": "Production Deployment PR Created" } - }, - { - "type": "section", - "fields": [ - { "type": "mrkdwn", "text": "*Commits:* ${{ steps.create_pr.outputs.total }}" }, - { "type": "mrkdwn", "text": "*Features:* ${{ steps.create_pr.outputs.feat_count }}" }, - { "type": "mrkdwn", "text": "*Fixes:* ${{ steps.create_pr.outputs.fix_count }}" }, - { "type": "mrkdwn", "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" } - ] - }, - { "type": "divider" }, - { - "type": "section", - "text": { "type": "mrkdwn", "text": "_Triggered by ${{ github.actor }}_" } - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} diff --git a/.github/workflows/deploy-static.yaml b/.github/workflows/deploy-static.yaml index 315e536320..0d67bab2c0 100644 --- a/.github/workflows/deploy-static.yaml +++ b/.github/workflows/deploy-static.yaml @@ -265,3 +265,148 @@ jobs: environment: ${{ needs.detect.outputs.env }} secrets: INFRA_WORKFLOW_TOKEN: ${{ secrets.INFRA_WORKFLOW_TOKEN }} + + ####### + # Manual promotion jobs β€” appear after deploy on push to master. + # Developers click "Review deployments" in the Actions UI to trigger. + # Requires GitHub environments "promote-preprod" and "promote-prod" + # configured with required reviewers in repo Settings β†’ Environments. + ####### + promote-to-preprod: + name: "Promote: master β†’ preprod" + needs: [detect, trigger-deploy] + if: > + always() && !failure() && !cancelled() && + needs.detect.outputs.env == 'staging' + runs-on: ubuntu-latest + environment: promote-preprod + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate changelog and create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + + COMMITS=$(git log preprod..master --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + TOTAL=$(echo "$COMMITS" | grep -c . || true) + + { + echo "## Promote to \`preprod\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`master\` |" + echo "| **Target** | \`preprod\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Deploy Static workflow_" + } > /tmp/pr_body.md + + gh pr create \ + --base preprod \ + --head master \ + --title "Promote to preprod β€” ${TIMESTAMP}" \ + --body-file /tmp/pr_body.md || echo "PR already exists" + + promote-to-prod: + name: "Promote: preprod β†’ prod" + needs: [detect, trigger-deploy] + if: > + always() && !failure() && !cancelled() && + needs.detect.outputs.env == 'staging' + runs-on: ubuntu-latest + environment: promote-prod + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate changelog and create PR + id: create_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + + COMMITS=$(git log prod..preprod --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + TOTAL=$(echo "$COMMITS" | grep -c . || true) + + { + echo "## Promote to \`prod\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`preprod\` |" + echo "| **Target** | \`prod\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Deploy Static workflow_" + } > /tmp/pr_body.md + + PR_URL=$(gh pr create \ + --base prod \ + --head preprod \ + --title "Promote to prod β€” ${TIMESTAMP}" \ + --body-file /tmp/pr_body.md 2>&1) || PR_URL="already exists" + echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "total=${TOTAL}" >> $GITHUB_OUTPUT + echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT + echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT + + - name: Post to Slack + if: ${{ !contains(steps.create_pr.outputs.pr_url, 'already exists') }} + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "Production Promotion PR Created" } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Commits:* ${{ steps.create_pr.outputs.total }}" }, + { "type": "mrkdwn", "text": "*Features:* ${{ steps.create_pr.outputs.feat_count }}" }, + { "type": "mrkdwn", "text": "*Fixes:* ${{ steps.create_pr.outputs.fix_count }}" }, + { "type": "mrkdwn", "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" } + ] + }, + { "type": "divider" }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "_Triggered by ${{ github.actor }}_" } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} From c86b2e9f6730c7ed8193abed17eb90ab9c3d0628 Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 15:43:09 +0300 Subject: [PATCH 6/9] feat: add branch protection gate to deploy-static workflow Adds a branch-protection job that runs before detect and enforces: - preprod: only accepts merges from master - prod: only accepts merges from preprod, unless the actor is in the HOTFIX_ALLOWED_USERS secret (comma-separated usernames) - master: no restriction - [skip ci] deploy state commits are always allowed Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-static.yaml | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.github/workflows/deploy-static.yaml b/.github/workflows/deploy-static.yaml index 0d67bab2c0..59be6e877d 100644 --- a/.github/workflows/deploy-static.yaml +++ b/.github/workflows/deploy-static.yaml @@ -9,7 +9,71 @@ concurrency: cancel-in-progress: false jobs: + branch-protection: + name: "Gate: Validate promotion path" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 50 + - name: Enforce promotion rules + env: + HOTFIX_USERS: ${{ secrets.HOTFIX_ALLOWED_USERS }} + run: | + BRANCH="${GITHUB_REF_NAME}" + ACTOR="${GITHUB_ACTOR}" + + # master can receive merges from any branch β€” no restriction + if [[ "$BRANCH" == "master" ]]; then + echo "βœ… master: no source restriction" + exit 0 + fi + + # Get the source branch of the merge commit + MERGE_PARENTS=$(git log -1 --pretty=format:"%P" HEAD) + PARENT_COUNT=$(echo "$MERGE_PARENTS" | wc -w | tr -d ' ') + + if [[ "$PARENT_COUNT" -lt 2 ]]; then + # Direct push (not a merge), check if it's a [skip ci] commit from deploy workflow + COMMIT_MSG=$(git log -1 --pretty=format:"%s" HEAD) + if [[ "$COMMIT_MSG" == *"[skip ci]"* ]]; then + echo "βœ… Deploy state commit β€” allowed" + exit 0 + fi + # Direct push to protected branch + SOURCE_BRANCH="direct-push" + else + # Merge commit β€” resolve source branch name + SECOND_PARENT=$(echo "$MERGE_PARENTS" | awk '{print $2}') + SOURCE_BRANCH=$(git branch -r --contains "$SECOND_PARENT" 2>/dev/null | grep -v HEAD | head -1 | sed 's|.*origin/||' | tr -d ' ') + fi + + echo "Branch: $BRANCH" + echo "Source: $SOURCE_BRANCH" + echo "Actor: $ACTOR" + + if [[ "$BRANCH" == "preprod" ]]; then + if [[ "$SOURCE_BRANCH" != "master" ]]; then + echo "❌ preprod only accepts merges from master (got: $SOURCE_BRANCH)" + exit 1 + fi + echo "βœ… preprod ← master: allowed" + + elif [[ "$BRANCH" == "prod" ]]; then + if [[ "$SOURCE_BRANCH" == "preprod" ]]; then + echo "βœ… prod ← preprod: allowed" + elif echo "${HOTFIX_USERS:-}" | tr ',' '\n' | grep -qx "$ACTOR"; then + echo "⚠️ prod ← $SOURCE_BRANCH: HOTFIX by authorized user $ACTOR" + else + echo "❌ prod only accepts merges from preprod (got: $SOURCE_BRANCH)" + echo " Direct push to prod requires authorization." + echo " Authorized hotfix users are configured in HOTFIX_ALLOWED_USERS secret." + exit 1 + fi + fi + detect: + needs: branch-protection runs-on: ubuntu-latest outputs: app_changed: ${{ steps.changes.outputs.app_changed }} From 8487f8c6c8f4f8a969c83cf5b079191f2168e1ee Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 16:34:39 +0300 Subject: [PATCH 7/9] feat: add PR-based promotion gate, remove post-push gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves branch protection from deploy-static (post-push, too late) to a separate promotion-gate.yaml that runs on PRs targeting preprod/prod. Rules: - preprod: only accepts PRs from master - prod: only accepts PRs from preprod (or hotfix users) This should be set as a required status check in GitHub branch protection for preprod and prod branches β€” prevents the merge button from being available when the source branch is wrong. Hotfix users are configured via HOTFIX_ALLOWED_USERS secret (comma-separated GitHub usernames). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-static.yaml | 64 --------------------------- .github/workflows/promotion-gate.yaml | 59 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/promotion-gate.yaml diff --git a/.github/workflows/deploy-static.yaml b/.github/workflows/deploy-static.yaml index 59be6e877d..0d67bab2c0 100644 --- a/.github/workflows/deploy-static.yaml +++ b/.github/workflows/deploy-static.yaml @@ -9,71 +9,7 @@ concurrency: cancel-in-progress: false jobs: - branch-protection: - name: "Gate: Validate promotion path" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 50 - - name: Enforce promotion rules - env: - HOTFIX_USERS: ${{ secrets.HOTFIX_ALLOWED_USERS }} - run: | - BRANCH="${GITHUB_REF_NAME}" - ACTOR="${GITHUB_ACTOR}" - - # master can receive merges from any branch β€” no restriction - if [[ "$BRANCH" == "master" ]]; then - echo "βœ… master: no source restriction" - exit 0 - fi - - # Get the source branch of the merge commit - MERGE_PARENTS=$(git log -1 --pretty=format:"%P" HEAD) - PARENT_COUNT=$(echo "$MERGE_PARENTS" | wc -w | tr -d ' ') - - if [[ "$PARENT_COUNT" -lt 2 ]]; then - # Direct push (not a merge), check if it's a [skip ci] commit from deploy workflow - COMMIT_MSG=$(git log -1 --pretty=format:"%s" HEAD) - if [[ "$COMMIT_MSG" == *"[skip ci]"* ]]; then - echo "βœ… Deploy state commit β€” allowed" - exit 0 - fi - # Direct push to protected branch - SOURCE_BRANCH="direct-push" - else - # Merge commit β€” resolve source branch name - SECOND_PARENT=$(echo "$MERGE_PARENTS" | awk '{print $2}') - SOURCE_BRANCH=$(git branch -r --contains "$SECOND_PARENT" 2>/dev/null | grep -v HEAD | head -1 | sed 's|.*origin/||' | tr -d ' ') - fi - - echo "Branch: $BRANCH" - echo "Source: $SOURCE_BRANCH" - echo "Actor: $ACTOR" - - if [[ "$BRANCH" == "preprod" ]]; then - if [[ "$SOURCE_BRANCH" != "master" ]]; then - echo "❌ preprod only accepts merges from master (got: $SOURCE_BRANCH)" - exit 1 - fi - echo "βœ… preprod ← master: allowed" - - elif [[ "$BRANCH" == "prod" ]]; then - if [[ "$SOURCE_BRANCH" == "preprod" ]]; then - echo "βœ… prod ← preprod: allowed" - elif echo "${HOTFIX_USERS:-}" | tr ',' '\n' | grep -qx "$ACTOR"; then - echo "⚠️ prod ← $SOURCE_BRANCH: HOTFIX by authorized user $ACTOR" - else - echo "❌ prod only accepts merges from preprod (got: $SOURCE_BRANCH)" - echo " Direct push to prod requires authorization." - echo " Authorized hotfix users are configured in HOTFIX_ALLOWED_USERS secret." - exit 1 - fi - fi - detect: - needs: branch-protection runs-on: ubuntu-latest outputs: app_changed: ${{ steps.changes.outputs.app_changed }} diff --git a/.github/workflows/promotion-gate.yaml b/.github/workflows/promotion-gate.yaml new file mode 100644 index 0000000000..5792a76a39 --- /dev/null +++ b/.github/workflows/promotion-gate.yaml @@ -0,0 +1,59 @@ +name: "Promotion Gate" + +# Runs on PRs targeting preprod or prod. +# Validates the source branch matches the allowed promotion path. +# Add "Promotion Gate" as a required status check in branch protection +# settings for preprod and prod β€” PRs cannot merge without this passing. + +on: + pull_request: + branches: [preprod, prod] + +jobs: + validate-promotion-path: + name: "Promotion Gate" + runs-on: ubuntu-latest + steps: + - name: Check source branch + env: + HOTFIX_USERS: ${{ secrets.HOTFIX_ALLOWED_USERS }} + run: | + TARGET="${{ github.base_ref }}" + SOURCE="${{ github.head_ref }}" + ACTOR="${{ github.actor }}" + + echo "PR: $SOURCE β†’ $TARGET" + echo "Actor: $ACTOR" + + if [[ "$TARGET" == "preprod" ]]; then + if [[ "$SOURCE" == "master" ]]; then + echo "βœ… preprod ← master: allowed" + else + echo "❌ BLOCKED: preprod only accepts PRs from master" + echo "" + echo " Got: $SOURCE β†’ preprod" + echo " Expected: master β†’ preprod" + echo "" + echo " To promote to preprod, create a PR from master." + exit 1 + fi + + elif [[ "$TARGET" == "prod" ]]; then + if [[ "$SOURCE" == "preprod" ]]; then + echo "βœ… prod ← preprod: allowed" + elif echo "${HOTFIX_USERS:-}" | tr ',' '\n' | grep -qx "$ACTOR"; then + echo "⚠️ HOTFIX: prod ← $SOURCE by authorized user $ACTOR" + echo "" + echo " This is a hotfix bypass. The normal path is preprod β†’ prod." + echo " Proceeding because $ACTOR is in the HOTFIX_ALLOWED_USERS list." + else + echo "❌ BLOCKED: prod only accepts PRs from preprod" + echo "" + echo " Got: $SOURCE β†’ prod" + echo " Expected: preprod β†’ prod" + echo "" + echo " To promote to prod, create a PR from preprod." + echo " For hotfixes, ask an authorized user (HOTFIX_ALLOWED_USERS) to open the PR." + exit 1 + fi + fi From 83ff9b7551e20fccd50e4d96a344354843160324 Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 16:48:10 +0300 Subject: [PATCH 8/9] fix: allow hotfix/* branches into preprod and prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates promotion gate per team feedback: - preprod accepts: master, hotfix/* - prod accepts: preprod, hotfix/* - Removed per-user hotfix check (HOTFIX_ALLOWED_USERS) β€” who can approve is controlled by GitHub environment reviewers instead Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/promotion-gate.yaml | 35 +++++++++++++-------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/workflows/promotion-gate.yaml b/.github/workflows/promotion-gate.yaml index 5792a76a39..e4fa62d010 100644 --- a/.github/workflows/promotion-gate.yaml +++ b/.github/workflows/promotion-gate.yaml @@ -4,6 +4,13 @@ name: "Promotion Gate" # Validates the source branch matches the allowed promotion path. # Add "Promotion Gate" as a required status check in branch protection # settings for preprod and prod β€” PRs cannot merge without this passing. +# +# Allowed paths: +# preprod ← master, hotfix/* +# prod ← preprod, hotfix/* +# +# Who can approve merges is controlled by GitHub environment reviewers +# on the "promote-preprod" and "promote-prod" environments, not here. on: pull_request: @@ -15,45 +22,37 @@ jobs: runs-on: ubuntu-latest steps: - name: Check source branch - env: - HOTFIX_USERS: ${{ secrets.HOTFIX_ALLOWED_USERS }} run: | TARGET="${{ github.base_ref }}" SOURCE="${{ github.head_ref }}" - ACTOR="${{ github.actor }}" echo "PR: $SOURCE β†’ $TARGET" - echo "Actor: $ACTOR" + + # hotfix/* branches are allowed into both preprod and prod + if [[ "$SOURCE" == hotfix/* ]]; then + echo "βœ… $TARGET ← $SOURCE: hotfix branch allowed" + exit 0 + fi if [[ "$TARGET" == "preprod" ]]; then if [[ "$SOURCE" == "master" ]]; then echo "βœ… preprod ← master: allowed" else - echo "❌ BLOCKED: preprod only accepts PRs from master" + echo "❌ BLOCKED: preprod only accepts PRs from master or hotfix/*" echo "" echo " Got: $SOURCE β†’ preprod" - echo " Expected: master β†’ preprod" - echo "" - echo " To promote to preprod, create a PR from master." + echo " Expected: master β†’ preprod (or hotfix/* β†’ preprod)" exit 1 fi elif [[ "$TARGET" == "prod" ]]; then if [[ "$SOURCE" == "preprod" ]]; then echo "βœ… prod ← preprod: allowed" - elif echo "${HOTFIX_USERS:-}" | tr ',' '\n' | grep -qx "$ACTOR"; then - echo "⚠️ HOTFIX: prod ← $SOURCE by authorized user $ACTOR" - echo "" - echo " This is a hotfix bypass. The normal path is preprod β†’ prod." - echo " Proceeding because $ACTOR is in the HOTFIX_ALLOWED_USERS list." else - echo "❌ BLOCKED: prod only accepts PRs from preprod" + echo "❌ BLOCKED: prod only accepts PRs from preprod or hotfix/*" echo "" echo " Got: $SOURCE β†’ prod" - echo " Expected: preprod β†’ prod" - echo "" - echo " To promote to prod, create a PR from preprod." - echo " For hotfixes, ask an authorized user (HOTFIX_ALLOWED_USERS) to open the PR." + echo " Expected: preprod β†’ prod (or hotfix/* β†’ prod)" exit 1 fi fi From db224184268707e3e850413f6b741186746cd34a Mon Sep 17 00:00:00 2001 From: yodem Date: Mon, 30 Mar 2026 17:30:30 +0300 Subject: [PATCH 9/9] refactor: extract shared changelog script, handle edge cases - Extract changelog + PR creation to build/ci/generate-promotion-pr.sh (DRYs up the two promote jobs in deploy-static.yaml) - Handle empty commits: skips PR creation when branches are identical - Add merge_group trigger to promotion-gate.yaml for merge queue support - Slack notification now also skips on no-changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-static.yaml | 74 ++------------------------- .github/workflows/promotion-gate.yaml | 2 + build/ci/generate-promotion-pr.sh | 69 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 71 deletions(-) create mode 100755 build/ci/generate-promotion-pr.sh diff --git a/.github/workflows/deploy-static.yaml b/.github/workflows/deploy-static.yaml index 0d67bab2c0..eb007663c1 100644 --- a/.github/workflows/deploy-static.yaml +++ b/.github/workflows/deploy-static.yaml @@ -290,39 +290,7 @@ jobs: - name: Generate changelog and create PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') - - COMMITS=$(git log preprod..master --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") - FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) - FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) - TOTAL=$(echo "$COMMITS" | grep -c . || true) - - { - echo "## Promote to \`preprod\`" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| **Source** | \`master\` |" - echo "| **Target** | \`preprod\` |" - echo "| **Date** | ${TIMESTAMP} |" - echo "| **Total Commits** | ${TOTAL} |" - echo "| **Features** | ${FEAT_COUNT} |" - echo "| **Bug Fixes** | ${FIX_COUNT} |" - echo "" - echo "### Changes" - echo "" - echo "$COMMITS" | sed 's/^/- /' - echo "" - echo "---" - echo "_Auto-generated by Deploy Static workflow_" - } > /tmp/pr_body.md - - gh pr create \ - --base preprod \ - --head master \ - --title "Promote to preprod β€” ${TIMESTAMP}" \ - --body-file /tmp/pr_body.md || echo "PR already exists" + run: ./build/ci/generate-promotion-pr.sh master preprod promote-to-prod: name: "Promote: preprod β†’ prod" @@ -343,46 +311,10 @@ jobs: id: create_pr env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') - - COMMITS=$(git log prod..preprod --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") - FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) - FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) - TOTAL=$(echo "$COMMITS" | grep -c . || true) - - { - echo "## Promote to \`prod\`" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| **Source** | \`preprod\` |" - echo "| **Target** | \`prod\` |" - echo "| **Date** | ${TIMESTAMP} |" - echo "| **Total Commits** | ${TOTAL} |" - echo "| **Features** | ${FEAT_COUNT} |" - echo "| **Bug Fixes** | ${FIX_COUNT} |" - echo "" - echo "### Changes" - echo "" - echo "$COMMITS" | sed 's/^/- /' - echo "" - echo "---" - echo "_Auto-generated by Deploy Static workflow_" - } > /tmp/pr_body.md - - PR_URL=$(gh pr create \ - --base prod \ - --head preprod \ - --title "Promote to prod β€” ${TIMESTAMP}" \ - --body-file /tmp/pr_body.md 2>&1) || PR_URL="already exists" - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT - echo "total=${TOTAL}" >> $GITHUB_OUTPUT - echo "feat_count=${FEAT_COUNT}" >> $GITHUB_OUTPUT - echo "fix_count=${FIX_COUNT}" >> $GITHUB_OUTPUT + run: ./build/ci/generate-promotion-pr.sh preprod prod - name: Post to Slack - if: ${{ !contains(steps.create_pr.outputs.pr_url, 'already exists') }} + if: ${{ steps.create_pr.outputs.pr_url != 'already exists' && steps.create_pr.outputs.pr_url != 'no-changes' }} uses: slackapi/slack-github-action@v1.27.0 with: payload: | diff --git a/.github/workflows/promotion-gate.yaml b/.github/workflows/promotion-gate.yaml index e4fa62d010..0250d54f36 100644 --- a/.github/workflows/promotion-gate.yaml +++ b/.github/workflows/promotion-gate.yaml @@ -15,6 +15,8 @@ name: "Promotion Gate" on: pull_request: branches: [preprod, prod] + merge_group: + branches: [preprod, prod] jobs: validate-promotion-path: diff --git a/build/ci/generate-promotion-pr.sh b/build/ci/generate-promotion-pr.sh new file mode 100755 index 0000000000..2e8cf6744e --- /dev/null +++ b/build/ci/generate-promotion-pr.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# generate-promotion-pr.sh β€” Creates a promotion PR with auto-generated changelog. +# Usage: ./generate-promotion-pr.sh +# +# Requires: GH_TOKEN environment variable for gh CLI authentication. +# Outputs (to GITHUB_OUTPUT if set): pr_url, total, feat_count, fix_count + +set -euo pipefail + +SOURCE="${1:?Usage: $0 }" +TARGET="${2:?Usage: $0 }" +TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + +# Generate changelog from commits between branches +COMMITS=$(git log "${TARGET}..${SOURCE}" --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + +if [[ -z "$COMMITS" || "$COMMITS" == "" ]]; then + echo "No new commits between ${SOURCE} and ${TARGET}. Skipping PR creation." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=no-changes" >> "$GITHUB_OUTPUT" + echo "total=0" >> "$GITHUB_OUTPUT" + echo "feat_count=0" >> "$GITHUB_OUTPUT" + echo "fix_count=0" >> "$GITHUB_OUTPUT" + fi + exit 0 +fi + +FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) +FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) +TOTAL=$(echo "$COMMITS" | grep -c . || true) + +# Build PR body +{ + echo "## Promote to \`${TARGET}\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`${SOURCE}\` |" + echo "| **Target** | \`${TARGET}\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Deploy Static workflow_" +} > /tmp/pr_body.md + +TITLE="Promote to ${TARGET} β€” ${TIMESTAMP}" + +PR_URL=$(gh pr create \ + --base "${TARGET}" \ + --head "${SOURCE}" \ + --title "${TITLE}" \ + --body-file /tmp/pr_body.md 2>&1) || PR_URL="already exists" + +echo "PR: ${PR_URL}" + +# Export outputs for GitHub Actions +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" + echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" + echo "feat_count=${FEAT_COUNT}" >> "$GITHUB_OUTPUT" + echo "fix_count=${FIX_COUNT}" >> "$GITHUB_OUTPUT" +fi