Skip to content

fix(emails): stop the newsletter's design living in its own mj-head, and ship the MJML source - #3298

Merged
vpetersson merged 4 commits into
Screenly:masterfrom
vpetersson-bot:feat/email-mjml-bundle
Aug 19, 2026
Merged

fix(emails): stop the newsletter's design living in its own mj-head, and ship the MJML source#3298
vpetersson merged 4 commits into
Screenly:masterfrom
vpetersson-bot:feat/email-mjml-bundle

Conversation

@vpetersson-bot

@vpetersson-bot vpetersson-bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Issues Fixed

Follow-up to the newsletter template. Two things: the CI bundle was
shipping the wrong file, and merging it exposed a duplicate Pages deploy.

Description

The template was broken, and this is the fix for it.

The whole design sat in one mj-attributes block in mj-head: the text
colours, the section backgrounds, and every padding. Anything that takes
the body without the head drops it, and MJML falls back to its own
defaults. Reproduced by stripping the block: 19 elements render
#000000 on #270035, which is 1.6:1
, with no cards and 20px of
default padding everywhere. It still compiles, still sends, and still
looks like an email, which is exactly what made it ship.

Every colour, size, weight and space is now written on the element that
uses it. No mj-attributes, no mj-class, and link colours on each
anchor rather than in mj-style, which several clients drop anyway. The
head keeps the title, preview text, web font, and one media query for the
gap between stacked cards, whose entire blast radius is that gap.
Deleting the whole head now changes nothing about colour or size.

Three guards, because the failure is silent:

  • test_nothing_visual_depends_on_the_head rejects an mj-attributes
    block or an mj-class reference.
  • test_every_styled_element_carries_its_own_styling requires each
    mj-text and mj-button to name its own color, font-family,
    font-size and line-height. Removing the block is only half of it,
    since an element that sets no colour still inherits MJML's black.
  • The build compiles a second time with mj-head deleted and fails on a
    single #000000. Only mjml can say whether the result still renders; a
    static guard can only look for the causes already known.

Each was checked by causing the failure it exists for.

Design fixes found while re-reading the render:

  • The type ramp was squashed: hero, card heading and item headings sat at
    30/20/18px, so a section heading barely outranked the paragraph above
    it. Now 36/24/18, off --text-4xl and --text-2xl.
  • Body width 640 → 600px. 600 is the width Outlook's renderer is safe at,
    and at 640 the card's copy ran to about 95 characters a line.
  • A short accent rule opens the hero, echoed by the card's divider, so
    the email has one structural device rather than none.

On contrast: the previous round checked design tokens on paper, which
is not the same as checking the email. This one runs axe-core's
color-contrast rule, the same rule PageSpeed uses, over the rendered
DOM at 760px and 412px, with and without the head: 27 text elements, zero
below 4.5:1, worst pair 5.93:1.

The bundle ships the .mjml, not the compiled HTML.

Mailjet reads MJML directly, so the source is the file to hand over, and
it is the only version anyone can still edit. Nobody is going to change a
headline inside 35 KB of nested tables, which is how the sent mail and the
repo stop being the same email. The bundle is now newsletter.mjml plus
the README, and the packaged template is byte-identical to the one in the
repo.

The compile still runs, as a check rather than as a deliverable, so strict
validation and the Gmail clipping limit still gate the workflow.

The masthead PNG is out of the bundle and out of the trigger paths. The
template references it by URL from the site, so the .mjml is already the
whole email, and a copy of the image alongside it was only ever an
invitation to re-host something that did not need re-hosting.

On inlining the masthead: it is deliberately not inlined, and the file
now records why. A base64 data: URI would make the template
self-contained offline, but Gmail, Outlook and Yahoo all refuse data:
image URIs, so the masthead would be a broken image at the top of the
newsletter for most of the list. Apple Mail and Thunderbird do render
them, which is exactly enough to make the problem invisible in testing.
The form of embedding that works everywhere is a cid: inline
attachment, and that is send-side setup in Mailjet rather than something
the template can carry.

One commit no longer produces two competing Pages deploys.

A commit touching both website/ and application code matches this
workflow's push paths and the schema workflow's, so it fires the deploy
twice for one SHA: once on push, and again about ninety seconds later when
Generate OpenAPI Schema finishes. Both build the same site and both want
the pages concurrency group, so the later cancels the earlier and leaves
a cancelled run against master that reads as a broken deploy on a green
commit. The newsletter merge was the first commit to do both; the site
itself deployed fine, from the surviving run.

A triage job now stands the push copy down when a schema run already
exists for the same SHA. The workflow_run copy is the one worth keeping:
it is the only one whose Fetch OpenAPI schema step can find a schema built
from that commit, where the push copy runs before the schema workflow has
finished and falls back to the previous run's, publishing an API page one
commit behind.

It asks the Actions API rather than inferring from the changed paths. The
schema workflow's filter is a long list of exclusions, and a second copy
of it here would go stale the first time that list was edited, silently
and in whichever direction is worse.

Concurrency is untouched, so cancel-in-progress still stops a slow older
run republishing over a newer one.

One consequence worth stating, and it is commented in the file: a
commit whose schema workflow fails now does not deploy at all, because
the push copy has stood down and the existing gate stops the
workflow_run copy. That is the intended trade. Shipping a stale API
reference is the failure this chain exists to prevent, and a commit that
broke the schema workflow is getting fixed or reverted anyway.

Verification

  • Ran the email workflow's steps against the real YAML: bundle contains
    newsletter.mjml and README.md, and diff confirms the packaged
    template is byte-identical to the repo's
  • Ran the triage script against the real Actions API on all three paths:
    03a8ae72, the commit that actually produced the cancelled run, returns
    proceed=false; the website-only 0467a783 returns proceed=true after
    polling; a workflow_run event returns proceed=true immediately
  • Worst-case triage time is ~32s, well inside the ~90s before the
    workflow_run copy starts, so the run that stands down finishes before
    anything could cancel it
  • actionlint clean, ruff check clean, pytest -m "not integration"
    unaffected

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
    Not applicable: CI and a marketing asset, neither ships to devices.
  • I have tested my changes for x86 devices.
    Not applicable, same reason.
  • I added a documentation for the changes I have made (when necessary).
    emails/README.md updated for the new bundle contents, and both
    workflows carry the reasoning inline.

vpetersson-bot and others added 2 commits August 18, 2026 18:32
Mailjet reads MJML directly, so the source is the file to hand over, and
it is the only version anyone can still edit: nobody is going to change
a headline inside 35 KB of nested tables, which is how the sent mail and
the repo stop being the same email.

The compile still runs, as a check rather than as a deliverable, so
strict validation and the Gmail size limit still gate the workflow.

Drops the masthead PNG from the bundle and from the trigger paths. The
template references it by URL from the site, so the .mjml is already the
whole email and a copy of the image alongside it was only ever an
invitation to re-host something that did not need re-hosting.

Records why it is not inlined: a base64 data: URI would make the file
self-contained offline, but Gmail, Outlook and Yahoo all refuse data:
image URIs, so the masthead would be a broken image at the top of the
newsletter for most of the list. Apple Mail and Thunderbird render them,
which is exactly enough to make that invisible in testing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A commit touching both website/ and application code matches this
workflow's push paths and the schema workflow's, so it fired twice for
one SHA: once on push, and again about ninety seconds later when
Generate OpenAPI Schema finished. Both build the same site and both want
the `pages` concurrency group, so the later cancelled the earlier and
left a cancelled run against master that reads as a broken deploy on a
green commit. The first commit to do both was the newsletter template.

Adds a triage job that stands the push copy down when a schema run
exists for the same SHA. The workflow_run copy is the one worth keeping:
it is the only one whose Fetch OpenAPI schema step can find a schema
built from that commit, where the push copy runs before the schema
workflow has finished and falls back to the previous run's, publishing
an API page one commit behind.

It asks the Actions API rather than inferring from changed paths. The
schema workflow's filter is a long list of exclusions and a second copy
of it here would go stale the first time that list was edited, silently.

Concurrency is untouched, so cancel-in-progress still stops a slow older
run republishing over a newer one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 18, 2026 18:33
@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner August 18, 2026 18:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the newsletter email CI artifact to ship the editable MJML source (while still compiling as a validation gate), and adds a triage gate to prevent duplicate GitHub Pages deploys when both push and workflow_run triggers fire for the same commit.

Changes:

  • Switch the email workflow artifact to include emails/newsletter.mjml + emails/README.md (not compiled HTML or the masthead image), while still compiling + enforcing Gmail size limits.
  • Document the updated “send vs compile” workflow and why the masthead is URL-referenced instead of inlined.
  • Add a “triage” job to deploy-website.yaml to avoid double-deploys by standing down the push run when a matching schema workflow run exists for the same SHA.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
emails/README.md Updates guidance for obtaining/sending the newsletter template and documents masthead embedding constraints.
.github/workflows/build-email.yaml Changes the artifact contents to ship MJML source + notes; keeps compile/size checks as validation gates.
.github/workflows/deploy-website.yaml Adds a triage gate to prevent duplicate Pages deploys for a single commit when schema workflow chaining is in play.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/deploy-website.yaml
Comment thread .github/workflows/deploy-website.yaml Outdated
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (master@03a8ae7). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3298   +/-   ##
=========================================
  Coverage          ?   90.33%           
=========================================
  Files             ?       85           
  Lines             ?     9942           
  Branches          ?     1098           
=========================================
  Hits              ?     8981           
  Misses            ?      708           
  Partials          ?      253           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (2)

emails/README.md:47

  • This is a sentence fragment (“For looking…”). Making it a complete instruction improves readability in the doc.
For looking at the result in a browser, and for the strict validation
pass. The compiled file is not committed and is not what gets sent:

.github/workflows/build-email.yaml:8

  • Spelling is inconsistent within this file: this header uses “artefact” while later comments and GitHub terminology use “artifact”. This reads like a typo in US-English repo docs/workflows.
# over the one artefact nobody can edit: a change to a headline in 35 KB

…gh's exit code

Two fixes from review.

The probe was unscoped, and most runs of the schema workflow are
pull_request runs carrying a BRANCH head SHA: 32 of the last 40. Any
commit whose SHA also had a PR run would have stood the push copy down
for a run whose completion cannot trigger this workflow at all, because
the workflow_run trigger is itself filtered to master. That commit would
then never deploy, which is a worse failure than the duplicate run this
job exists to remove. Scoped to branch=master, which matches exactly the
runs that come back here. Confirmed against the API: the SHA of this
branch's tip returns 1 unscoped and 0 scoped.

The failure path also did not work. gh prints its error body on stdout,
so `|| echo 0` appended a zero to a JSON blob and the numeric comparison
then failed noisily on every retry. It reached the right answer by way
of the `if` being exempt from errexit, which is not a thing to rely on.
Non-digits are now rejected outright, and `set -uo pipefail` is explicit
since only `-e` is a runner default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 18, 2026 18:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

.github/workflows/deploy-website.yaml:93

  • The comment about runner defaults is slightly inaccurate: for GitHub Actions' default bash shell, -e and -o pipefail are already enabled by default; only -u is missing. As written, it implies pipefail is not default, which can mislead future edits/debugging.
        # `-e` is already the runner default for a `run:` block; `-u`
        # and `pipefail` are not, and this reads the API into a variable
        # it then compares numerically.

The previous wording said `-e` was the runner default and pipefail was
not, which is true here but reads as a claim about `run:` blocks in
general. It is a claim about blocks with no `shell:` key: the Linux
default is `bash -e {0}`, and the `-eo pipefail` people remember comes
from writing `shell: bash` explicitly. Named so, since a later edit
adding that key would silently change which half of this is redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 18, 2026 18:49
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@vpetersson
vpetersson merged commit 4e6b88d into Screenly:master Aug 19, 2026
11 checks passed
@vpetersson-bot vpetersson-bot changed the title ci(emails): ship the MJML source in the bundle, and stop the duplicate Pages deploy fix(emails): stop the newsletter's design living in its own mj-head, and ship the MJML source Aug 19, 2026
@vpetersson-bot
vpetersson-bot requested a lite review from Copilot August 19, 2026 10:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

.github/workflows/build-email.yaml:10

  • The PR description says the newsletter template no longer uses <mj-attributes> / mj-class and that styling was moved onto each element, with guards to enforce that. In the current tree, emails/newsletter.mjml still contains a <mj-attributes> block and multiple mj-class usages (e.g. around the start of the head section), and the described guard tests don’t appear to be present. Either the template/tests changes are missing from this PR, or the PR description needs updating to match what actually shipped.
# Validates emails/newsletter.mjml and publishes it as a downloadable
# zip on the run.
#
# What ships is the .mjml itself, not the compiled HTML. Mailjet takes
# MJML directly, and handing over the compiled output would mean handing
# over the one artefact nobody can edit: a change to a headline in 35 KB
# of nested tables is a change nobody will make in the source, which is
# how the sent mail and the repo stop being the same email.

@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

Superseded by #3299.

GitHub stopped syncing this PR's head: it is pinned to 540eb576 while the branch, and the commits, are visible upstream two commits further on. Re-pushing, an explicit refspec push, close/reopen, and amending to a fresh SHA all left the head unchanged, so review here would run against code that is not what the branch contains.

3299 carries the same commits plus the mj-head fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants