Skip to content

Commit c59f009

Browse files
mikhail-dclclaude
andcommitted
feat: switch Slack canvas delivery to Workflow Builder webhook
No Slack app needed: CI renders both environments statelessly from the GitHub Deployments API and POSTs to a member-created Slack workflow whose Update-a-canvas step replaces the channel canvas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b0dd91f commit c59f009

11 files changed

Lines changed: 574 additions & 468 deletions

File tree

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,28 @@
11
name: Slack canvas deploy status
2-
description: Upserts per-environment deployment status lines on the Pulse Slack channel canvas.
2+
description: Renders deployment status for dev and prd from the GitHub Deployments API and sends it to a Slack workflow webhook that replaces the Pulse channel canvas.
33

44
inputs:
5-
environment:
6-
description: Deployment environment (dev or prd).
5+
webhook-url:
6+
description: Slack Workflow Builder webhook URL (a secret — anyone holding it can write the canvas).
77
required: true
8-
state:
9-
description: Deployment state (in_progress, success, failure or error).
8+
github-token:
9+
description: Token with read access to this repository's deployments.
1010
required: true
11-
ref:
12-
description: Deployed git ref (branch or tag; long refs/... form is shortened).
11+
repo:
12+
description: Repository in owner/name form whose deployments are rendered.
1313
required: true
14-
sha:
15-
description: Deployed commit SHA.
16-
required: true
17-
docker-image:
18-
description: Deployed docker image.
19-
required: false
20-
default: ""
21-
timestamp:
22-
description: ISO8601 time of the status change; defaults to now when empty.
23-
required: false
24-
default: ""
25-
url:
26-
description: Link to the deployment pipeline.
27-
required: false
28-
default: ""
2914
repo-url:
3015
description: Repository web URL used for commit links.
3116
required: true
32-
slack-token:
33-
description: Slack bot token (canvases:read, canvases:write, channels:read scopes).
34-
required: true
35-
channel-id:
36-
description: Slack channel whose canvas is updated.
37-
required: true
3817

3918
runs:
4019
using: composite
4120
steps:
4221
- name: Update canvas
4322
shell: bash
4423
env:
45-
ENVIRONMENT: ${{ inputs.environment }}
46-
STATE: ${{ inputs.state }}
47-
REF: ${{ inputs.ref }}
48-
SHA: ${{ inputs.sha }}
49-
DOCKER_IMAGE: ${{ inputs.docker-image }}
50-
TIMESTAMP: ${{ inputs.timestamp }}
51-
TARGET_URL: ${{ inputs.url }}
24+
SLACK_WEBHOOK_URL: ${{ inputs.webhook-url }}
25+
GITHUB_TOKEN: ${{ inputs.github-token }}
26+
REPO: ${{ inputs.repo }}
5227
REPO_URL: ${{ inputs.repo-url }}
53-
SLACK_BOT_TOKEN: ${{ inputs.slack-token }}
54-
SLACK_CHANNEL_ID: ${{ inputs.channel-id }}
5528
run: bash "${GITHUB_ACTION_PATH}/update-canvas.sh"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""Mock GitHub API + Slack workflow webhook for update-canvas.sh flow tests.
3+
4+
Scenario is selected with the MOCK_SCENARIO env var:
5+
normal - dev's newest deployment succeeded; prd's newest failed, an older one succeeded.
6+
degraded - dev's newest deployment has no statuses yet, the next one failed, none
7+
succeeded; prd has no deployments.
8+
empty - no deployments in either environment.
9+
gh_error - the deployments endpoint returns HTTP 500.
10+
webhook_fail - deployment data as in normal, but the webhook responds HTTP 500.
11+
Every request is appended to MOCK_LOG as one JSON line: {"path": ..., "body": ...},
12+
except GET /ping (a readiness probe) which is answered 200 and not logged.
13+
"""
14+
import json
15+
import os
16+
import re
17+
import sys
18+
from http.server import BaseHTTPRequestHandler, HTTPServer
19+
from urllib.parse import urlparse, parse_qs
20+
21+
SCENARIO = os.environ.get("MOCK_SCENARIO", "normal")
22+
LOG_PATH = os.environ["MOCK_LOG"]
23+
24+
DEPLOYMENTS = {
25+
"dev": [
26+
{
27+
"id": 2,
28+
"ref": "refs/heads/main",
29+
"sha": "aaaabbbbccccddddeeeeffff0000111122223333",
30+
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:aaaabbbbccccddddeeeeffff0000111122223333"},
31+
},
32+
],
33+
"prd": [
34+
{
35+
"id": 12,
36+
"ref": "refs/tags/v0.9.3",
37+
"sha": "9999888877776666555544443333222211110000",
38+
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"},
39+
},
40+
{
41+
"id": 11,
42+
"ref": "refs/tags/v0.9.2",
43+
"sha": "1234567890abcdef1234567890abcdef12345678",
44+
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:59791d9"},
45+
},
46+
],
47+
}
48+
49+
DEPLOYMENTS_DEGRADED = {
50+
"dev": [
51+
{
52+
"id": 21,
53+
"ref": "refs/heads/feat/hotfix",
54+
"sha": "ccccddddeeeeffff0000111122223333aaaabbbb",
55+
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:hotfix"},
56+
},
57+
{
58+
"id": 22,
59+
"ref": "refs/heads/main",
60+
"sha": "bbbbccccddddeeeeffff0000111122223333aaaa",
61+
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"},
62+
},
63+
],
64+
"prd": [],
65+
}
66+
67+
STATUSES = {
68+
2: {"state": "success", "created_at": "2026-07-18T10:00:00Z"},
69+
12: {"state": "failure", "created_at": "2026-07-19T08:30:00Z"},
70+
11: {"state": "success", "created_at": "2026-07-15T17:11:32Z"},
71+
# 21 deliberately absent: a just-created deployment with no statuses yet.
72+
22: {"state": "failure", "created_at": "2026-07-19T09:00:00Z"},
73+
}
74+
75+
76+
class Handler(BaseHTTPRequestHandler):
77+
def _respond(self, payload, status=200):
78+
body = json.dumps(payload).encode()
79+
self.send_response(status)
80+
self.send_header("Content-Type", "application/json")
81+
self.send_header("Content-Length", str(len(body)))
82+
self.end_headers()
83+
self.wfile.write(body)
84+
85+
def _record(self, path, body):
86+
with open(LOG_PATH, "a", encoding="utf-8") as f:
87+
f.write(json.dumps({"path": path, "body": body}, ensure_ascii=False) + "\n")
88+
89+
def do_GET(self):
90+
parsed = urlparse(self.path)
91+
if parsed.path == "/ping":
92+
self._respond({"ok": True})
93+
return
94+
self._record(parsed.path, self.path)
95+
if re.fullmatch(r"/repos/[^/]+/[^/]+/deployments", parsed.path):
96+
environment = parse_qs(parsed.query).get("environment", [""])[0]
97+
if SCENARIO == "gh_error":
98+
self._respond({"message": "boom"}, status=500)
99+
elif SCENARIO == "empty":
100+
self._respond([])
101+
elif SCENARIO == "degraded":
102+
self._respond(DEPLOYMENTS_DEGRADED.get(environment, []))
103+
else:
104+
self._respond(DEPLOYMENTS.get(environment, []))
105+
return
106+
match = re.fullmatch(r"/repos/[^/]+/[^/]+/deployments/(\d+)/statuses", parsed.path)
107+
if match:
108+
status = STATUSES.get(int(match.group(1)))
109+
self._respond([status] if status else [])
110+
return
111+
self._respond({"message": "not found"}, status=404)
112+
113+
def do_POST(self):
114+
length = int(self.headers.get("Content-Length", 0))
115+
body = json.loads(self.rfile.read(length) or b"{}")
116+
parsed = urlparse(self.path)
117+
self._record(parsed.path, body)
118+
if parsed.path == "/webhook":
119+
if SCENARIO == "webhook_fail":
120+
self._respond({"error": "trigger_failed"}, status=500)
121+
else:
122+
self._respond({"ok": True})
123+
return
124+
self._respond({"message": "not found"}, status=404)
125+
126+
def log_message(self, *args):
127+
pass
128+
129+
130+
if __name__ == "__main__":
131+
HTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()

.github/actions/slack-canvas-status/test/mock_slack.py

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)