Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 27 additions & 0 deletions .github/workflows/runPendingTimelockTXs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
timeout-minutes: 20
permissions:
contents: read # required to checkout the repository
actions: read # required to resolve this job's URL (jobs API) for the Slack deep-link

steps:
- name: Checkout repository
Expand Down Expand Up @@ -97,7 +98,19 @@ jobs:
PRIVATE_KEY_PRODUCTION: ${{ secrets.TIMELOCK_EXECUTOR_PRIVATE_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.TIMELOCK_SLACK_WEBHOOK_URL }}
MONGODB_URI: ${{ secrets.MONGODB_URI }}
GH_TOKEN: ${{ github.token }} # required by `gh api` to resolve the job's html_url
# Fallback link (run overview); upgraded to a deep job link below when the jobs API is reachable.
RUN_OVERVIEW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
##### Resolve a deep-link to THIS job so the executor's own Slack notifications
# (which fire on the happy path AND on per-operation failures) point straight at
# the failing job and its logs. github.job is the job key, not the numeric id the
# URL needs, so resolve the job's html_url via the jobs API; fall back to the run
# overview if it can't be read.
RESOLVED_URL=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
--jq '[.jobs[] | select(.name == "${{ github.job }}") | .html_url][0] // .jobs[0].html_url' 2>/dev/null || true)
export TIMELOCK_RUN_URL="${RESOLVED_URL:-$RUN_OVERVIEW_URL}"

##### Run the executor and tee stdout+stderr to execution.log on disk.
# The log file is read by the Slack failure step and uploaded as an artifact,
# so we deliberately do NOT push it through $GITHUB_OUTPUT (1 MB cap, template-injection risk).
Expand All @@ -113,7 +126,10 @@ jobs:
env:
# All values flow via env to avoid ${{ }} expansion inside shell/Python (template-injection risk).
SLACK_WEBHOOK_URL: ${{ secrets.TIMELOCK_SLACK_WEBHOOK_URL }}
# Fallback link (run overview); the bash body below upgrades this to a deep
# link to THIS job (and its failing step) when the jobs API is reachable.
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_TOKEN: ${{ github.token }} # required by `gh api` to resolve the job's html_url
TRIGGER: ${{ github.event_name }}
run: |
##### Fallback notification for when the script crashes before its own SlackNotifier can fire.
Expand All @@ -128,6 +144,17 @@ jobs:
exit 0
fi

##### Deep-link straight to THIS job (…/runs/<run>/job/<job_id>) so the Slack
# link lands on the failing job (and its failing step) rather than the run
# overview. github.job is the job key, not the numeric id the URL needs, so
# resolve the job's html_url via the jobs API; keep the run-overview WORKFLOW_URL
# as the fallback if it can't be read.
RESOLVED_URL=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
--jq '[.jobs[] | select(.name == "${{ github.job }}") | .html_url][0] // .jobs[0].html_url' 2>/dev/null || true)
if [ -n "${RESOLVED_URL}" ]; then
export WORKFLOW_URL="${RESOLVED_URL}"
fi

##### Extract errors from the on-disk log. The log may not exist if an earlier step
# (e.g. dependency install or typechain) failed before the executor ran — handle that gracefully.
NETWORK_ERRORS=""
Expand Down
8 changes: 7 additions & 1 deletion script/deploy/safe/execute-pending-timelock-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,13 @@ const cmd = defineCommand({
if (notifyWebhook)
try {
new URL(notifyWebhook) // Validate webhook URL format
slackNotifier = new SlackNotifier(notifyWebhook)
// In CI the workflow exports a deep-link to the running job; when present
// it is surfaced in failure/summary notifications so on-call can jump
// straight to the failing logs. Absent for local runs.
slackNotifier = new SlackNotifier(
notifyWebhook,
process.env.TIMELOCK_RUN_URL
)
consola.info('📢 Slack notifications enabled')
} catch (error) {
consola.error('❌ Invalid Slack webhook URL provided')
Expand Down
111 changes: 111 additions & 0 deletions script/utils/slack-notifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Tests for SlackNotifier's CI-link and payload-safety behavior: the optional
* run URL must surface as a deep-link on failure/summary messages, and
* oversized error text must be clamped so Slack never rejects the blocks.
*/
import {
afterEach,
describe,
expect,
it,
mock,
// eslint-disable-next-line import/no-unresolved
} from 'bun:test'

import { SlackNotifier } from './slack-notifier'
import type { ISlackMessage } from './slack-notifier'

const WEBHOOK = 'https://hooks.slack.com/services/T000/B000/xxx'
const RUN_URL = 'https://github.com/lifinance/contracts/actions/runs/1/job/2'

interface ICapturedBlock {
type: string
text?: { type: string; text: string }
elements?: { type: string; text: string }[]
}

/**
* Stub global fetch so notifications are captured instead of sent. Returns the
* parsed Slack payload from the most recent call.
*/
function mockFetchCapturing(): () => ISlackMessage {
let lastBody = ''
global.fetch = mock(async (_url: string, init?: { body?: string }) => {
lastBody = init?.body ?? ''
return new Response('ok', { status: 200 })
}) as unknown as typeof fetch
return () => JSON.parse(lastBody) as ISlackMessage
}

const baseOp = {
id: '0xabc123' as `0x${string}`,
target: '0x1111111111111111111111111111111111111111' as `0x${string}`,
value: 0n,
data: '0x' as `0x${string}`,
functionName: 'batch',
}

const originalFetch = global.fetch

afterEach(() => {
global.fetch = originalFetch
mock.restore()
})

describe('SlackNotifier run-link', () => {
it('appends a "View workflow run" context block to failures when runUrl is set', async () => {
const getPayload = mockFetchCapturing()
await new SlackNotifier(WEBHOOK, RUN_URL).notifyOperationFailed({
network: 'tron',
operation: baseOp,
status: 'failed',
error: new Error('boom'),
})

const blocks = (getPayload().blocks ?? []) as unknown as ICapturedBlock[]
const ctx = blocks.find((b) => b.type === 'context')
expect(ctx).toBeDefined()
expect(ctx?.elements?.[0]?.text).toBe(`<${RUN_URL}|View workflow run>`)
})

it('omits the run-link block when no runUrl is configured', async () => {
const getPayload = mockFetchCapturing()
await new SlackNotifier(WEBHOOK).notifyOperationFailed({
network: 'tron',
operation: baseOp,
status: 'failed',
error: new Error('boom'),
})

const blocks = (getPayload().blocks ?? []) as unknown as ICapturedBlock[]
expect(blocks.some((b) => b.type === 'context')).toBe(false)
})

it('adds the run-link to batch summaries as well', async () => {
const getPayload = mockFetchCapturing()
await new SlackNotifier(WEBHOOK, RUN_URL).notifyBatchSummary([
{ network: 'tron', success: false, error: new Error('nope') },
])

const blocks = (getPayload().blocks ?? []) as unknown as ICapturedBlock[]
expect(blocks.some((b) => b.type === 'context')).toBe(true)
})
})

describe('SlackNotifier payload safety', () => {
it('clamps an oversized error message below the Slack 3000-char block limit', async () => {
const getPayload = mockFetchCapturing()
const huge = 'x'.repeat(8000)
await new SlackNotifier(WEBHOOK).notifyOperationFailed({
network: 'tron',
operation: baseOp,
status: 'failed',
error: new Error(huge),
})

const blocks = (getPayload().blocks ?? []) as unknown as ICapturedBlock[]
const errorBlock = blocks.find((b) => b.text?.text?.includes('*Error:*'))
expect(errorBlock).toBeDefined()
expect(errorBlock?.text?.text.length ?? 0).toBeLessThanOrEqual(3000)
})
})
61 changes: 54 additions & 7 deletions script/utils/slack-notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,27 @@ interface IProcessingStats {
duration?: number
}

// Slack rejects any single block text element longer than 3000 chars with
// `invalid_blocks`. Cap error/detail text below that so an oversized RPC error
// (e.g. a Tron broadcast failure that echoes the whole raw transaction) still
// posts instead of being dropped.
const SLACK_TEXT_LIMIT = 2900

export class SlackNotifier {
private webhookUrl: string
private startTime: Date
private runUrl?: string

public constructor(webhookUrl: string) {
/**
* @param webhookUrl - Slack incoming-webhook URL to post to.
* @param runUrl - Optional CI run/job URL; when set, failure and summary
* notifications include a "View workflow run" deep-link so an on-call
* engineer can jump straight to the failing job and its logs.
*/
public constructor(webhookUrl: string, runUrl?: string) {
this.webhookUrl = webhookUrl
this.startTime = new Date()
this.runUrl = runUrl
}

/**
Expand Down Expand Up @@ -269,6 +283,8 @@ export class SlackNotifier {
],
}

this.appendRunLink(message)

await this.sendNotificationWithRetry(message)
}

Expand Down Expand Up @@ -442,11 +458,13 @@ export class SlackNotifier {
type: 'section',
text: {
type: 'mrkdwn',
text: `*Failed Networks:*\n${failureDetails}`,
text: `*Failed Networks:*\n${this.truncateText(failureDetails)}`,
},
})
}

this.appendRunLink(message)

await this.sendNotificationWithRetry(message)
}

Expand Down Expand Up @@ -517,6 +535,8 @@ export class SlackNotifier {
},
})

this.appendRunLink(message)

await this.sendNotificationWithRetry(message)
}

Expand Down Expand Up @@ -564,6 +584,33 @@ export class SlackNotifier {
else return `${baseUrl}/tx/${txHash}`
}

/**
* Append a compact "View workflow run" deep-link to the message when a run
* URL was configured. No-op otherwise, so local/manual runs stay link-free.
*/
private appendRunLink(message: ISlackMessage): void {
if (!this.runUrl || !message.blocks) return

message.blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `<${this.runUrl}|View workflow run>`,
},
],
})
}

/**
* Clamp arbitrary text to Slack's per-block limit so an oversized payload
* cannot trigger an `invalid_blocks` rejection.
*/
private truncateText(text: string, max = SLACK_TEXT_LIMIT): string {
if (text.length <= max) return text
return `${text.slice(0, max - 1)}…`
}

/**
* Helper to truncate hash for display
*/
Expand All @@ -586,21 +633,21 @@ export class SlackNotifier {
private extractErrorMessage(error: unknown): string {
if (!error) return 'Unknown error'

if (typeof error === 'string') return error
if (typeof error === 'string') return this.truncateText(error)

const errorObj = error as Record<string, unknown>

if (errorObj.message && typeof errorObj.message === 'string')
return errorObj.message
return this.truncateText(errorObj.message)

if (errorObj.reason && typeof errorObj.reason === 'string')
return errorObj.reason
return this.truncateText(errorObj.reason)

if (errorObj.shortMessage && typeof errorObj.shortMessage === 'string')
return errorObj.shortMessage
return this.truncateText(errorObj.shortMessage)

if (errorObj.details && typeof errorObj.details === 'string')
return errorObj.details
return this.truncateText(errorObj.details)

return JSON.stringify(error).slice(0, 500)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Expand Down
Loading