diff --git a/.github/actions/discord-notify/action.yml b/.github/actions/discord-notify/action.yml new file mode 100644 index 0000000..e9a214f --- /dev/null +++ b/.github/actions/discord-notify/action.yml @@ -0,0 +1,60 @@ +name: Discord deploy notification +description: Post a green (healthy) or red (failed) deploy-status embed to Discord. + +inputs: + webhook-url: + description: Discord webhook URL. When empty, the step is a no-op. + required: true + name: + description: Deploy target name shown in the title (e.g. kaapi-staging). + required: true + release: + description: Release identifier (tag or run number). + required: true + sha: + description: Full commit SHA; truncated to 7 chars for display. + required: true + run-url: + description: Link to the workflow run. + required: true + ok: + description: "'true' for a healthy deploy; anything else renders as failed." + required: true + failure-reason: + description: Optional extra line explaining a failure (e.g. CI blocked the release). + required: false + default: "" + +runs: + using: composite + steps: + - shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ inputs.webhook-url }} + NAME: ${{ inputs.name }} + RELEASE: ${{ inputs.release }} + SHA: ${{ inputs.sha }} + RUN_URL: ${{ inputs.run-url }} + OK: ${{ inputs.ok }} + REASON: ${{ inputs.failure-reason }} + run: | + [ -z "$DISCORD_WEBHOOK_URL" ] && { echo "No webhook configured, skipping"; exit 0; } + if [ "$OK" = "true" ]; then + TITLE="🟢 $NAME deployment healthy"; COLOR=3066993 # green + else + TITLE="🔴 $NAME deployment failed"; COLOR=15158332 # red + fi + SHA_SHORT=$(echo "$SHA" | cut -c1-7) + + fields=$(jq -n --arg release "$RELEASE" --arg sha "$SHA_SHORT" \ + '[{name:"Release",value:$release,inline:true},{name:"SHA",value:$sha,inline:true}]') + # Only a failed deploy with a stated cause gets the extra Reason line. + if [ "$OK" != "true" ] && [ -n "$REASON" ]; then + fields=$(echo "$fields" | jq --arg r "$REASON" '. + [{name:"Reason",value:$r,inline:false}]') + fi + + payload=$(jq -n --arg title "$TITLE" --argjson color "$COLOR" \ + --arg url "$RUN_URL" --argjson fields "$fields" \ + '{embeds:[{title:$title,url:$url,color:$color,fields:$fields,timestamp:(now|todate)}]}') + curl -sf -H "Content-Type: application/json" -X POST -d "$payload" "$DISCORD_WEBHOOK_URL" \ + || echo "Discord notification failed to send" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 1138e35..f54ef26 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -5,45 +5,82 @@ on: tags: - "v[0-9]+.[0-9]+.[0-9]+" # Deploy only when tags like v1.0.0, v2.1.0, etc., are created +concurrency: + group: production-ec2-deploy + cancel-in-progress: false + jobs: + verify-ci: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Confirm CI passed on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SHA="${{ github.sha }}" + echo "Checking Kaapi CI conclusion for ${SHA}" + CONCLUSION=$(gh api \ + "repos/${{ github.repository }}/actions/runs?head_sha=${SHA}" \ + --jq 'first(.workflow_runs[] + | select(.name == "Kaapi CI" and .status == "completed") + | .conclusion) // "missing"') + echo "Kaapi CI conclusion: ${CONCLUSION}" + if [ "${CONCLUSION}" != "success" ]; then + echo "::error::Kaapi CI has not passed for ${SHA} (conclusion: ${CONCLUSION}). Aborting release." + exit 1 + fi + deploy: + needs: verify-ci runs-on: ubuntu-latest environment: AWS_ENV permissions: - packages: write - contents: read - attestations: write id-token: write + contents: read steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ secrets.AWS_REGION }} - - name: Deploy via SSM + - name: Trigger deploy on EC2 via SSM id: ssm env: BUILD_DIRECTORY: ${{ secrets.BUILD_DIRECTORY }} APP_NAME: ${{ secrets.PM2_APP_NAME }} AWS_REGION: ${{ secrets.AWS_REGION }} INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }} - ROOT_USER: ${{ secrets.USER }} + DEPLOY_SHA: ${{ github.sha }} run: | - REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin main && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}" + : "${BUILD_DIRECTORY:?BUILD_DIRECTORY secret is not set}" + + REMOTE_CMD="git config --global --add safe.directory $BUILD_DIRECTORY \ + && cd $BUILD_DIRECTORY \ + && git fetch origin --tags --prune \ + && git checkout $DEPLOY_SHA \ + && npm ci \ + && npm run build \ + && pm2 restart $APP_NAME" + + # Build the parameters as JSON so quoting in REMOTE_CMD can't break it. + PARAMS=$(jq -n --arg cmd "$REMOTE_CMD" '{commands: [$cmd]}') + CMD_ID=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ --document-name "AWS-RunShellScript" \ - --parameters commands="[\"$REMOTE_CMD\"]" \ + --comment "Deploy kaapi-frontend ${{ github.ref_name }}" \ + --parameters "$PARAMS" \ --region "$AWS_REGION" \ - --query 'Command.CommandId' \ - --output text) + --query "Command.CommandId" --output text) echo "cmd_id=$CMD_ID" >> "$GITHUB_OUTPUT" + echo "Sent SSM command: $CMD_ID" - name: Wait for SSM command to finish timeout-minutes: 20 @@ -83,3 +120,20 @@ jobs: --instance-id "$INSTANCE_ID" \ --query '{Status:Status,Stdout:StandardOutputContent,Stderr:StandardErrorContent}' \ --output json + + notify: + needs: [verify-ci, deploy] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/discord-notify + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + name: kaapi-frontend-production + release: ${{ github.ref_name }} + sha: ${{ github.sha }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ok: ${{ needs.deploy.result == 'success' }} + # Call out the specific case where the release was blocked by CI. + failure-reason: ${{ needs.verify-ci.result != 'success' && 'CI never passed on the tagged commit — release blocked.' || '' }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 01eafc8..b92c758 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -1,56 +1,81 @@ name: Deploy Kaapi to EC2 Staging on: - push: - branches: - - main # Deploy only when changes are pushed to the main branch + workflow_run: + workflows: ["Kaapi CI"] + branches: [main] + types: [completed] + workflow_dispatch: + +concurrency: + group: staging-ec2-deploy + cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest - environment: AWS_ENV + environment: Staging + # workflow_dispatch runs unconditionally; workflow_run only on a green CI from a push to main (not PRs). + if: >- + ${{ github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' }} permissions: - packages: write - contents: read - attestations: write id-token: write + contents: read steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }} aws-region: ${{ secrets.AWS_REGION }} - - name: Deploy via SSM + - name: Trigger deploy on EC2 via SSM id: ssm env: BUILD_DIRECTORY: ${{ secrets.BUILD_DIRECTORY }} APP_NAME: ${{ secrets.PM2_APP_NAME }} AWS_REGION: ${{ secrets.AWS_REGION }} - INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }} + INSTANCE_ID: ${{ secrets.INSTANCE_ID }} ROOT_USER: ${{ secrets.USER }} + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | - REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin main && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}" + : "${BUILD_DIRECTORY:?BUILD_DIRECTORY secret is not set}" + : "${ROOT_USER:?USER secret is not set}" + + REMOTE_CMD="export HOME=/home/$ROOT_USER \ + && export NVM_DIR=/home/$ROOT_USER/.nvm \ + && . \$NVM_DIR/nvm.sh \ + && git config --global --add safe.directory $BUILD_DIRECTORY \ + && cd $BUILD_DIRECTORY \ + && git fetch origin --tags --prune \ + && git checkout $DEPLOY_SHA \ + && npm ci \ + && npm run build \ + && sudo -iu $ROOT_USER pm2 restart $APP_NAME" + + # Build the parameters as JSON so quoting in REMOTE_CMD can't break it. + PARAMS=$(jq -n --arg cmd "$REMOTE_CMD" '{commands: [$cmd]}') + CMD_ID=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ --document-name "AWS-RunShellScript" \ - --parameters commands="[\"$REMOTE_CMD\"]" \ + --comment "Deploy kaapi-frontend kaapi-staging" \ + --parameters "$PARAMS" \ --region "$AWS_REGION" \ - --query 'Command.CommandId' \ - --output text) + --query "Command.CommandId" --output text) echo "cmd_id=$CMD_ID" >> "$GITHUB_OUTPUT" + echo "Sent SSM command: $CMD_ID" - name: Wait for SSM command to finish timeout-minutes: 20 env: - INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }} + INSTANCE_ID: ${{ secrets.INSTANCE_ID }} CMD_ID: ${{ steps.ssm.outputs.cmd_id }} run: | + : "${INSTANCE_ID:?INSTANCE_ID secret is not set}" + : "${CMD_ID:?SSM command ID missing from previous step}" while true; do if aws ssm wait command-executed \ --command-id "$CMD_ID" \ @@ -83,3 +108,18 @@ jobs: --instance-id "$INSTANCE_ID" \ --query '{Status:Status,Stdout:StandardOutputContent,Stderr:StandardErrorContent}' \ --output json + + notify: + needs: [deploy] + if: ${{ always() && needs.deploy.result != 'skipped' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/discord-notify + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + name: kaapi-frontend-staging + release: "#${{ github.run_number }}" + sha: ${{ github.event.workflow_run.head_sha || github.sha }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ok: ${{ needs.deploy.result == 'success' }}