Skip to content
Closed
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,31 @@ jobs:
| `include_run_url` | no | `true` | Include the GitHub Actions run URL. |
| `fail_on_error` | no | `false` | Fail the workflow if the Stoat request fails. |
| `timeout_ms` | no | `10000` | HTTP timeout in milliseconds. |
| `dry_run` | no | `false` | Skip the HTTP request and report `status=dry_run` instead of sending. |

## Outputs

| Nom | Valeurs possibles | Description |
| --- | --- | --- |
| `sent` | `true` / `false` | `true` si le webhook a été envoyé avec succès, sinon `false`. |
| `status` | `sent` / `failed` / `skipped` / `dry_run` | Statut final de la notification. |
| `error` | chaîne courte / vide | Message d'erreur court (chaîne vide en cas de succès). La valeur de `webhook_url` est expurgée avant publication. |
| `attempts` | entier ≥ 0 | Nombre de tentatives HTTP effectuées (`0` si erreur de configuration, `dry_run` ou résolution sans correspondance ; `1` ou `2` selon que le retry 429 a été déclenché). |

Example consumer workflow:

```yaml
- name: Notify Stoat
id: notify
uses: systm-d/stoat-github-notify@v1
with:
webhook_url: ${{ secrets.STOAT_WEBHOOK_URL }}
event: ci_failed

- name: Record delivery failure
if: steps.notify.outputs.status == 'failed'
run: echo "Stoat notification failed after ${{ steps.notify.outputs.attempts }} attempt(s): ${{ steps.notify.outputs.error }}"
```

## Event types

Expand Down
14 changes: 14 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ inputs:
description: "HTTP timeout in milliseconds"
required: false
default: "10000"
dry_run:
description: "Skip the HTTP request and report status=dry_run instead of sending the notification"
required: false
default: "false"

outputs:
sent:
description: "'true' si le webhook a été envoyé avec succès, sinon 'false'"
status:
description: "Statut de la notification : sent | failed | skipped | dry_run"
error:
description: "Message d'erreur court, chaîne vide en cas de succès"
attempts:
description: "Nombre de tentatives HTTP effectuées (0 si erreur de configuration)"

runs:
using: "node20"
Expand Down
1 change: 1 addition & 0 deletions dist/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function readConfig(env = process.env) {
includeRunUrl: readBooleanInput(env, "include_run_url", true),
failOnError: readBooleanInput(env, "fail_on_error", false),
timeoutMs: readPositiveInteger(readInput(env, "timeout_ms") || "10000", "timeout_ms"),
dryRun: readBooleanInput(env, "dry_run", false),
};
}
export function maskSecret(value) {
Expand Down
49 changes: 44 additions & 5 deletions dist/index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,67 @@
import { appendFileSync } from "node:fs";
import { maskSecret, readConfig } from "./config.js";
import { readGitHubContext } from "./github-context.js";
import { buildPayload } from "./message-builder.js";
import { sendStoatWebhook } from "./stoat-client.js";
import { sendStoatWebhook, WebhookError } from "./stoat-client.js";
export async function run() {
let failOnError = false;
let webhookUrl = "";
try {
const config = readConfig();
failOnError = config.failOnError;
webhookUrl = config.webhookUrl;
maskSecret(config.webhookUrl);
if (config.dryRun) {
writeOutputs({ sent: false, status: "dry_run", error: "", attempts: 0 });
info("Stoat notification skipped (dry_run).");
return;
}
const context = readGitHubContext();
const payload = buildPayload(config, context);
await sendStoatWebhook(payload, config.webhookUrl, {
if (payload === null) {
writeOutputs({ sent: false, status: "skipped", error: "", attempts: 0 });
info(`Stoat notification skipped: no template matches event '${context.eventName}'.`);
return;
}
const { attempts } = await sendStoatWebhook(payload, config.webhookUrl, {
timeoutMs: config.timeoutMs,
});
writeOutputs({ sent: true, status: "sent", error: "", attempts });
info("Stoat notification sent.");
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
const attempts = error instanceof WebhookError ? error.attempts : 0;
const sanitized = sanitizeError(message, webhookUrl);
writeOutputs({ sent: false, status: "failed", error: sanitized, attempts });
if (failOnError || isConfigurationError(message)) {
setFailed(message);
setFailed(sanitized);
}
else {
warning(message);
warning(sanitized);
}
}
}
function writeOutputs(record) {
setOutput("sent", record.sent ? "true" : "false");
setOutput("status", record.status);
setOutput("error", record.error);
setOutput("attempts", String(record.attempts));
}
export function setOutput(name, value) {
const path = process.env.GITHUB_OUTPUT;
if (!path) {
return;
}
appendFileSync(path, `${name}=${value}\n`);
}
export function sanitizeError(message, webhookUrl) {
let sanitized = message;
if (webhookUrl) {
sanitized = sanitized.split(webhookUrl).join("[url]");
}
return sanitized.replace(/https?:\/\/\S+/g, "[url]");
}
function isConfigurationError(message) {
return (message.startsWith("Missing required input") ||
message.startsWith("Invalid ") ||
Expand All @@ -40,4 +77,6 @@ function setFailed(message) {
console.log(`::error::${message}`);
process.exitCode = 1;
}
void run();
if (!process.env.VITEST) {
void run();
}
10 changes: 6 additions & 4 deletions dist/message-builder.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { buildRunUrl } from "./github-context.js";
export function buildPayload(config, context) {
const event = resolveEvent(config.event, context);
if (event === null) {
return null;
}
const runUrl = buildRunUrl(context);
const title = config.title || buildDefaultTitle(event, context);
const content = config.message || buildDefaultContent(title, context, runUrl);
Expand All @@ -11,7 +14,7 @@ export function buildPayload(config, context) {
embeds: [
{
title,
description: buildDescription(config, context, runUrl),
description: buildDescription(config, event, context, runUrl),
},
],
};
Expand All @@ -33,7 +36,7 @@ export function resolveEvent(event, context) {
if (context.eventName === "push") {
return "push";
}
return "custom";
return null;
}
export function buildDefaultTitle(event, context) {
switch (event) {
Expand All @@ -59,8 +62,7 @@ function buildDefaultContent(title, context, runUrl) {
const suffix = runUrl ? `\n${runUrl}` : "";
return `${title} on ${context.repository}${suffix}`;
}
function buildDescription(config, context, runUrl) {
const event = resolveEvent(config.event, context);
function buildDescription(config, event, context, runUrl) {
const lines = buildEventLines(event, context);
lines.push(`Event: ${context.eventName}`);
if (context.workflow) {
Expand Down
15 changes: 12 additions & 3 deletions dist/stoat-client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export class WebhookError extends Error {
attempts;
constructor(message, attempts) {
super(message);
this.attempts = attempts;
this.name = "WebhookError";
}
}
export async function sendStoatWebhook(payload, webhookUrl, options) {
const fetchFn = options.fetchFn || fetch;
const sleepFn = options.sleepFn || sleep;
Expand All @@ -7,13 +15,14 @@ export async function sendStoatWebhook(payload, webhookUrl, options) {
await sleepFn(retryAfter);
const retryResponse = await postPayload(fetchFn, webhookUrl, payload, options.timeoutMs);
if (!retryResponse.ok) {
throw new Error(await buildFailureMessage(retryResponse));
throw new WebhookError(await buildFailureMessage(retryResponse), 2);
}
return;
return { attempts: 2 };
}
if (!response.ok) {
throw new Error(await buildFailureMessage(response));
throw new WebhookError(await buildFailureMessage(response), 1);
}
return { attempts: 1 };
}
async function postPayload(fetchFn, webhookUrl, payload, timeoutMs) {
const controller = new AbortController();
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ActionConfig {
includeRunUrl: boolean;
failOnError: boolean;
timeoutMs: number;
dryRun: boolean;
}

const eventTypes = new Set<EventType>([
Expand Down Expand Up @@ -53,6 +54,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): ActionConfig {
includeRunUrl: readBooleanInput(env, "include_run_url", true),
failOnError: readBooleanInput(env, "fail_on_error", false),
timeoutMs: readPositiveInteger(readInput(env, "timeout_ms") || "10000", "timeout_ms"),
dryRun: readBooleanInput(env, "dry_run", false),
};
}

Expand Down
66 changes: 61 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,89 @@
import { appendFileSync } from "node:fs";
import { maskSecret, readConfig } from "./config.js";
import { readGitHubContext } from "./github-context.js";
import { buildPayload } from "./message-builder.js";
import { sendStoatWebhook } from "./stoat-client.js";
import { sendStoatWebhook, WebhookError } from "./stoat-client.js";

export async function run(): Promise<void> {
let failOnError = false;
let webhookUrl = "";

try {
const config = readConfig();
failOnError = config.failOnError;
webhookUrl = config.webhookUrl;
maskSecret(config.webhookUrl);

if (config.dryRun) {
writeOutputs({ sent: false, status: "dry_run", error: "", attempts: 0 });
info("Stoat notification skipped (dry_run).");
return;
}

const context = readGitHubContext();
const payload = buildPayload(config, context);

await sendStoatWebhook(payload, config.webhookUrl, {
if (payload === null) {
writeOutputs({ sent: false, status: "skipped", error: "", attempts: 0 });
info(`Stoat notification skipped: no template matches event '${context.eventName}'.`);
return;
}

const { attempts } = await sendStoatWebhook(payload, config.webhookUrl, {
timeoutMs: config.timeoutMs,
});

writeOutputs({ sent: true, status: "sent", error: "", attempts });
info("Stoat notification sent.");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const attempts = error instanceof WebhookError ? error.attempts : 0;
const sanitized = sanitizeError(message, webhookUrl);

writeOutputs({ sent: false, status: "failed", error: sanitized, attempts });

if (failOnError || isConfigurationError(message)) {
setFailed(message);
setFailed(sanitized);
} else {
warning(message);
warning(sanitized);
}
}
}

interface OutputRecord {
sent: boolean;
status: "sent" | "failed" | "skipped" | "dry_run";
error: string;
attempts: number;
}

function writeOutputs(record: OutputRecord): void {
setOutput("sent", record.sent ? "true" : "false");
setOutput("status", record.status);
setOutput("error", record.error);
setOutput("attempts", String(record.attempts));
}

export function setOutput(name: string, value: string): void {
const path = process.env.GITHUB_OUTPUT;

if (!path) {
return;
}

appendFileSync(path, `${name}=${value}\n`);
}

export function sanitizeError(message: string, webhookUrl: string): string {
let sanitized = message;

if (webhookUrl) {
sanitized = sanitized.split(webhookUrl).join("[url]");
}

return sanitized.replace(/https?:\/\/\S+/g, "[url]");
}

function isConfigurationError(message: string): boolean {
return (
message.startsWith("Missing required input") ||
Expand All @@ -51,4 +105,6 @@ function setFailed(message: string): void {
process.exitCode = 1;
}

void run();
if (!process.env.VITEST) {
void run();
}
16 changes: 10 additions & 6 deletions src/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ export interface StoatPayload {
embeds: StoatEmbed[];
}

export function buildPayload(config: ActionConfig, context: GitHubContext): StoatPayload {
export function buildPayload(config: ActionConfig, context: GitHubContext): StoatPayload | null {
const event = resolveEvent(config.event, context);

if (event === null) {
return null;
}

const runUrl = buildRunUrl(context);
const title = config.title || buildDefaultTitle(event, context);
const content = config.message || buildDefaultContent(title, context, runUrl);
Expand All @@ -27,13 +32,13 @@ export function buildPayload(config: ActionConfig, context: GitHubContext): Stoa
embeds: [
{
title,
description: buildDescription(config, context, runUrl),
description: buildDescription(config, event, context, runUrl),
},
],
};
}

export function resolveEvent(event: EventType, context: GitHubContext): EventType {
export function resolveEvent(event: EventType, context: GitHubContext): EventType | null {
if (event !== "auto") {
return event;
}
Expand All @@ -56,7 +61,7 @@ export function resolveEvent(event: EventType, context: GitHubContext): EventTyp
return "push";
}

return "custom";
return null;
}

export function buildDefaultTitle(event: EventType, context: GitHubContext): string {
Expand Down Expand Up @@ -86,8 +91,7 @@ function buildDefaultContent(title: string, context: GitHubContext, runUrl: stri
return `${title} on ${context.repository}${suffix}`;
}

function buildDescription(config: ActionConfig, context: GitHubContext, runUrl: string): string {
const event = resolveEvent(config.event, context);
function buildDescription(config: ActionConfig, event: EventType, context: GitHubContext, runUrl: string): string {
const lines = buildEventLines(event, context);

lines.push(`Event: ${context.eventName}`);
Expand Down
Loading
Loading