diff --git a/.github/workflows/weekly-nightly-promotion.yml b/.github/workflows/weekly-nightly-promotion.yml new file mode 100644 index 00000000..4a61a328 --- /dev/null +++ b/.github/workflows/weekly-nightly-promotion.yml @@ -0,0 +1,481 @@ +name: Weekly Nightly to Main Promotion + +# Creates a PR from nightly → main every Monday for scheduled release promotion. +# Includes safety checks for workflow status and provides manual trigger option. + +on: + schedule: + # Every Monday at 09:00 UTC (4am EST / 5am EDT) + - cron: '0 9 * * 1' + workflow_dispatch: + inputs: + reason: + description: 'Why are you running this manually?' + required: true + default: 'Ad-hoc promotion request' + skip_workflow_check: + description: 'Skip nightly workflow status check?' + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + NODE_VERSION: '24.12.0' + SOURCE_BRANCH: 'nightly' + TARGET_BRANCH: 'main' + +permissions: + contents: read + pull-requests: write + issues: write + actions: read + +jobs: + check-nightly-health: + name: Verify Nightly Branch Health + runs-on: ubuntu-latest + outputs: + is_healthy: ${{ steps.check.outputs.is_healthy }} + latest_run_url: ${{ steps.check.outputs.latest_run_url }} + failure_reason: ${{ steps.check.outputs.failure_reason }} + + steps: + - name: Check Nightly Workflow Status + id: check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const skipCheck = '${{ inputs.skip_workflow_check }}' === 'true'; + + if (skipCheck) { + core.info('Skipping workflow health check as requested'); + core.setOutput('is_healthy', 'true'); + core.setOutput('latest_run_url', 'N/A - check skipped'); + core.setOutput('failure_reason', ''); + return; + } + + core.info('Checking nightly branch workflow health...'); + + // Get the latest workflow runs on the nightly branch + const { data: runs } = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'nightly', + status: 'completed', + per_page: 10, + }); + + if (runs.workflow_runs.length === 0) { + core.setOutput('is_healthy', 'true'); + core.setOutput('latest_run_url', 'No completed runs found'); + core.setOutput('failure_reason', ''); + core.info('No completed workflow runs found on nightly - proceeding'); + return; + } + + // Check the most recent critical workflows + const criticalWorkflows = ['Nightly Build & Package', 'Quality Checks', 'E2E Tests']; + const recentRuns = runs.workflow_runs.slice(0, 10); + + let hasFailure = false; + let failureReason = ''; + let latestRunUrl = recentRuns[0]?.html_url || 'N/A'; + + for (const workflowName of criticalWorkflows) { + const latestRun = recentRuns.find(r => r.name === workflowName); + if (latestRun && latestRun.conclusion === 'failure') { + hasFailure = true; + failureReason = `${workflowName} failed (${latestRun.html_url})`; + latestRunUrl = latestRun.html_url; + core.warning(`Critical workflow "${workflowName}" has failed`); + break; + } + } + + core.setOutput('is_healthy', hasFailure ? 'false' : 'true'); + core.setOutput('latest_run_url', latestRunUrl); + core.setOutput('failure_reason', failureReason); + + if (hasFailure) { + core.warning(`Nightly branch has failing workflows: ${failureReason}`); + } else { + core.info('Nightly branch is healthy - all critical workflows passing'); + } + + create-promotion-pr: + name: Create Promotion PR + needs: check-nightly-health + runs-on: ubuntu-latest + if: needs.check-nightly-health.outputs.is_healthy == 'true' + outputs: + pr_number: ${{ steps.create-pr.outputs.pr_number }} + pr_url: ${{ steps.create-pr.outputs.pr_url }} + skipped: ${{ steps.check-diff.outputs.skipped }} + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for Differences + id: check-diff + run: | + git fetch origin ${{ env.SOURCE_BRANCH }} + + # Compare the branches + AHEAD_COUNT=$(git rev-list --count origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }}) + BEHIND_COUNT=$(git rev-list --count origin/${{ env.SOURCE_BRANCH }}..origin/${{ env.TARGET_BRANCH }}) + + echo "Nightly is $AHEAD_COUNT commits ahead of main" + echo "Nightly is $BEHIND_COUNT commits behind main" + + if [ "$AHEAD_COUNT" -eq 0 ]; then + echo "No changes to promote - nightly is up-to-date with main" + echo "skipped=true" >> $GITHUB_OUTPUT + echo "skip_reason=No changes to promote" >> $GITHUB_OUTPUT + else + echo "skipped=false" >> $GITHUB_OUTPUT + echo "ahead_count=$AHEAD_COUNT" >> $GITHUB_OUTPUT + fi + + - name: Generate Commit Summary + id: commits + if: steps.check-diff.outputs.skipped != 'true' + run: | + # Get the date for the PR title + DATE=$(date -u +%Y-%m-%d) + echo "date=$DATE" >> $GITHUB_OUTPUT + + # Generate commit log + COMMIT_LOG=$(git log --oneline origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }} | head -50) + COMMIT_COUNT=$(git rev-list --count origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }}) + + # Store commit log in a file to preserve formatting + cat > /tmp/commit_log.md << 'COMMITS_EOF' + ## Commits Being Promoted + + COMMITS_EOF + + if [ "$COMMIT_COUNT" -gt 50 ]; then + echo "_Showing first 50 of $COMMIT_COUNT commits:_" >> /tmp/commit_log.md + fi + + echo '```' >> /tmp/commit_log.md + echo "$COMMIT_LOG" >> /tmp/commit_log.md + echo '```' >> /tmp/commit_log.md + + if [ "$COMMIT_COUNT" -gt 50 ]; then + echo "" >> /tmp/commit_log.md + echo "_...and $((COMMIT_COUNT - 50)) more commits_" >> /tmp/commit_log.md + fi + + # Get files changed summary + FILES_CHANGED=$(git diff --stat origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }} | tail -1) + echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT + echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT + + - name: Check for Existing PR + id: existing-pr + if: steps.check-diff.outputs.skipped != 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${{ env.SOURCE_BRANCH }}`, + base: '${{ env.TARGET_BRANCH }}', + }); + + if (pulls.length > 0) { + core.info(`Existing PR found: #${pulls[0].number}`); + core.setOutput('exists', 'true'); + core.setOutput('pr_number', pulls[0].number); + core.setOutput('pr_url', pulls[0].html_url); + } else { + core.setOutput('exists', 'false'); + } + + - name: Create Promotion PR + id: create-pr + if: steps.check-diff.outputs.skipped != 'true' && steps.existing-pr.outputs.exists != 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const date = '${{ steps.commits.outputs.date }}'; + const commitCount = '${{ steps.commits.outputs.commit_count }}'; + const filesChanged = '${{ steps.commits.outputs.files_changed }}'; + const commitLog = fs.readFileSync('/tmp/commit_log.md', 'utf8'); + + const triggerReason = '${{ inputs.reason }}' || 'Scheduled weekly promotion'; + + const body = `## 🚀 Weekly Nightly to Main Promotion + + **Date:** ${date} + **Trigger:** ${triggerReason} + **Commits:** ${commitCount} commits to promote + **Changes:** ${filesChanged} + + --- + + ${commitLog} + + --- + + ## Pre-Merge Checklist + + - [ ] All status checks pass + - [ ] No critical security issues identified + - [ ] Changelog is up-to-date (auto-generated via workflow) + - [ ] Version bump is appropriate (if applicable) + + ## Merge Instructions + + This PR promotes changes from \`nightly\` to \`main\`. Once all checks pass: + + 1. **Review** the commit summary above + 2. **Approve** if changes look correct + 3. **Merge** using "Merge commit" to preserve history + + --- + + _This PR was automatically created by the [Weekly Nightly Promotion](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow._ + `; + + try { + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Weekly: Promote nightly to main (${date})`, + head: '${{ env.SOURCE_BRANCH }}', + base: '${{ env.TARGET_BRANCH }}', + body: body, + draft: false, + }); + + core.info(`Created PR #${pr.data.number}: ${pr.data.html_url}`); + core.setOutput('pr_number', pr.data.number); + core.setOutput('pr_url', pr.data.html_url); + + // Add labels (create if they don't exist) + const labels = ['automated', 'weekly-promotion']; + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + }); + } catch (e) { + // Label doesn't exist, create it + const colors = { + 'automated': '0e8a16', + 'weekly-promotion': '5319e7', + }; + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: colors[label] || 'ededed', + description: label === 'automated' + ? 'Automatically generated by CI/CD' + : 'Weekly promotion from nightly to main', + }); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.data.number, + labels: labels, + }); + + core.info('Labels added successfully'); + + } catch (error) { + core.setFailed(`Failed to create PR: ${error.message}`); + } + + - name: Update Existing PR + if: steps.check-diff.outputs.skipped != 'true' && steps.existing-pr.outputs.exists == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const prNumber = ${{ steps.existing-pr.outputs.pr_number }}; + core.info(`PR #${prNumber} already exists - adding comment with update`); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `🔄 **Weekly check:** This PR is still open. New commits may have been added to \`nightly\` since the original PR was created.\n\n_Triggered by [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})_`, + }); + + core.setOutput('pr_number', prNumber); + core.setOutput('pr_url', '${{ steps.existing-pr.outputs.pr_url }}'); + + notify-on-failure: + name: Notify on Failure + needs: [check-nightly-health, create-promotion-pr] + runs-on: ubuntu-latest + if: | + always() && + (needs.check-nightly-health.outputs.is_healthy == 'false' || + needs.create-promotion-pr.result == 'failure') + + steps: + - name: Create Failure Issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const isHealthy = '${{ needs.check-nightly-health.outputs.is_healthy }}'; + const failureReason = '${{ needs.check-nightly-health.outputs.failure_reason }}'; + const latestRunUrl = '${{ needs.check-nightly-health.outputs.latest_run_url }}'; + const prResult = '${{ needs.create-promotion-pr.result }}'; + + let title, body; + + if (isHealthy === 'false') { + title = '🚨 Weekly Promotion Blocked: Nightly Branch Unhealthy'; + body = `## Weekly Promotion Failed + + The weekly promotion from \`nightly\` to \`main\` was **blocked** because the nightly branch has failing workflows. + + ### Failure Details + + - **Reason:** ${failureReason} + - **Latest Run:** ${latestRunUrl} + - **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + ### Required Actions + + 1. Investigate the failing workflow on the nightly branch + 2. Fix the underlying issue + 3. Re-run the failed workflow + 4. Manually trigger the weekly promotion workflow once nightly is healthy + + --- + + _This issue was automatically created by the Weekly Nightly Promotion workflow._ + `; + } else { + title = '🚨 Weekly Promotion Failed: PR Creation Error'; + body = `## Weekly Promotion Failed + + The weekly promotion workflow encountered an error while trying to create the PR. + + ### Details + + - **PR Creation Result:** ${prResult} + - **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + ### Required Actions + + 1. Check the workflow logs for detailed error information + 2. Manually create the promotion PR if needed + 3. Investigate and fix any configuration issues + + --- + + _This issue was automatically created by the Weekly Nightly Promotion workflow._ + `; + } + + // Check for existing open issues with same title + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'weekly-promotion-failure', + }); + + const existingIssue = issues.find(i => i.title === title); + + if (existingIssue) { + // Add comment to existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: `🔄 **Update:** This issue occurred again.\n\n${body}`, + }); + core.info(`Updated existing issue #${existingIssue.number}`); + } else { + // Create label if it doesn't exist + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'weekly-promotion-failure', + }); + } catch (e) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'weekly-promotion-failure', + color: 'd73a4a', + description: 'Weekly promotion workflow failure', + }); + } + + // Create new issue + const issue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['weekly-promotion-failure', 'automated'], + }); + core.info(`Created issue #${issue.data.number}`); + } + + summary: + name: Workflow Summary + needs: [check-nightly-health, create-promotion-pr] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Generate Summary + run: | + echo "## 📋 Weekly Nightly Promotion Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + HEALTH="${{ needs.check-nightly-health.outputs.is_healthy }}" + SKIPPED="${{ needs.create-promotion-pr.outputs.skipped }}" + PR_URL="${{ needs.create-promotion-pr.outputs.pr_url }}" + PR_NUMBER="${{ needs.create-promotion-pr.outputs.pr_number }}" + FAILURE_REASON="${{ needs.check-nightly-health.outputs.failure_reason }}" + + echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + + if [ "$HEALTH" = "true" ]; then + echo "| Nightly Health Check | ✅ Healthy |" >> $GITHUB_STEP_SUMMARY + else + echo "| Nightly Health Check | ❌ Unhealthy: $FAILURE_REASON |" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$SKIPPED" = "true" ]; then + echo "| PR Creation | ⏭️ Skipped (no changes) |" >> $GITHUB_STEP_SUMMARY + elif [ -n "$PR_URL" ]; then + echo "| PR Creation | ✅ [PR #$PR_NUMBER]($PR_URL) |" >> $GITHUB_STEP_SUMMARY + else + echo "| PR Creation | ❌ Failed |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "_Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}_" >> $GITHUB_STEP_SUMMARY