Automating everything is a lifestyle for me rather than a task I finish, and this blog is where it shows most. This site is a Hugo build pushed to Azure Static Web Apps from a GitHub Actions workflow. Over the past year the workflow and the templates have grown a handful of jobs that do the chores I used to do by hand after publishing a post. None of it is clever, and most of it is under a hundred lines, but each piece came with one gotcha that cost me an afternoon. Here they are, in the order a post meets them.
Publishing on a date I am not at the keyboard
Hugo skips any post whose date is in the future unless you build with --buildFuture. That is the whole scheduling system. I write a post, give it a Tuesday, and push it with everything else. The build ignores it until the date passes.
The gotcha is that Hugo's idea of "new" and git's are different. For most of this year the deploy ran on push only, and the two announcement jobs below decided a post was new when its file was added to the repo. That is the wrong definition. The first time I pushed five queued posts in one commit, two of them went to dev.to as live articles with canonical links to pages that did not exist yet, and five newsletter drafts appeared for posts a month away.
So there is now a daily cron trigger at quarter past seven UTC, which rebuilds the site so a post appears on its date without a push, and a small script, tools/due_posts.py, that is the single place deciding what counts as published today: on a push, the files added whose date has arrived; on the morning run, every post dated today. Both announcement jobs read from it, and the dev.to script refuses a future-dated post on its own account, so the mistake upstream cannot happen twice. I had left the cron out on purpose, on the grounds that a rebuild with nothing new in it fires for years after you forget it exists. It still does. It turns out that is cheaper than announcing things early.
The dev server runs with --buildFuture so I can read queued posts, and the plan for the quarter lives in a Markdown table that says which file carries which date.
Share images drawn at build time
Every page gets a 1200 by 630 share image rendered by Hugo itself, with no Puppeteer, no Node, and no image service. The partial takes a base PNG from assets/, draws the series name in blue above the title in white, and publishes the result next to the page as og.png:
{{ $filters := slice
(images.Text ($kicker | upper) (dict "font" $font "size" 26 "color" "#60a5fa" "x" 80 "y" 120))
(images.Text $title (dict "font" $font "size" $size "color" "#ffffff" "x" 80 "y" 180 "linespacing" 10)) }}
{{ $img := $base.Filter $filters }}
{{ $img = resources.Copy (path.Join (path.Dir .RelPermalink) "og.png") $img }}
images.Text wraps long lines at the image edge, which I did not expect and was pleased to find. What it does not do is shrink the text to fit, so the partial picks a font size from the title length: 64 pixels up to 45 characters, 56 up to 70, and 48 beyond that. The font has to be a TrueType resource, so JetBrains Mono Bold lives in assets/fonts/. The head partial prefers a hand-made cover image if the front matter has one, falls back to the generated one, and only then to the site hero.
A full build produces about 190 of these. They are regenerated only when the title or the base image changes, because Hugo caches image filters by their inputs, so the build cost is a one-off.
Structured data, and the double escaping
Each post carries a BlogPosting block in JSON-LD so search engines get the headline, dates, author and word count without guessing. My first version wrote the JSON by hand in the template and piped each value through jsonify:
<script type="application/ld+json">
{
"headline": {{ .Title | jsonify }},
...
}
</script>
That looks right and is wrong. Hugo templates are Go's html/template, which knows it is inside a <script> element and escapes anything you print there as a JavaScript string. jsonify had already produced a quoted JSON string, so the escaper quoted it again. A title containing a quotation mark came out as:
{"headline": "\"Why \\\"Barely\\\" Is the Right Word\""}
Valid JSON, and every validator accepted it. The headline was just a string that happened to begin and end with quote marks, and because the page source still parses as JSON it is easy to miss until you read a value back. The fix is to build the whole object as a Hugo dict, serialise it once, and mark the result as safe so the script context leaves it alone:
{{ $ld := dict
"@context" "https://schema.org"
"@type" "BlogPosting"
"headline" .Title
"datePublished" (.Date.Format "2006-01-02")
"wordCount" .WordCount }}
<script type="application/ld+json">{{ $ld | jsonify | safeJS }}</script>
The rule I took from it: in a Hugo template, never emit JSON field by field. Make the object, serialise it in one go.
Cross-posting to dev.to
The deploy workflow has a job that runs after the build and calls a small Python script against the Forem API. The script reads the Hugo post, converts it, and creates or updates the matching article on dev.to. It carries no state. Before writing anything it lists every article on the account and matches by canonical_url, so there is no mapping file to keep in sync and nothing to break if I rename a post locally.
The conversions are small:
- The canonical URL points back here, so search credit stays on this domain.
- Site-relative links and image paths are rewritten to absolute ones. A
/posts/foo/link that works on this site is a dead link on dev.to. - Tags are lowercased, stripped to alphanumerics, deduplicated, and cut to four. Dev.to allows four tags made of letters and digits only, so
react-nativebecomesreactnativeand a post with six tags loses its last two. - The series slug is mapped to the series title from the section's index file.
- Posts with no description get one from the first paragraph, trimmed to 150 characters.
The job distinguishes between added and modified files in the push. New posts are created. Edited posts are only pushed if an article already exists for them, so fixing a typo in a 2024 post never creates a 2024 post on dev.to with today's date. The script waits three seconds between writes and backs off on a 429, because the API throttles article writes.
The --dry-run flag prints the converted payload and calls nothing. I run it on every new post before pushing, mostly to see what the tag mangling has done.
Newsletter drafts
A second job takes the same list of posts published today and creates a Buttondown email for each, with the title, the description and a link. It creates them with status: draft, not about_to_send, so I open Buttondown, add a sentence or two, and send it myself. Fully automatic sending from a CI job felt like the kind of thing that emails every subscriber a half-finished post at 2am.
Because a post can be picked up by a push and by the morning run on the same day, the script asks Buttondown for an email with the same subject first and skips the post if one exists. That is the whole deduplication story, and the subject line is the key. It used to be an inline curl in the workflow, diffing the last commit against its parent, which missed posts in a multi-commit push and announced queued ones early. It is a hundred lines of Python now, and it has a --dry-run that prints the email it would create.
Sending goes through tools/drafts.py, which lists the drafts, matches each subject back to a post's front matter, and refuses to send one whose post is still in the future. The early-announcement bug above left drafts behind for posts a month out, and a date check at the point of sending is the cheapest guard against one of them going to every subscriber by mistake.
Markdown and plain text alongside the HTML
Every post is also published as index.md next to its index.html, from a four-line template that emits the title, description, date and the raw Markdown source. The "Copy as Markdown" button at the top of each post fetches that file and puts it on the clipboard, and prefetches it on hover so the click feels instant. The site root also serves llms.txt, a plain list of every post with a one-line summary and a link to its Markdown, for whatever reads those.
The one thing to know is that Azure Static Web Apps rewrites unknown paths to index.html for single-page apps, so the config has to exclude .md, .txt, .xml and .json from the fallback and declare their MIME types, or your Markdown comes back as your home page.
A hand crank
The workflow also runs on workflow_dispatch. I added that after a deploy failed on the hosting side, with nothing wrong in the commit, and the only way to retry was an empty commit. One button in the Actions tab is better than that.
Top comments (0)