Reusable GitHub Action that installs your project's dependencies, renders static files with whatever you already use (an npm script, MJML, Maizzle, pandoc, a catalog CLI), uploads a browsable artifact, writes a job summary, and on a pull request posts or updates one comment.
Works with npm, yarn, pnpm and bun, on Linux, macOS and Windows runners, in single-package repositories and monorepos.
v3 is the generic major. Pin LLazyEmail/email-template-workflows@v3 for the neutral defaults. Pin @v2 to keep the old email defaults (product-name: emails, size-limit-kb: 102, artifact-name: rendered-email).
Pick the render mode that matches your project.
Command mode: you have a CLI that renders a folder or catalog.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: LLazyEmail/email-template-workflows@v3
with:
working-directory: .
command: npx generate-template --all --out=generated
artifact-directory: generatedScript mode: you have an npm script that takes an input and an output path.
steps:
- uses: actions/checkout@v7
- uses: LLazyEmail/email-template-workflows@v3
with:
working-directory: .
build-script: build-email
input: src/welcome.md
output: generated/welcome.html
validate-script: ""Build-command mode: you have a tool that is not an npm script (MJML, pandoc, a Python script...). The action runs your command once per file and hands it the paths as environment variables.
steps:
- uses: actions/checkout@v7
- uses: LLazyEmail/email-template-workflows@v3
with:
working-directory: .
templates: |
emails/welcome.mjml:dist/welcome.html
emails/invoice.mjml:dist/invoice.html
build-command: npx mjml "$FILE_INPUT" -o "$FILE_OUTPUT"
validate-script: ""
artifact-directory: distConfig file: keep the workflow short by putting the flags in a file. Explicit with: values still win.
steps:
- uses: actions/checkout@v7
- uses: LLazyEmail/email-template-workflows@v3
with:
working-directory: .
config: .render-config.yml.render-config.yml:
product-name: docs
file-pattern: "*.md"
command: npm run build:docs
artifact-directory: dist| You have | Set | Notes |
|---|---|---|
| npm, yarn, pnpm or bun | nothing, or package-manager |
auto reads the packageManager field, then the lockfile (also in parent folders), then falls back to npm. yarn and pnpm run through Corepack, bun through oven-sh/setup-bun. |
| A monorepo | working-directory |
Lockfile and packageManager are found in parent folders. cache: auto caches from the root lockfile. |
A build script that takes --input and --output |
build-script |
The default style. |
| A script with other argument names | input-flag, output-flag |
--src passes --src <path>, --src= passes --src=<path>, empty passes the path positionally. |
| A script that reads environment variables | nothing | FILE_INPUT and FILE_OUTPUT are always set. EMAIL_TEMPLATE_* are also set for older builds. |
| A CLI that is not an npm script | build-command |
Runs once per file. |
| A CLI that renders a whole folder | command |
Runs once; every file matching file-pattern (default *.html) under artifact-directory is indexed. |
| Shared code to build first | prebuild-command |
Runs after install, before rendering, in every mode. |
| A private registry | registry-url, registry-scope |
Authenticate with NODE_AUTH_TOKEN in the job's env. |
| A Node version file | node-version-file |
.nvmrc, .node-version or .tool-versions. |
| One broken file among many | fail-fast: false |
Renders the rest, uploads and comments, then fails the job. |
| Two sets of files in one pull request | comment-key |
Each key gets its own comment. |
| No pull request (schedule, push) | pr-comment: false |
The job summary and the artifact still list everything. |
| A size limit | size-limit-kb |
Default 0 (off). Set 102 and product-name: emails for the old Gmail clipping warning. |
| Any product | product-name |
Defaults to files. Set emails, docs, components, and so on. |
| A single downloadable archive | zip-artifact: true |
Packs the artifact directory into a zip (named from artifact-name) before upload. |
| Any file type in command mode | file-pattern |
Defaults to *.html. Set to e.g. *.md or *.html *.json to index other files. |
| Many settings to share | config |
JSON or simple YAML file. Keys match input names. Explicit with: values win. |
LLazyEmail/_playing_with_lit renders 15 templates with a job matrix, one job per template: render-email-action.yml. Line-by-line explanation: docs/real-world-example.md. More in examples/:
script-mode-single.yml,script-mode-templates.yml,matrix-script-mode.ymlcommand-mode.ymlmjml-build-command.yml: any CLI, no npm scriptpnpm-monorepo.yml: workspace, root lockfile, filtered installkeep-going-two-comments.yml:fail-fast: falseandcomment-keyscheduled-summary-only.yml: no pull request
docs/agent-playbook.md: start here. Inspect the repository, choose a mode, write the workflow, verify. Copy-paste recipes and a table that maps log messages to fixes.docs/keeping-consumers-up-to-date.md: consumer-side Renovate/Dependabot bumps (no cross-repoCASCADE_TOKEN).llms.txt: short index of this repository for LLMs.AGENTS.md: decision rules, input precedence, gotchas and a verification checklist for agents that add this Action to a repository.CONTRIBUTING.md: local checks, commit trailer, and.changelog/entry format for changes in this repository.CHANGELOG.md: release notes (new entries come from.changelog/fragments).action.yml: source of truth for inputs, outputs and defaults.
The caller job needs pull-requests: write to post the comment. Without it (a read-only token, Dependabot, some forks) the action logs a warning and carries on; the job summary and the artifact are unaffected. Set pr-comment: false to skip the comment entirely. Comments are always skipped for pull requests from forks.
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: LLazyEmail/email-template-workflows@v3
with:
working-directory: .
command: npx generate-template --all --out=generated
artifact-directory: generated
artifact-name: renderedThe artifact is a directory; the comment links the run and tells you to open index.html. index.html lists every rendered file with its size. With zip-artifact: true the directory also contains a zip of everything, named after artifact-name.
The comment heading is ### Rendered files (or ### Rendered <product-name>). A later run updates the existing bot comment instead of adding another one. It also adopts an older <!-- email-template-workflows --> comment or a legacy heading such as ### Rendered emails. With comment-key the heading is ### Rendered <product-name> (<key>) and each key is tracked separately. Override the whole heading with comment-heading.
Set command, or templates, or both input and output. The action writes index.html and rendered.txt (and failed.txt when something failed) into the artifact directory and uploads that directory.
| Input | Required | Default | Meaning |
|---|---|---|---|
command |
no | empty | Command mode. Shell command run after install. Ignores templates, input, output, build-command, build-script, validate-command, validate-script and extra-args. Needs artifact-directory. |
file-pattern |
no | *.html |
Glob (or space-separated globs) used to index files in command mode. Example: *.md or *.html *.json. |
config |
no | empty | Path to a JSON or simple YAML file whose keys match action inputs. Explicit with: values always win; the file fills in anything still at its default. Relative to working-directory. |
templates |
no | empty | Multiline input:output pairs, relative to working-directory. Blank lines and # lines are skipped. Paths must not contain a colon. Ignored when command is set. |
input |
no | empty | Single source path, relative to working-directory |
output |
no | empty | Single output path, relative to working-directory |
| Input | Required | Default | Meaning |
|---|---|---|---|
build-command |
no | empty | Shell command run once per file, with $FILE_INPUT and $FILE_OUTPUT set (and the legacy $EMAIL_TEMPLATE_*). Takes precedence over build-script. |
build-script |
no | build-email |
package.json script that compiles one file. Called with the input and output paths and extra-args. |
input-flag |
no | --input |
How the input path is passed to build-script: --input is --input <path>, --input= is --input=<path>, empty is positional. |
output-flag |
no | --output |
How the output path is passed. Same styles as input-flag. |
extra-args |
no | empty | Appended after the input and output for every file. Split on whitespace, never glob-expanded. Do not put values with spaces in it. build-script only. |
validate-command |
no | empty | Shell command run after each file is built, with $FILE_OUTPUT set. Non-zero fails that file. Takes precedence over validate-script. |
validate-script |
no | validate-email |
package.json script that validates one compiled file. Empty skips validation. If the script is not defined in package.json, validation is skipped with a warning. |
validate-flag |
no | --file |
How the compiled file is passed to validate-script. Same styles as input-flag. |
fail-fast |
no | true |
true stops at the first failing file. false renders the rest, uploads, comments, then fails the job at the end. |
size-limit-kb |
no | 0 |
Warn when a rendered file is larger than this many KB. 0 disables the check. When product-name is emails the warning mentions Gmail clipping. |
| Input | Required | Default | Meaning |
|---|---|---|---|
working-directory |
no | generated |
Directory that contains package.json, relative to the workspace. Every path input is relative to it. Use . for a repository root. |
package-manager |
no | auto |
auto, npm, yarn, pnpm or bun. See What it covers. |
package-manager-version |
no | empty | Version to activate, for example 9.15.0 for pnpm. Empty uses the packageManager field, or the latest. |
install-command |
no | empty | Command that installs dependencies in working-directory. Empty runs <package-manager> install. Use npm ci, pnpm install --frozen-lockfile or yarn install --immutable to enforce the lockfile. |
skip-install |
no | false |
true skips dependency installation. |
prebuild-command |
no | empty | Shell command run after install and before rendering, in working-directory. Runs in every render mode. |
setup-node |
no | true |
false skips actions/setup-node and uses the Node.js already on the runner. Node.js must be on PATH either way. |
node-version |
no | 24 |
Node.js version. Ignored when node-version-file is set. |
node-version-file |
no | empty | .nvmrc, .node-version or .tool-versions, relative to the workspace root. |
cache |
no | empty | npm, yarn or pnpm, or auto to follow the detected package manager when a lockfile exists. Empty disables caching. |
registry-url |
no | empty | Registry to configure for the install, for example https://npm.pkg.github.com. |
registry-scope |
no | empty | Scope to authenticate against registry-url, for example @my-org. |
| Input | Required | Default | Meaning |
|---|---|---|---|
upload-artifact |
no | true |
Upload the rendered directory |
artifact-name |
no | rendered |
Artifact name. Must be unique within a run, so give each matrix job its own. |
artifact-directory |
no | empty | Directory to index and upload, relative to working-directory. Empty uses the directory of the first rendered file. Required when command is set. |
retention-days |
no | 14 |
Days to keep the uploaded artifact |
job-summary |
no | true |
Write the rendered files and their sizes to the job summary. Needs no token permission. |
pr-comment |
no | true |
Post or update the pull request comment. No-op unless the event is pull_request. Skipped for forks. A read-only token logs a warning instead of failing. |
comment-key |
no | empty | Gives this call its own comment, so several calls in one pull request do not overwrite each other. |
product-name |
no | files |
Human-readable name used in the index title, job summary, and PR comment. |
comment-heading |
no | empty | Full override of the PR comment heading. Empty uses ### Rendered <product-name>. |
zip-artifact |
no | false |
When true, pack the artifact directory into a zip (named from artifact-name) before upload. The zip is included in the uploaded directory. |
| Output | Meaning |
|---|---|
file-path |
Path to the first generated file, relative to the workspace. Preferred name. |
file-count |
Number of files rendered. Preferred name. |
html-path |
Alias of file-path. Kept for existing workflows. |
html-count |
Alias of file-count. Kept for existing workflows. |
failed-count |
Number of files that failed. Always 0 unless fail-fast is false. |
artifact-dir |
The directory that was indexed and uploaded, relative to the workspace |
artifact-url |
Link to the uploaded artifact. Empty when upload-artifact is not true. |
package-manager |
The package manager that was used: npm, yarn, pnpm or bun |
For every file, in build-script, build-command, validate-script and validate-command:
| Variable | Value |
|---|---|
FILE_INPUT |
The input path, relative to working-directory |
FILE_OUTPUT |
The output path, relative to working-directory |
EMAIL_TEMPLATE_INPUT |
Same as FILE_INPUT. Kept so existing builds keep working. |
EMAIL_TEMPLATE_OUTPUT |
Same as FILE_OUTPUT. Kept so existing builds keep working. |
Your own env: on the job is passed through as usual, so secrets such as NODE_AUTH_TOKEN or GITHUB_TOKEN reach the install and the build.
- A file that fails to build, fails validation, or writes an empty file fails the job with an error annotation naming it. The annotation also includes the first cause line from the command (for example
error TS2307: Cannot find module ...), and the failure block reprints that cause plus the last lines of the command. - With
fail-fast: falsethe other files still render. The artifact, the job summary and the pull request comment are produced, then the job fails.failed.txtin the artifact and a Failed to render section in the comment list what broke. - If every file fails there is nothing to publish and the job fails immediately.
- A file over
size-limit-kbproduces a warning annotation, not a failure. The wording is email-specific only whenproduct-nameisemails.
Pin @v2 if you want no changes. For v3:
- Prefer
$FILE_INPUT/$FILE_OUTPUTin new build commands. The old names still work. - Set
product-name: emailsif you still want email wording. - Set
size-limit-kb: 102if you still want the Gmail clipping warning. - Set
artifact-name: rendered-emailif something downstream expects that name. - Prefer
file-pathandfile-count.html-*remain as aliases.
Consumers should receive future bumps through their own Renovate or Dependabot config, not a cross-repo cascade. See Keeping consumers up to date.
bash tests/detect-package-manager.test.sh # package manager, lockfile and cache resolution
bash tests/render.test.sh # every render mode, with each installed package manager
node tests/pr-comment.test.js # the pull request comment, with a mocked GitHub client
python3 tests/check-action.py # action.yml, docs, snippets, links and pinned actions stay in syncCI runs these on Linux, macOS (including the system bash 3.2) and Windows, lints the YAML, shell scripts and workflows (yamllint, shellcheck, actionlint), and renders fixtures/consumer through uses: ./ with each package manager. .github/workflows/fixture-render.yml has one job per capability.
Every uses: in this repository is pinned to a full commit SHA with a version comment. renovate.json keeps those pins current.
The action detects the package manager and runs install-command (default <package-manager> install) inside working-directory. Without command, it then renders each file with build-command if set, otherwise with <package-manager> run <build-script> and the input and output paths. With command, it runs that shell command once and indexes artifact-directory.
_playing_with_lit already uses script mode with --input and --output.