Skip to content

Commit ccd5061

Browse files
talissoncostaclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2e5ea89 commit ccd5061

2 files changed

Lines changed: 126 additions & 32 deletions

File tree

.github/actions/notify-slack-deploy/action.yml

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: Notify Slack of a deploy
2-
description: Post a deploy notification to a Slack incoming webhook, prompting a smoke test.
2+
description: Post the outcome of a deploy to a Slack incoming webhook.
33

44
inputs:
55
webhook_url:
@@ -15,6 +15,12 @@ inputs:
1515
description: The environment that was deployed to.
1616
required: false
1717
default: production
18+
status:
19+
description: >
20+
What happened: deployed, failed, or cancelled. Only a deploy asks for a
21+
smoke test, so this picks the copy and the buttons.
22+
required: false
23+
default: deployed
1824

1925
runs:
2026
using: composite
@@ -27,58 +33,119 @@ runs:
2733
SERVICE: ${{ inputs.service }}
2834
APP_URL: ${{ inputs.app_url }}
2935
ENVIRONMENT: ${{ inputs.environment }}
36+
STATUS: ${{ inputs.status }}
3037
COMMIT_SHA: ${{ github.sha }}
3138
ACTOR: ${{ github.actor }}
3239
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
33-
# jq builds the payload so every value is escaped for us, rather than
34-
# interpolated into a JSON heredoc.
40+
# Reading the run's own start time needs actions:read on the calling job.
41+
GH_TOKEN: ${{ github.token }}
42+
RUN_API_PATH: repos/${{ github.repository }}/actions/runs/${{ github.run_id }}
3543
run: |
44+
set -euo pipefail
45+
46+
# How long the run took, so the outcome message also answers "when did
47+
# this start", and no separate start notification is needed. The run is
48+
# over either way, so treat every step here as best effort and fall back
49+
# to omitting the fields.
50+
started_epoch=''
51+
started_label=''
52+
duration=''
53+
started_at="$(gh api "$RUN_API_PATH" --jq '.run_started_at' 2>/dev/null || true)"
54+
if [ -n "$started_at" ]; then
55+
started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || true)"
56+
fi
57+
if [ -n "$started_epoch" ]; then
58+
started_label="$(date -u -d "@$started_epoch" '+%H:%M UTC' 2>/dev/null || true)"
59+
elapsed=$(( $(date -u +%s) - started_epoch ))
60+
if [ "$elapsed" -ge 0 ]; then
61+
duration="$(( elapsed / 60 ))m $(( elapsed % 60 ))s"
62+
fi
63+
fi
64+
65+
# jq builds the payload so every value is escaped for us, rather than
66+
# interpolated into a JSON heredoc.
3667
jq -n \
3768
--arg service "$SERVICE" \
3869
--arg environment "$ENVIRONMENT" \
70+
--arg status "$STATUS" \
3971
--arg short_sha "${COMMIT_SHA:0:7}" \
4072
--arg actor "$ACTOR" \
4173
--arg app_url "$APP_URL" \
4274
--arg workflow_url "$WORKFLOW_URL" \
75+
--arg started_epoch "$started_epoch" \
76+
--arg started_label "$started_label" \
77+
--arg duration "$duration" \
4378
'($environment | (.[0:1] | ascii_upcase) + .[1:]) as $env_name |
79+
(if $status == "failed" then
80+
{
81+
headline: "🛑 \($service) deploy to \($environment) failed",
82+
body: "\($env_name) is unchanged and still serving the previous release.",
83+
actor_label: "Pushed by",
84+
link_app: false
85+
}
86+
elif $status == "cancelled" then
87+
{
88+
headline: "⏭️ \($service) deploy to \($environment) stopped",
89+
# Job level concurrency cancels a run in progress when the next
90+
# commit lands, which is the usual reason to see this.
91+
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.",
92+
actor_label: "Pushed by",
93+
link_app: false
94+
}
95+
else
96+
{
97+
headline: "🚀 \($service) deployed to \($environment)",
98+
body: "🧪 *\($env_name) smoke test required*\n\nPlease verify the application directly in \($environment).",
99+
actor_label: "Deployed by",
100+
link_app: true
101+
}
102+
end) as $copy |
44103
{
45104
# Slack honours username on this webhook but ignores icon_emoji.
46105
# The avatar comes from the icon set on the Slack app itself.
47106
username: "\($service) Deploy",
48-
text: "🚀 \($service) deployed to \($environment): smoke test required",
107+
text: $copy.headline,
49108
blocks: [
50109
{
51110
type: "header",
52-
text: { type: "plain_text", text: "🚀 \($service) deployed to \($environment)" }
111+
text: { type: "plain_text", text: $copy.headline }
53112
},
54113
{
55114
type: "section",
56-
text: {
57-
type: "mrkdwn",
58-
text: "🧪 *\($env_name) smoke test required*\n\nPlease verify the application directly in \($environment)."
59-
}
115+
text: { type: "mrkdwn", text: $copy.body }
60116
},
61117
{
62118
type: "section",
63-
fields: [
64-
{ type: "mrkdwn", text: "*Commit:*\n\($short_sha)" },
65-
{ type: "mrkdwn", text: "*Deployed by:*\n@\($actor)" }
66-
]
119+
fields: (
120+
[
121+
{ type: "mrkdwn", text: "*Commit:*\n\($short_sha)" },
122+
{ type: "mrkdwn", text: "*\($copy.actor_label):*\n@\($actor)" }
123+
]
124+
# Slack renders this date token in local time for each reader.
125+
+ (if $started_epoch != "" then
126+
[{ type: "mrkdwn", text: "*Started:*\n<!date^\($started_epoch)^{time}|\($started_label)>" }]
127+
else [] end)
128+
+ (if $duration != "" then
129+
[{ type: "mrkdwn", text: "*Duration:*\n\($duration)" }]
130+
else [] end)
131+
)
67132
},
68133
{
69134
type: "actions",
70-
elements: [
71-
{
72-
type: "button",
73-
text: { type: "plain_text", text: "Open \($env_name)" },
74-
url: $app_url
75-
},
76-
{
77-
type: "button",
78-
text: { type: "plain_text", text: "View GitHub Actions" },
79-
url: $workflow_url
80-
}
81-
]
135+
elements: (
136+
(if $copy.link_app then
137+
[{
138+
type: "button",
139+
text: { type: "plain_text", text: "Open \($env_name)" },
140+
url: $app_url
141+
}]
142+
else [] end)
143+
+ [{
144+
type: "button",
145+
text: { type: "plain_text", text: "View GitHub Actions" },
146+
url: $workflow_url
147+
}]
148+
)
82149
}
83150
]
84151
}' \

.github/workflows/frontend-deploy-production.yml

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,19 @@ jobs:
7979
npm_build_environment: prod
8080
secrets: inherit
8181

82-
notify-production-smoke-test:
83-
name: Notify Production Smoke Test
84-
needs: deploy-production
85-
if: ${{ needs.deploy-production.result == 'success' }}
82+
notify-production-deploy:
83+
name: Notify Production Deploy
84+
needs: [run-unit-tests, run-tests, deploy-production]
85+
# Reports whatever happened, not just a success. This notification is the
86+
# only report the deploy channel gets, so a failed or superseded run has to
87+
# come through here too.
88+
if: ${{ always() }}
8689
runs-on: ubuntu-latest
87-
# Only needs to read the repo to resolve the local action below.
8890
permissions:
91+
# Enough to resolve the local action below.
8992
contents: read
93+
# The notification reads this run to report when it started.
94+
actions: read
9095

9196
steps:
9297
- name: Cloning repo
@@ -95,15 +100,37 @@ jobs:
95100
# Nothing runs git after this, so the token need not persist.
96101
persist-credentials: false
97102

103+
- name: Resolve what to report
104+
id: outcome
105+
env:
106+
UNIT_TESTS: ${{ needs.run-unit-tests.result }}
107+
E2E_TESTS: ${{ needs.run-tests.result }}
108+
DEPLOY: ${{ needs.deploy-production.result }}
109+
run: |
110+
set -eu
111+
# A superseded run reads as cancelled rather than failed: run-tests
112+
# cancels itself in progress when the next commit lands on main. That
113+
# needs saying differently to a real failure, or every busy morning
114+
# looks like a broken deploy.
115+
if [ "$DEPLOY" = success ]; then
116+
status=deployed
117+
elif [ "$UNIT_TESTS" = cancelled ] || [ "$E2E_TESTS" = cancelled ] || [ "$DEPLOY" = cancelled ]; then
118+
status=cancelled
119+
else
120+
status=failed
121+
fi
122+
echo "status=$status" >> "$GITHUB_OUTPUT"
123+
98124
- name: Notify Slack
99-
# The deploy has already succeeded by this point, so a Slack outage or a
100-
# rotated webhook should not fail the run.
125+
# The run is over by this point either way, so a Slack outage or a
126+
# rotated webhook should not fail it.
101127
continue-on-error: true
102128
uses: ./.github/actions/notify-slack-deploy
103129
with:
104130
webhook_url: ${{ secrets.SLACK_FRONTEND_DEPLOY_WEBHOOK }}
105131
service: Frontend
106132
app_url: https://app.flagsmith.com
133+
status: ${{ steps.outcome.outputs.status }}
107134

108135
deploy-demo:
109136
name: Deploy to Vercel Demo

0 commit comments

Comments
 (0)