intial state

This commit is contained in:
Jason Ross
2026-03-26 14:55:12 -05:00
commit f987f3f8c9
29 changed files with 1105 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Build And Release Templates
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- name: Build production image
run: podman build --target runtime -t ci-cd-email-templates:${{ github.sha }} .
- name: Render HTML templates
run: |
mkdir -p build/html
podman run --rm \
-e OUTPUT_DIR=/workspace/build/html \
-v "${GITHUB_WORKSPACE}/build/html:/workspace/build/html" \
ci-cd-email-templates:${{ github.sha }}
- name: Upload rendered templates artifact
uses: actions/upload-artifact@v4
with:
name: rendered-email-templates
path: build/html/*.html
if-no-files-found: error
release:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
steps:
- name: Download rendered templates
uses: actions/download-artifact@v4
with:
name: rendered-email-templates
path: release/html
- name: Upload templates to Cloudflare R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }}
CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }}
CF_R2_PREFIX: ${{ vars.CF_R2_PREFIX }}
run: |
ENDPOINT="https://${CF_R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
TARGET="s3://${CF_R2_BUCKET}"
if [ -n "${CF_R2_PREFIX}" ]; then
TARGET="${TARGET}/${CF_R2_PREFIX}"
fi
aws s3 sync release/html "${TARGET}" --endpoint-url "${ENDPOINT}" --delete
+4
View File
@@ -0,0 +1,4 @@
node_modules/
build/
.DS_Store
npm-debug.log*
+19
View File
@@ -0,0 +1,19 @@
FROM docker.io/library/node:24-bookworm AS builder
WORKDIR /workspace
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
FROM docker.io/library/node:24-bookworm-slim AS runtime
WORKDIR /workspace
ENV NODE_ENV=production
COPY package.json ./
COPY --from=builder /workspace/dist ./dist
CMD ["npm", "start"]
+75
View File
@@ -0,0 +1,75 @@
# CI/CD Email Templates
TSX-based email templates for CI/CD pipeline success and failure notifications. The package renders minified HTML for both templates, writes them to disk, and emits the rendered templates on stdout as JSON for pipeline automation.
## Placeholder
The application name placeholder is always `{{APP_NAME}}`.
Pipeline logic can either:
- replace `{{APP_NAME}}` in the uploaded HTML before sending, or
- set `APP_NAME` when running the renderer to emit app-specific HTML directly.
## Local usage
Build the package:
```bash
npm install
npm run build
```
Render the templates:
```bash
npm start
```
By default this writes:
- `build/html/pipeline-success.html`
- `build/html/pipeline-failure.html`
Override the application name or output directory with environment variables:
```bash
APP_NAME=my-service OUTPUT_DIR=./out npm start
```
## Podman lifecycle
Build and run through `podman-compose`:
```bash
podman-compose up --build
```
The rendered HTML files are written to `./build/html` on the host.
Override the rendered app name during compose runs:
```bash
APP_NAME=my-service podman-compose up --build
```
If `APP_NAME` is not set, the renderer falls back to `{{APP_NAME}}`.
## GitHub Actions release
The workflow at `.github/workflows/release.yml`:
1. builds the production container image with Podman
2. runs the renderer to produce HTML files
3. uploads the generated templates to a Cloudflare R2 bucket
Required GitHub secrets:
- `CF_R2_ACCESS_KEY_ID`
- `CF_R2_SECRET_ACCESS_KEY`
- `CF_R2_ACCOUNT_ID`
- `CF_R2_BUCKET`
Optional GitHub variable:
- `CF_R2_PREFIX`
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};
Vendored
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES, renderAllTemplates } from "./index.js";
async function main() {
const appName = process.env.APP_NAME?.trim() || APP_NAME_PLACEHOLDER;
const outputDir = resolve(process.cwd(), process.env.OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR);
const templates = renderAllTemplates({ appName });
await mkdir(outputDir, { recursive: true });
await Promise.all([
writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.success), templates.success, "utf8"),
writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.failure), templates.failure, "utf8")
]);
process.stdout.write(`${JSON.stringify({
appName,
outputDir,
templates
}, null, 2)}\n`);
}
await main();
+7
View File
@@ -0,0 +1,7 @@
export declare const APP_NAME_PLACEHOLDER = "{{APP_NAME}}";
export declare const DEFAULT_OUTPUT_DIR = "build/html";
export declare const TEMPLATE_FILE_NAMES: {
readonly success: "pipeline-success.html";
readonly failure: "pipeline-failure.html";
};
export type TemplateName = keyof typeof TEMPLATE_FILE_NAMES;
+6
View File
@@ -0,0 +1,6 @@
export const APP_NAME_PLACEHOLDER = "{{APP_NAME}}";
export const DEFAULT_OUTPUT_DIR = "build/html";
export const TEMPLATE_FILE_NAMES = {
success: "pipeline-success.html",
failure: "pipeline-failure.html"
};
+14
View File
@@ -0,0 +1,14 @@
export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js";
export type { TemplateName } from "./constants.js";
export type { FailureTemplateOptions } from "./templates/failure.js";
export type { SuccessTemplateOptions } from "./templates/success.js";
export { renderFailureTemplate } from "./templates/failure.js";
export { renderSuccessTemplate } from "./templates/success.js";
export interface RenderAllTemplatesOptions {
appName?: string;
}
export interface RenderedTemplates {
success: string;
failure: string;
}
export declare function renderAllTemplates({ appName }?: RenderAllTemplatesOptions): RenderedTemplates;
+12
View File
@@ -0,0 +1,12 @@
import { APP_NAME_PLACEHOLDER } from "./constants.js";
import { renderFailureTemplate } from "./templates/failure.js";
import { renderSuccessTemplate } from "./templates/success.js";
export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js";
export { renderFailureTemplate } from "./templates/failure.js";
export { renderSuccessTemplate } from "./templates/success.js";
export function renderAllTemplates({ appName = APP_NAME_PLACEHOLDER } = {}) {
return {
success: renderSuccessTemplate({ appName }),
failure: renderFailureTemplate({ appName })
};
}
+28
View File
@@ -0,0 +1,28 @@
export type StyleValue = string | Record<string, string | number | null | undefined>;
export type Child = HtmlNode | string | number | boolean | null | undefined | Child[];
export type Props = Record<string, unknown> & {
children?: Child[];
style?: StyleValue;
};
export type Component<P = Record<string, unknown>> = (props: P & {
children?: Child[];
}) => Child;
export interface HtmlElement {
type: string;
props: Props;
children: Child[];
}
export declare const Fragment: unique symbol;
type NodeType = string | typeof Fragment | Component;
export type HtmlNode = HtmlElement;
export declare function h(type: NodeType, props: Props | null, ...children: Child[]): Child;
export declare function renderEmail(node: Child): string;
declare global {
namespace JSX {
type Element = Child;
interface IntrinsicElements {
[elementName: string]: Record<string, unknown>;
}
}
}
export {};
+96
View File
@@ -0,0 +1,96 @@
export const Fragment = Symbol.for("email-template.fragment");
const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]);
export function h(type, props, ...children) {
const normalizedProps = props ?? {};
if (type === Fragment) {
return children;
}
if (typeof type === "function") {
return type({
...normalizedProps,
children
});
}
return {
type,
props: normalizedProps,
children
};
}
export function renderEmail(node) {
return minifyHtml(`<!DOCTYPE html>${renderNode(node)}`);
}
function renderNode(node) {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (Array.isArray(node)) {
return node.map((child) => renderNode(child)).join("");
}
if (typeof node === "string" || typeof node === "number") {
return escapeHtml(String(node));
}
const attributes = renderAttributes(node.props);
if (VOID_ELEMENTS.has(node.type)) {
return `<${node.type}${attributes}>`;
}
const content = node.children.map((child) => renderNode(child)).join("");
return `<${node.type}${attributes}>${content}</${node.type}>`;
}
function renderAttributes(props) {
return Object.entries(props)
.filter(([key, value]) => key !== "children" && value !== null && value !== undefined && value !== false)
.map(([key, value]) => {
const attrName = key === "className" ? "class" : key;
if (value === true) {
return ` ${attrName}`;
}
if (attrName === "style") {
return ` style="${escapeAttribute(serializeStyle(value))}"`;
}
return ` ${attrName}="${escapeAttribute(String(value))}"`;
})
.join("");
}
function serializeStyle(style) {
if (typeof style === "string") {
return style.trim();
}
return Object.entries(style)
.filter(([, value]) => value !== null && value !== undefined)
.map(([key, value]) => `${toKebabCase(key)}:${String(value)}`)
.join(";");
}
function toKebabCase(value) {
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
function escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value) {
return escapeHtml(value).replaceAll("\"", "&quot;");
}
function minifyHtml(html) {
return html
.replace(/>\s+</g, "><")
.replace(/\s{2,}/g, " ")
.trim();
}
+4
View File
@@ -0,0 +1,4 @@
export interface FailureTemplateOptions {
appName?: string;
}
export declare function renderFailureTemplate({ appName }?: FailureTemplateOptions): string;
+13
View File
@@ -0,0 +1,13 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { renderPipelineStatusTemplate } from "./pipeline-status.js";
export function renderFailureTemplate({ appName = APP_NAME_PLACEHOLDER } = {}) {
return renderPipelineStatusTemplate({
appName,
bannerLabel: "Failure",
heading: "Pipeline failed before completion",
summary: "failed before it could complete.",
details: "Review the job logs, identify the failing stage, and rerun the pipeline after the issue has been corrected.",
accentColor: "#b91c1c",
accentTint: "#fee2e2"
});
}
+11
View File
@@ -0,0 +1,11 @@
export interface PipelineStatusTemplateProps {
appName?: string;
bannerLabel: string;
heading: string;
summary: string;
details: string;
accentColor: string;
accentTint: string;
}
export declare function PipelineStatusTemplate({ appName, bannerLabel, heading, summary, details, accentColor, accentTint }: PipelineStatusTemplateProps): import("../lib/jsx.js").Child;
export declare function renderPipelineStatusTemplate(props: PipelineStatusTemplateProps): string;
+141
View File
@@ -0,0 +1,141 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { h, renderEmail } from "../lib/jsx.js";
const bodyStyle = {
margin: 0,
padding: 0,
backgroundColor: "#f3f4f6",
fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
color: "#111827"
};
const shellStyle = {
width: "100%",
backgroundColor: "#f3f4f6",
padding: "32px 16px"
};
const cardStyle = {
width: "100%",
maxWidth: "640px",
backgroundColor: "#ffffff",
borderCollapse: "collapse",
border: "1px solid #e5e7eb"
};
const contentStyle = {
padding: "32px"
};
const badgeStyle = (accentColor, accentTint) => ({
display: "inline-block",
padding: "8px 12px",
borderRadius: "999px",
backgroundColor: accentTint,
color: accentColor,
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase"
});
const headingStyle = {
margin: "20px 0 12px",
fontSize: "28px",
lineHeight: 1.25,
color: "#111827"
};
const copyStyle = {
margin: "0 0 16px",
fontSize: "16px",
lineHeight: 1.65,
color: "#374151"
};
const panelStyle = (accentColor) => ({
marginTop: "28px",
border: `1px solid ${accentColor}`,
borderRadius: "12px",
overflow: "hidden"
});
const panelHeaderStyle = (accentColor) => ({
padding: "14px 18px",
backgroundColor: accentColor,
color: "#ffffff",
fontSize: "14px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase"
});
const panelBodyStyle = {
padding: "18px",
backgroundColor: "#ffffff"
};
const labelStyle = {
width: "140px",
padding: "0 0 12px",
fontSize: "13px",
lineHeight: 1.5,
color: "#6b7280",
verticalAlign: "top"
};
const valueStyle = {
padding: "0 0 12px",
fontSize: "15px",
lineHeight: 1.6,
color: "#111827",
verticalAlign: "top"
};
const footerStyle = {
padding: "20px 32px 32px",
fontSize: "13px",
lineHeight: 1.6,
color: "#6b7280"
};
function PreviewText({ text }) {
return (h("div", { style: {
display: "none",
overflow: "hidden",
lineHeight: 1,
opacity: 0,
maxHeight: 0,
maxWidth: 0,
msoHide: "all"
} }, text));
}
export function PipelineStatusTemplate({ appName = APP_NAME_PLACEHOLDER, bannerLabel, heading, summary, details, accentColor, accentTint }) {
const resolvedAppName = appName.trim() || APP_NAME_PLACEHOLDER;
const previewText = `${resolvedAppName} ${summary}`;
return (h("html", { lang: "en" },
h("head", null,
h("meta", { charset: "utf-8" }),
h("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0" }),
h("title", null, heading)),
h("body", { style: bodyStyle },
h(PreviewText, { text: previewText }),
h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: shellStyle },
h("tr", null,
h("td", { align: "center" },
h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: cardStyle },
h("tr", null,
h("td", { style: contentStyle },
h("span", { style: badgeStyle(accentColor, accentTint) }, bannerLabel),
h("h1", { style: headingStyle }, heading),
h("p", { style: copyStyle },
"The CI/CD pipeline for ",
h("strong", null, resolvedAppName),
" ",
summary),
h("p", { style: copyStyle }, details),
h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: panelStyle(accentColor) },
h("tr", null,
h("td", { style: panelHeaderStyle(accentColor) }, "Pipeline Summary")),
h("tr", null,
h("td", { style: panelBodyStyle },
h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0" },
h("tr", null,
h("td", { style: labelStyle }, "Application"),
h("td", { style: valueStyle },
h("strong", null, resolvedAppName))),
h("tr", null,
h("td", { style: labelStyle }, "Status"),
h("td", { style: valueStyle }, bannerLabel)))))))),
h("tr", null,
h("td", { style: footerStyle }, "This message was generated from the CI/CD notification template package.")))))))));
}
export function renderPipelineStatusTemplate(props) {
return renderEmail(h(PipelineStatusTemplate, { ...props }));
}
+4
View File
@@ -0,0 +1,4 @@
export interface SuccessTemplateOptions {
appName?: string;
}
export declare function renderSuccessTemplate({ appName }?: SuccessTemplateOptions): string;
+13
View File
@@ -0,0 +1,13 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { renderPipelineStatusTemplate } from "./pipeline-status.js";
export function renderSuccessTemplate({ appName = APP_NAME_PLACEHOLDER } = {}) {
return renderPipelineStatusTemplate({
appName,
bannerLabel: "Success",
heading: "Pipeline completed successfully",
summary: "completed successfully and is ready for the next step.",
details: "No blocking failures were detected during this run. Continue with the normal rollout, deployment, or stakeholder notification flow.",
accentColor: "#15803d",
accentTint: "#dcfce7"
});
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "ci-cd-email-templates",
"version": "0.1.0",
"description": "TSX email templates for CI/CD pipeline success and failure notifications.",
"type": "module",
"license": "MIT",
"engines": {
"node": ">=24.0.0"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"ci-cd-email-templates": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"render": "node dist/cli.js",
"start": "node dist/cli.js"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
+12
View File
@@ -0,0 +1,12 @@
services:
templates:
build:
context: .
dockerfile: Containerfile
target: runtime
image: localhost/ci-cd-email-templates:latest
environment:
APP_NAME:
OUTPUT_DIR: /workspace/build/html
volumes:
- ./build/html:/workspace/build/html:Z
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
APP_NAME_PLACEHOLDER,
DEFAULT_OUTPUT_DIR,
TEMPLATE_FILE_NAMES,
renderAllTemplates
} from "./index.js";
async function main(): Promise<void> {
const appName = process.env.APP_NAME?.trim() || APP_NAME_PLACEHOLDER;
const outputDir = resolve(process.cwd(), process.env.OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR);
const templates = renderAllTemplates({ appName });
await mkdir(outputDir, { recursive: true });
await Promise.all([
writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.success), templates.success, "utf8"),
writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.failure), templates.failure, "utf8")
]);
process.stdout.write(
`${JSON.stringify(
{
appName,
outputDir,
templates
},
null,
2
)}\n`
);
}
await main();
+9
View File
@@ -0,0 +1,9 @@
export const APP_NAME_PLACEHOLDER = "{{APP_NAME}}";
export const DEFAULT_OUTPUT_DIR = "build/html";
export const TEMPLATE_FILE_NAMES = {
success: "pipeline-success.html",
failure: "pipeline-failure.html"
} as const;
export type TemplateName = keyof typeof TEMPLATE_FILE_NAMES;
+28
View File
@@ -0,0 +1,28 @@
import { APP_NAME_PLACEHOLDER } from "./constants.js";
import { renderFailureTemplate } from "./templates/failure.js";
import { renderSuccessTemplate } from "./templates/success.js";
export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js";
export type { TemplateName } from "./constants.js";
export type { FailureTemplateOptions } from "./templates/failure.js";
export type { SuccessTemplateOptions } from "./templates/success.js";
export { renderFailureTemplate } from "./templates/failure.js";
export { renderSuccessTemplate } from "./templates/success.js";
export interface RenderAllTemplatesOptions {
appName?: string;
}
export interface RenderedTemplates {
success: string;
failure: string;
}
export function renderAllTemplates({
appName = APP_NAME_PLACEHOLDER
}: RenderAllTemplatesOptions = {}): RenderedTemplates {
return {
success: renderSuccessTemplate({ appName }),
failure: renderFailureTemplate({ appName })
};
}
+160
View File
@@ -0,0 +1,160 @@
export type StyleValue =
| string
| Record<string, string | number | null | undefined>;
export type Child =
| HtmlNode
| string
| number
| boolean
| null
| undefined
| Child[];
export type Props = Record<string, unknown> & {
children?: Child[];
style?: StyleValue;
};
export type Component<P = Record<string, unknown>> = (
props: P & { children?: Child[] }
) => Child;
export interface HtmlElement {
type: string;
props: Props;
children: Child[];
}
export const Fragment = Symbol.for("email-template.fragment");
type NodeType = string | typeof Fragment | Component;
const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]);
export type HtmlNode = HtmlElement;
export function h(type: NodeType, props: Props | null, ...children: Child[]): Child {
const normalizedProps = props ?? {};
if (type === Fragment) {
return children;
}
if (typeof type === "function") {
return type({
...normalizedProps,
children
});
}
return {
type,
props: normalizedProps,
children
};
}
export function renderEmail(node: Child): string {
return minifyHtml(`<!DOCTYPE html>${renderNode(node)}`);
}
function renderNode(node: Child): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (Array.isArray(node)) {
return node.map((child) => renderNode(child)).join("");
}
if (typeof node === "string" || typeof node === "number") {
return escapeHtml(String(node));
}
const attributes = renderAttributes(node.props);
if (VOID_ELEMENTS.has(node.type)) {
return `<${node.type}${attributes}>`;
}
const content = node.children.map((child) => renderNode(child)).join("");
return `<${node.type}${attributes}>${content}</${node.type}>`;
}
function renderAttributes(props: Props): string {
return Object.entries(props)
.filter(([key, value]) => key !== "children" && value !== null && value !== undefined && value !== false)
.map(([key, value]) => {
const attrName = key === "className" ? "class" : key;
if (value === true) {
return ` ${attrName}`;
}
if (attrName === "style") {
return ` style="${escapeAttribute(serializeStyle(value as StyleValue))}"`;
}
return ` ${attrName}="${escapeAttribute(String(value))}"`;
})
.join("");
}
function serializeStyle(style: StyleValue): string {
if (typeof style === "string") {
return style.trim();
}
return Object.entries(style)
.filter(([, value]) => value !== null && value !== undefined)
.map(([key, value]) => `${toKebabCase(key)}:${String(value)}`)
.join(";");
}
function toKebabCase(value: string): string {
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replaceAll("\"", "&quot;");
}
function minifyHtml(html: string): string {
return html
.replace(/>\s+</g, "><")
.replace(/\s{2,}/g, " ")
.trim();
}
declare global {
namespace JSX {
type Element = Child;
interface IntrinsicElements {
[elementName: string]: Record<string, unknown>;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
declare module "node:fs/promises" {
export function mkdir(
path: string,
options?: {
recursive?: boolean;
}
): Promise<string | undefined>;
export function writeFile(
file: string,
data: string,
encoding?: string
): Promise<void>;
}
declare module "node:path" {
export function resolve(...paths: string[]): string;
}
declare const process: {
cwd(): string;
env: Record<string, string | undefined>;
stdout: {
write(chunk: string): boolean;
};
};
+21
View File
@@ -0,0 +1,21 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { renderPipelineStatusTemplate } from "./pipeline-status.js";
export interface FailureTemplateOptions {
appName?: string;
}
export function renderFailureTemplate({
appName = APP_NAME_PLACEHOLDER
}: FailureTemplateOptions = {}): string {
return renderPipelineStatusTemplate({
appName,
bannerLabel: "Failure",
heading: "Pipeline failed before completion",
summary: "failed before it could complete.",
details:
"Review the job logs, identify the failing stage, and rerun the pipeline after the issue has been corrected.",
accentColor: "#b91c1c",
accentTint: "#fee2e2"
});
}
+202
View File
@@ -0,0 +1,202 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { Fragment, h, renderEmail } from "../lib/jsx.js";
export interface PipelineStatusTemplateProps {
appName?: string;
bannerLabel: string;
heading: string;
summary: string;
details: string;
accentColor: string;
accentTint: string;
}
const bodyStyle = {
margin: 0,
padding: 0,
backgroundColor: "#f3f4f6",
fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
color: "#111827"
};
const shellStyle = {
width: "100%",
backgroundColor: "#f3f4f6",
padding: "32px 16px"
};
const cardStyle = {
width: "100%",
maxWidth: "640px",
backgroundColor: "#ffffff",
borderCollapse: "collapse",
border: "1px solid #e5e7eb"
};
const contentStyle = {
padding: "32px"
};
const badgeStyle = (accentColor: string, accentTint: string) => ({
display: "inline-block",
padding: "8px 12px",
borderRadius: "999px",
backgroundColor: accentTint,
color: accentColor,
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase"
});
const headingStyle = {
margin: "20px 0 12px",
fontSize: "28px",
lineHeight: 1.25,
color: "#111827"
};
const copyStyle = {
margin: "0 0 16px",
fontSize: "16px",
lineHeight: 1.65,
color: "#374151"
};
const panelStyle = (accentColor: string) => ({
marginTop: "28px",
border: `1px solid ${accentColor}`,
borderRadius: "12px",
overflow: "hidden"
});
const panelHeaderStyle = (accentColor: string) => ({
padding: "14px 18px",
backgroundColor: accentColor,
color: "#ffffff",
fontSize: "14px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase"
});
const panelBodyStyle = {
padding: "18px",
backgroundColor: "#ffffff"
};
const labelStyle = {
width: "140px",
padding: "0 0 12px",
fontSize: "13px",
lineHeight: 1.5,
color: "#6b7280",
verticalAlign: "top"
};
const valueStyle = {
padding: "0 0 12px",
fontSize: "15px",
lineHeight: 1.6,
color: "#111827",
verticalAlign: "top"
};
const footerStyle = {
padding: "20px 32px 32px",
fontSize: "13px",
lineHeight: 1.6,
color: "#6b7280"
};
function PreviewText({ text }: { text: string }) {
return (
<div
style={{
display: "none",
overflow: "hidden",
lineHeight: 1,
opacity: 0,
maxHeight: 0,
maxWidth: 0,
msoHide: "all"
}}
>
{text}
</div>
);
}
export function PipelineStatusTemplate({
appName = APP_NAME_PLACEHOLDER,
bannerLabel,
heading,
summary,
details,
accentColor,
accentTint
}: PipelineStatusTemplateProps) {
const resolvedAppName = appName.trim() || APP_NAME_PLACEHOLDER;
const previewText = `${resolvedAppName} ${summary}`;
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{heading}</title>
</head>
<body style={bodyStyle}>
<PreviewText text={previewText} />
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={shellStyle}>
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={cardStyle}>
<tr>
<td style={contentStyle}>
<span style={badgeStyle(accentColor, accentTint)}>{bannerLabel}</span>
<h1 style={headingStyle}>{heading}</h1>
<p style={copyStyle}>
The CI/CD pipeline for <strong>{resolvedAppName}</strong> {summary}
</p>
<p style={copyStyle}>{details}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={panelStyle(accentColor)}>
<tr>
<td style={panelHeaderStyle(accentColor)}>Pipeline Summary</td>
</tr>
<tr>
<td style={panelBodyStyle}>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style={labelStyle}>Application</td>
<td style={valueStyle}>
<strong>{resolvedAppName}</strong>
</td>
</tr>
<tr>
<td style={labelStyle}>Status</td>
<td style={valueStyle}>{bannerLabel}</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style={footerStyle}>
This message was generated from the CI/CD notification template package.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
);
}
export function renderPipelineStatusTemplate(props: PipelineStatusTemplateProps): string {
return renderEmail(<PipelineStatusTemplate {...props} />);
}
+21
View File
@@ -0,0 +1,21 @@
import { APP_NAME_PLACEHOLDER } from "../constants.js";
import { renderPipelineStatusTemplate } from "./pipeline-status.js";
export interface SuccessTemplateOptions {
appName?: string;
}
export function renderSuccessTemplate({
appName = APP_NAME_PLACEHOLDER
}: SuccessTemplateOptions = {}): string {
return renderPipelineStatusTemplate({
appName,
bannerLabel: "Success",
heading: "Pipeline completed successfully",
summary: "completed successfully and is ready for the next step.",
details:
"No blocking failures were detected during this run. Continue with the normal rollout, deployment, or stakeholder notification flow.",
accentColor: "#15803d",
accentTint: "#dcfce7"
});
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"declaration": true,
"sourceMap": false,
"skipLibCheck": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
},
"include": [
"src/**/*"
]
}