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