Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/actions/slack-canvas-status/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Slack canvas deploy status
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.

inputs:
webhook-url:
description: Slack Workflow Builder webhook URL (a secret — anyone holding it can write the canvas).
required: true
github-token:
description: Token with read access to this repository's deployments.
required: true
repo:
description: Repository in owner/name form whose deployments are rendered.
required: true
repo-url:
description: Repository web URL used for commit links.
required: true

runs:
using: composite
steps:
- name: Update canvas
shell: bash
env:
SLACK_WEBHOOK_URL: ${{ inputs.webhook-url }}
GITHUB_TOKEN: ${{ inputs.github-token }}
REPO: ${{ inputs.repo }}
REPO_URL: ${{ inputs.repo-url }}
run: bash "${GITHUB_ACTION_PATH}/update-canvas.sh"
143 changes: 143 additions & 0 deletions .github/actions/slack-canvas-status/test/mock_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Mock GitHub API + Slack workflow webhook for update-canvas.sh flow tests.

Scenario is selected with the MOCK_SCENARIO env var:
normal - dev's newest deployment succeeded; prd's newest failed, an older one succeeded.
degraded - dev's newest deployment has no statuses yet, the next one failed, none
succeeded; prd has no deployments.
statusless - dev's only deployment has no statuses; prd has no deployments.
empty - no deployments in either environment.
gh_error - the deployments endpoint returns HTTP 500.
gh_status_error - deployment data as in normal, but the statuses endpoint returns HTTP 500.
webhook_fail - deployment data as in normal, but the webhook responds HTTP 500.
Every request is appended to MOCK_LOG as one JSON line: {"path": ..., "body": ...},
except GET /ping (a readiness probe) which is answered 200 and not logged.
"""
import json
import os
import re
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

SCENARIO = os.environ.get("MOCK_SCENARIO", "normal")
LOG_PATH = os.environ["MOCK_LOG"]

DEPLOYMENTS = {
"dev": [
{
"id": 2,
"ref": "refs/heads/main",
"sha": "aaaabbbbccccddddeeeeffff0000111122223333",
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:aaaabbbbccccddddeeeeffff0000111122223333"},
},
],
"prd": [
{
"id": 12,
"ref": "refs/tags/v0.9.3",
"sha": "9999888877776666555544443333222211110000",
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"},
},
{
"id": 11,
"ref": "refs/tags/v0.9.2",
"sha": "1234567890abcdef1234567890abcdef12345678",
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:59791d9"},
},
],
}

DEPLOYMENTS_DEGRADED = {
"dev": [
{
"id": 21,
"ref": "refs/heads/feat/hotfix",
"sha": "ccccddddeeeeffff0000111122223333aaaabbbb",
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:hotfix"},
},
{
"id": 22,
"ref": "refs/heads/main",
"sha": "bbbbccccddddeeeeffff0000111122223333aaaa",
"payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"},
},
],
"prd": [],
}

DEPLOYMENTS_STATUSLESS = {
"dev": [DEPLOYMENTS_DEGRADED["dev"][0]],
"prd": [],
}

STATUSES = {
2: {"state": "success", "created_at": "2026-07-18T10:00:00Z"},
12: {"state": "failure", "created_at": "2026-07-19T08:30:00Z"},
11: {"state": "success", "created_at": "2026-07-15T17:11:32Z"},
# 21 deliberately absent: a just-created deployment with no statuses yet.
22: {"state": "failure", "created_at": "2026-07-19T09:00:00Z"},
}


class Handler(BaseHTTPRequestHandler):
def _respond(self, payload, status=200):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def _record(self, path, body):
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps({"path": path, "body": body}, ensure_ascii=False) + "\n")

def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/ping":
self._respond({"ok": True})
return
self._record(parsed.path, self.path)
if re.fullmatch(r"/repos/[^/]+/[^/]+/deployments", parsed.path):
environment = parse_qs(parsed.query).get("environment", [""])[0]
if SCENARIO == "gh_error":
self._respond({"message": "boom"}, status=500)
elif SCENARIO == "empty":
self._respond([])
elif SCENARIO == "degraded":
self._respond(DEPLOYMENTS_DEGRADED.get(environment, []))
elif SCENARIO == "statusless":
self._respond(DEPLOYMENTS_STATUSLESS.get(environment, []))
else:
self._respond(DEPLOYMENTS.get(environment, []))
return
match = re.fullmatch(r"/repos/[^/]+/[^/]+/deployments/(\d+)/statuses", parsed.path)
if match:
if SCENARIO == "gh_status_error":
self._respond({"message": "status boom"}, status=500)
return
status = STATUSES.get(int(match.group(1)))
self._respond([status] if status else [])
return
self._respond({"message": "not found"}, status=404)

def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
parsed = urlparse(self.path)
self._record(parsed.path, body)
if parsed.path == "/webhook":
if SCENARIO == "webhook_fail":
self._respond({"error": "trigger_failed"}, status=500)
else:
self._respond({"ok": True})
return
self._respond({"message": "not found"}, status=404)

def log_message(self, *args):
pass


if __name__ == "__main__":
HTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()
133 changes: 133 additions & 0 deletions .github/actions/slack-canvas-status/test/test-flow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# End-to-end tests for update-canvas.sh against a mock GitHub API + Slack webhook.
set -euo pipefail
cd "$(dirname "$0")"

PORT=18465
PYTHON="$(command -v python || command -v python3)"

export GITHUB_API_BASE="http://127.0.0.1:${PORT}"
export SLACK_WEBHOOK_URL="http://127.0.0.1:${PORT}/webhook"
export REPO=decentraland/Pulse
export REPO_URL=https://github.com/decentraland/Pulse
export GITHUB_TOKEN=test-token

MOCK_LOG="$(mktemp)"
ERR_FILE="$(mktemp)"
SERVER_PID=""

cleanup() {
[[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true
rm -f "$MOCK_LOG" "$ERR_FILE"
}
trap cleanup EXIT

fail() { echo "FAIL: $1"; exit 1; }

payload_field() {
jq -r --arg k "$1" 'select(.path == "/webhook") | .body[$k]' "$MOCK_LOG"
}

start_server() {
local scenario="$1" i
: > "$MOCK_LOG"
MOCK_SCENARIO="$scenario" MOCK_LOG="$MOCK_LOG" "$PYTHON" mock_api.py "$PORT" &
SERVER_PID=$!
# Bounded readiness poll: the || true keeps a failed probe from tripping set -e,
# and a never-ready server just falls through so the first real request fails loudly.
for i in $(seq 1 50); do curl -s "${GITHUB_API_BASE}/ping" >/dev/null 2>&1 && break || true; sleep 0.1; done
}

stop_server() {
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
}

# Scenario 1: normal — dev's newest deployment succeeded; prd's newest failed
# while an older one succeeded, so prd running and last-deploy differ.
start_server normal
bash ../update-canvas.sh
stop_server

[[ "$(payload_field dev_running)" == 'main @ aaaabbbb · pulse-server:aaaabbbbcccc… · since 2026-07-18 10:00 UTC' ]] \
|| fail "dev_running mismatch: $(payload_field dev_running)"
[[ "$(payload_field dev_last_deploy)" == '✅ success · main @ aaaabbbb · 2026-07-18 10:00 UTC' ]] \
|| fail "dev_last_deploy mismatch: $(payload_field dev_last_deploy)"
[[ "$(payload_field prd_running)" == 'v0.9.2 @ 12345678 · pulse-server:59791d9 · since 2026-07-15 17:11 UTC' ]] \
|| fail "prd_running mismatch: $(payload_field prd_running)"
[[ "$(payload_field prd_last_deploy)" == '❌ failure · v0.9.3 @ 99998888 · 2026-07-19 08:30 UTC' ]] \
|| fail "prd_last_deploy mismatch: $(payload_field prd_last_deploy)"
[[ "$(payload_field dev_running_commit_url)" == 'https://github.com/decentraland/Pulse/commit/aaaabbbbccccddddeeeeffff0000111122223333' ]] \
|| fail "dev_running_commit_url mismatch: $(payload_field dev_running_commit_url)"
[[ "$(payload_field prd_running_commit_url)" == 'https://github.com/decentraland/Pulse/commit/1234567890abcdef1234567890abcdef12345678' ]] \
|| fail "prd_running_commit_url mismatch: $(payload_field prd_running_commit_url)"
[[ "$(payload_field prd_last_deploy_commit_url)" == 'https://github.com/decentraland/Pulse/commit/9999888877776666555544443333222211110000' ]] \
|| fail "prd_last_deploy_commit_url mismatch: $(payload_field prd_last_deploy_commit_url)"
[[ "$(payload_field updated_at)" == *" UTC" ]] || fail "updated_at missing/malformed: $(payload_field updated_at)"
grep -q 'environment=dev' "$MOCK_LOG" || fail "expected dev deployments query"
grep -q 'environment=prd' "$MOCK_LOG" || fail "expected prd deployments query"
grep -q 'task=dcl/container-deployment' "$MOCK_LOG" || fail "expected task filter in deployments query"

# Scenario 2: no deployments at all -> placeholder lines, empty commit urls.
start_server empty
bash ../update-canvas.sh
stop_server
[[ "$(payload_field dev_running)" == "—" ]] || fail "expected placeholder dev_running, got: $(payload_field dev_running)"
[[ "$(payload_field prd_last_deploy)" == "—" ]] || fail "expected placeholder prd_last_deploy, got: $(payload_field prd_last_deploy)"
[[ "$(payload_field dev_running_commit_url)" == "" ]] || fail "expected empty dev_running_commit_url"

# Scenario 3: GitHub API errors -> loud non-zero exit with the response body,
# webhook never called.
start_server gh_error
if bash ../update-canvas.sh 2>"$ERR_FILE"; then
fail "expected non-zero exit when the GitHub API fails"
fi
stop_server
grep -q '500' "$ERR_FILE" || fail "expected HTTP 500 in stderr, got: $(cat "$ERR_FILE")"
grep -q 'boom' "$ERR_FILE" || fail "expected error body in stderr, got: $(cat "$ERR_FILE")"
! grep -q '"/webhook"' "$MOCK_LOG" || fail "webhook must not be called when the GitHub API fails"

# Scenario 4: webhook rejects -> loud non-zero exit with the response body.
start_server webhook_fail
if bash ../update-canvas.sh 2>"$ERR_FILE"; then
fail "expected non-zero exit when the webhook rejects"
fi
stop_server
grep -q '"/webhook"' "$MOCK_LOG" || fail "expected webhook request to be recorded"
grep -q '500' "$ERR_FILE" || fail "expected HTTP 500 in stderr, got: $(cat "$ERR_FILE")"
grep -q 'trigger_failed' "$ERR_FILE" || fail "expected webhook body in stderr, got: $(cat "$ERR_FILE")"

# Scenario 5: the statuses endpoint errors -> loud non-zero exit with the body,
# webhook never called (pins latest_status's explicit error handler).
start_server gh_status_error
if bash ../update-canvas.sh 2>"$ERR_FILE"; then
fail "expected non-zero exit when the statuses endpoint fails"
fi
stop_server
grep -q 'status boom' "$ERR_FILE" || fail "expected statuses error body in stderr, got: $(cat "$ERR_FILE")"
! grep -q '"/webhook"' "$MOCK_LOG" || fail "webhook must not be called when the statuses endpoint fails"

# Scenario 6: every deployment is statusless -> both placeholders stay "—"
# (no contradictory "no recent success" next to a last-deploy of "—").
start_server statusless
bash ../update-canvas.sh
stop_server
[[ "$(payload_field dev_running)" == "—" ]] || fail "statusless dev_running mismatch: $(payload_field dev_running)"
[[ "$(payload_field dev_last_deploy)" == "—" ]] || fail "statusless dev_last_deploy mismatch: $(payload_field dev_last_deploy)"

# Scenario 7: degraded — dev's newest deployment has no statuses yet (skipped for
# the last-deploy line), the next one failed, none succeeded; prd is empty.
start_server degraded
bash ../update-canvas.sh
stop_server
[[ "$(payload_field dev_last_deploy)" == '❌ failure · main @ bbbbcccc · 2026-07-19 09:00 UTC' ]] \
|| fail "degraded dev_last_deploy mismatch: $(payload_field dev_last_deploy)"
[[ "$(payload_field dev_running)" == '⚠️ no recent success (last 2 deploys)' ]] \
|| fail "degraded dev_running mismatch: $(payload_field dev_running)"
[[ "$(payload_field dev_running_commit_url)" == "" ]] || fail "expected empty degraded dev_running_commit_url"
[[ "$(payload_field dev_last_deploy_commit_url)" == 'https://github.com/decentraland/Pulse/commit/bbbbccccddddeeeeffff0000111122223333aaaa' ]] \
|| fail "degraded dev_last_deploy_commit_url mismatch: $(payload_field dev_last_deploy_commit_url)"
[[ "$(payload_field prd_running)" == "—" ]] || fail "expected placeholder prd_running in degraded scenario"

echo "flow tests passed"
40 changes: 40 additions & 0 deletions .github/actions/slack-canvas-status/test/test-render.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Unit tests for the line rendering helpers in update-canvas.sh.
set -euo pipefail
cd "$(dirname "$0")"

source ../update-canvas.sh

fail() { printf 'FAIL: %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; exit 1; }
assert_eq() { [[ "$2" == "$3" ]] || fail "$1" "$2" "$3"; }

SHA=aaaabbbbccccddddeeeeffff0000111122223333
IMAGE="quay.io/decentraland/pulse-server:${SHA}"

assert_eq "running line" \
'main @ aaaabbbb · pulse-server:aaaabbbbcccc… · since 2026-07-17 14:13 UTC' \
"$(render_running_line refs/heads/main "$SHA" "$IMAGE" 2026-07-17T14:13:53Z)"

assert_eq "running line omits empty image" \
'v0.9.2 @ aaaabbbb · since 2026-07-17 14:13 UTC' \
"$(render_running_line refs/tags/v0.9.2 "$SHA" "" 2026-07-17T14:13:53Z)"

assert_eq "last deploy success" \
'✅ success · main @ aaaabbbb · 2026-07-17 14:13 UTC' \
"$(render_last_deploy_line success refs/heads/main "$SHA" 2026-07-17T14:13:53Z)"

assert_eq "last deploy failure" \
'❌ failure · v0.9.3 @ aaaabbbb · 2026-07-17 14:13 UTC' \
"$(render_last_deploy_line failure refs/tags/v0.9.3 "$SHA" 2026-07-17T14:13:53Z)"

assert_eq "error emoji" "❌" "$(state_emoji error)"
assert_eq "in_progress emoji" "🔄" "$(state_emoji in_progress)"
assert_eq "queued emoji" "🔄" "$(state_emoji queued)"
assert_eq "unknown state emoji" "❔" "$(state_emoji superseded)"

assert_eq "short tag not truncated" "pulse-server:short" "$(short_image quay.io/decentraland/pulse-server:short)"
assert_eq "untagged image passthrough" "pulse-server" "$(short_image quay.io/decentraland/pulse-server)"
assert_eq "branch ref shortened" "main" "$(short_ref refs/heads/main)"
assert_eq "plain ref unchanged" "main" "$(short_ref main)"

echo "render tests passed"
Loading
Loading