From ccd5061bef6f9e88228d1966bd20cc4ec8d325e6 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 25 Aug 2026 17:58:00 -0300 Subject: [PATCH] ci(frontend-deploy): report every deploy outcome in one Slack message The deploy channel carried three messages per deploy: a run started card and a succeeded card from the GitHub Slack app, plus this notification. The succeeded card said nothing this one does not already say, with less detail. Dropping the app subscription would have lost the two states this job never covered, so cover them here instead. The job now runs on always() and reports deployed, failed, or cancelled, and the outcome message carries the run start time and duration so a separate start notification is not needed either. Cancellation gets its own wording rather than the red cross the app used for it. Job level concurrency stops a run in progress whenever the next commit lands on main, so most cancellations are a supersede and nothing is wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/notify-slack-deploy/action.yml | 117 ++++++++++++++---- .../workflows/frontend-deploy-production.yml | 41 ++++-- 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/.github/actions/notify-slack-deploy/action.yml b/.github/actions/notify-slack-deploy/action.yml index c673ed55cb3a..b0edac2a8e39 100644 --- a/.github/actions/notify-slack-deploy/action.yml +++ b/.github/actions/notify-slack-deploy/action.yml @@ -1,5 +1,5 @@ name: Notify Slack of a deploy -description: Post a deploy notification to a Slack incoming webhook, prompting a smoke test. +description: Post the outcome of a deploy to a Slack incoming webhook. inputs: webhook_url: @@ -15,6 +15,12 @@ inputs: description: The environment that was deployed to. required: false default: production + status: + description: > + What happened: deployed, failed, or cancelled. Only a deploy asks for a + smoke test, so this picks the copy and the buttons. + required: false + default: deployed runs: using: composite @@ -27,58 +33,119 @@ runs: SERVICE: ${{ inputs.service }} APP_URL: ${{ inputs.app_url }} ENVIRONMENT: ${{ inputs.environment }} + STATUS: ${{ inputs.status }} COMMIT_SHA: ${{ github.sha }} ACTOR: ${{ github.actor }} WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # jq builds the payload so every value is escaped for us, rather than - # interpolated into a JSON heredoc. + # Reading the run's own start time needs actions:read on the calling job. + GH_TOKEN: ${{ github.token }} + RUN_API_PATH: repos/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | + set -euo pipefail + + # How long the run took, so the outcome message also answers "when did + # this start", and no separate start notification is needed. The run is + # over either way, so treat every step here as best effort and fall back + # to omitting the fields. + started_epoch='' + started_label='' + duration='' + started_at="$(gh api "$RUN_API_PATH" --jq '.run_started_at' 2>/dev/null || true)" + if [ -n "$started_at" ]; then + started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || true)" + fi + if [ -n "$started_epoch" ]; then + started_label="$(date -u -d "@$started_epoch" '+%H:%M UTC' 2>/dev/null || true)" + elapsed=$(( $(date -u +%s) - started_epoch )) + if [ "$elapsed" -ge 0 ]; then + duration="$(( elapsed / 60 ))m $(( elapsed % 60 ))s" + fi + fi + + # jq builds the payload so every value is escaped for us, rather than + # interpolated into a JSON heredoc. jq -n \ --arg service "$SERVICE" \ --arg environment "$ENVIRONMENT" \ + --arg status "$STATUS" \ --arg short_sha "${COMMIT_SHA:0:7}" \ --arg actor "$ACTOR" \ --arg app_url "$APP_URL" \ --arg workflow_url "$WORKFLOW_URL" \ + --arg started_epoch "$started_epoch" \ + --arg started_label "$started_label" \ + --arg duration "$duration" \ '($environment | (.[0:1] | ascii_upcase) + .[1:]) as $env_name | + (if $status == "failed" then + { + headline: "๐Ÿ›‘ \($service) deploy to \($environment) failed", + body: "\($env_name) is unchanged and still serving the previous release.", + actor_label: "Pushed by", + link_app: false + } + elif $status == "cancelled" then + { + headline: "โญ๏ธ \($service) deploy to \($environment) stopped", + # Job level concurrency cancels a run in progress when the next + # commit lands, which is the usual reason to see this. + body: "This run was cancelled before deploying, usually because a newer commit landed on main. \($env_name) is unchanged, and the newer run deploys instead.", + actor_label: "Pushed by", + link_app: false + } + else + { + headline: "๐Ÿš€ \($service) deployed to \($environment)", + body: "๐Ÿงช *\($env_name) smoke test required*\n\nPlease verify the application directly in \($environment).", + actor_label: "Deployed by", + link_app: true + } + end) as $copy | { # Slack honours username on this webhook but ignores icon_emoji. # The avatar comes from the icon set on the Slack app itself. username: "\($service) Deploy", - text: "๐Ÿš€ \($service) deployed to \($environment): smoke test required", + text: $copy.headline, blocks: [ { type: "header", - text: { type: "plain_text", text: "๐Ÿš€ \($service) deployed to \($environment)" } + text: { type: "plain_text", text: $copy.headline } }, { type: "section", - text: { - type: "mrkdwn", - text: "๐Ÿงช *\($env_name) smoke test required*\n\nPlease verify the application directly in \($environment)." - } + text: { type: "mrkdwn", text: $copy.body } }, { type: "section", - fields: [ - { type: "mrkdwn", text: "*Commit:*\n\($short_sha)" }, - { type: "mrkdwn", text: "*Deployed by:*\n@\($actor)" } - ] + fields: ( + [ + { type: "mrkdwn", text: "*Commit:*\n\($short_sha)" }, + { type: "mrkdwn", text: "*\($copy.actor_label):*\n@\($actor)" } + ] + # Slack renders this date token in local time for each reader. + + (if $started_epoch != "" then + [{ type: "mrkdwn", text: "*Started:*\n" }] + else [] end) + + (if $duration != "" then + [{ type: "mrkdwn", text: "*Duration:*\n\($duration)" }] + else [] end) + ) }, { type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Open \($env_name)" }, - url: $app_url - }, - { - type: "button", - text: { type: "plain_text", text: "View GitHub Actions" }, - url: $workflow_url - } - ] + elements: ( + (if $copy.link_app then + [{ + type: "button", + text: { type: "plain_text", text: "Open \($env_name)" }, + url: $app_url + }] + else [] end) + + [{ + type: "button", + text: { type: "plain_text", text: "View GitHub Actions" }, + url: $workflow_url + }] + ) } ] }' \ diff --git a/.github/workflows/frontend-deploy-production.yml b/.github/workflows/frontend-deploy-production.yml index 47e0c2808f6e..15cc14bbd771 100644 --- a/.github/workflows/frontend-deploy-production.yml +++ b/.github/workflows/frontend-deploy-production.yml @@ -79,14 +79,19 @@ jobs: npm_build_environment: prod secrets: inherit - notify-production-smoke-test: - name: Notify Production Smoke Test - needs: deploy-production - if: ${{ needs.deploy-production.result == 'success' }} + notify-production-deploy: + name: Notify Production Deploy + needs: [run-unit-tests, run-tests, deploy-production] + # Reports whatever happened, not just a success. This notification is the + # only report the deploy channel gets, so a failed or superseded run has to + # come through here too. + if: ${{ always() }} runs-on: ubuntu-latest - # Only needs to read the repo to resolve the local action below. permissions: + # Enough to resolve the local action below. contents: read + # The notification reads this run to report when it started. + actions: read steps: - name: Cloning repo @@ -95,15 +100,37 @@ jobs: # Nothing runs git after this, so the token need not persist. persist-credentials: false + - name: Resolve what to report + id: outcome + env: + UNIT_TESTS: ${{ needs.run-unit-tests.result }} + E2E_TESTS: ${{ needs.run-tests.result }} + DEPLOY: ${{ needs.deploy-production.result }} + run: | + set -eu + # A superseded run reads as cancelled rather than failed: run-tests + # cancels itself in progress when the next commit lands on main. That + # needs saying differently to a real failure, or every busy morning + # looks like a broken deploy. + if [ "$DEPLOY" = success ]; then + status=deployed + elif [ "$UNIT_TESTS" = cancelled ] || [ "$E2E_TESTS" = cancelled ] || [ "$DEPLOY" = cancelled ]; then + status=cancelled + else + status=failed + fi + echo "status=$status" >> "$GITHUB_OUTPUT" + - name: Notify Slack - # The deploy has already succeeded by this point, so a Slack outage or a - # rotated webhook should not fail the run. + # The run is over by this point either way, so a Slack outage or a + # rotated webhook should not fail it. continue-on-error: true uses: ./.github/actions/notify-slack-deploy with: webhook_url: ${{ secrets.SLACK_FRONTEND_DEPLOY_WEBHOOK }} service: Frontend app_url: https://app.flagsmith.com + status: ${{ steps.outcome.outputs.status }} deploy-demo: name: Deploy to Vercel Demo