Skip to content
77 changes: 77 additions & 0 deletions .github/workflows/publish-scheduled-blogs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Publish scheduled blogs

on:
schedule:
# Once a day at 15:00 UTC, which is 07:00 PST / 08:00 PDT.
# Running once during PT working hours avoids the middle-of-the-night
# publishing that an hourly job would cause, since posts are conventionally
# dated 00:00:00 or 01:01:01.
- cron: '0 15 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Report what would be published without committing'
type: boolean
default: false

permissions:
contents: read

concurrency:
group: publish-scheduled-blogs
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Generate token
id: generate-token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0
with:
app-id: ${{ secrets.VALKEYRIE_BOT_APP_ID }}
private-key: ${{ secrets.VALKEYRIE_BOT_PRIVATE_KEY }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# workflow `permissions` only scopes GITHUB_TOKEN, not this token.
permission-contents: write

- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ steps.generate-token.outputs.token }}

- name: Install dependencies
run: pip install --disable-pip-version-check 'tomlkit==0.15.1'

- name: Release posts whose date has arrived
id: release
run: python3 build/publish-scheduled-blogs.py

- name: Commit and push
if: steps.release.outputs.count != '0' && inputs.dry_run != true
env:
PUBLISHED: ${{ steps.release.outputs.published }}
run: |
git config user.name 'valkeyrie-bot[bot]'
git config user.email '${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com'
# Stage by directory: the script only edits files under content/blog,
# and this is safe for paths containing spaces.
git add -- content/blog
git commit --signoff -m 'Publish scheduled blog posts' \
-m "$(printf '%s\n' "$PUBLISHED" | grep . | sed 's/^/- /')"
git push origin HEAD:main

- name: Summary
env:
PUBLISHED: ${{ steps.release.outputs.published }}
run: |
if [[ '${{ steps.release.outputs.count }}' == '0' ]]; then
echo 'Nothing to publish.' >> "$GITHUB_STEP_SUMMARY"
else
echo '### Published' >> "$GITHUB_STEP_SUMMARY"
printf '%s\n' "$PUBLISHED" | grep . | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY"
if [[ '${{ inputs.dry_run }}' == 'true' ]]; then
echo '' >> "$GITHUB_STEP_SUMMARY"
echo '_Dry run: nothing was committed._' >> "$GITHUB_STEP_SUMMARY"
fi
fi
29 changes: 21 additions & 8 deletions CONTRIBUTING-BLOG-POST.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,19 @@ Here is an example of the frontmatter:
+++
# `title` is how your post will be listed and what will appear at the top of the post
title= "Using Valkey for mind control experiments"
# `date` is when your post will be published.
# For the most part, you can leave this as the day you _started_ the post.
# The maintainers will update this value before publishing
# The time is generally irrelevant in how Valkey published, so '01:01:01' is a good placeholder
date= 2024-07-01 01:01:01
# `date` is when your post will be published. Use a date with no time.
# Put the date you expect it to go out; maintainers will adjust it if needed.
date= 2024-07-01
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 'description' is what is shown as a snippet/summary in various contexts.
# You can make this the first few lines of the post or (better) a hook for readers.
# Aim for 2 short sentences.
description= "It's become clear that people want to talk about Valkey and have been publishing blog posts/articles fervently. Here you'll find a collection of all the post I'm aware of in the last few weeks."
# 'authors' are the folks who wrote or contributed to the post.
# Each author corresponds to a biography file (more info later in this document)
authors= [ "maury", "jacobim" ]
# 'draft' holds a finished post back until its 'date' arrives.
# Leave it out unless you are scheduling; see "Scheduling a post" below.
draft = true
[extra]
# 'featured' controls whether the blog post appears in the featured section on the main blog page
featured = true
Expand Down Expand Up @@ -157,6 +158,18 @@ Make sure to communicate your change fully in the body of the pull request.
After your contribution is made, the website maintainers will review the post.
They may have feedback for you and ask you to make changes.
Once everyone is satisfied with the post, the maintainers will merge it into the `main` branch.
The `main` branch of the repo represents the *future* state of all integrated changes before publishing.
At this point, the maintainers make further changes to your post to properly schedule or link the post.
Once this occurs, the maintainers will make move the changes into ‘production’ which will trigger a rebuild and publishing of the content website to [Valkey.io](http://valkey.io/)
Merging to `main` rebuilds and publishes [Valkey.io](http://valkey.io/), so a post without `draft = true` goes live as soon as it is merged.

## Scheduling a post

Scheduling is for finished, approved posts that shouldn't be public yet.
Work-in-progress stays in a pull request.

Set `draft = true` and set `date` to the day it should go live.
Until it publishes, the post is not built, listed, or included in the feed.
Once it publishes, it behaves like any other post.

A job runs daily at 15:00 UTC (07:00 PST / 08:00 PDT) and publishes any draft whose `date` has arrived.
Dates are UTC, and the time of day is ignored, so use a date with no time.
A post dated `2026-08-15` goes live that morning, Pacific time.
Publish by hand if you need an exact time.
132 changes: 132 additions & 0 deletions build/publish-scheduled-blogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Release blog posts whose publish date has arrived.

Scans `content/blog` for posts marked `draft = true` in their TOML frontmatter.
Once a post's `date` has arrived, the `draft` key is removed so Zola will render
it on the next build.

Only the calendar date is compared, in UTC, so any time of day in `date` is
ignored. Frontmatter is edited with `tomlkit`,
which preserves the original formatting, comments, and line endings, so only the
`draft` line changes.

Writes the released paths to $GITHUB_OUTPUT as `published`.
"""

import datetime
import os
import pathlib
import sys

import tomlkit

UTC = datetime.timezone.utc
FENCE = "+++"


def split_frontmatter(text):
"""Return (start, end) offsets of the TOML between the leading `+++` fences.

Returns None when the file does not open with a frontmatter block.
"""
if not text.lstrip().startswith(FENCE):
return None

start = text.index(FENCE) + len(FENCE)
end = text.find(FENCE, start)
if end == -1:
return None
return start, end


def publish_date(value):
"""Return the UTC calendar date a `date` value schedules, or None if unusable.

TOML dates arrive as a date or datetime. A quoted value arrives as a string,
so it is re-parsed as bare TOML to hold both forms to the same rules.

Only the date is kept. The job runs once a day, so a post scheduled for
today publishes on today's run whatever time of day it carries.
"""
if isinstance(value, str):
try:
value = tomlkit.parse(f"date = {value.strip()}")["date"]
except Exception:
return None

if isinstance(value, datetime.datetime):
# An offset like +02:00 can land on a different UTC day.
if value.tzinfo:
value = value.astimezone(UTC)
return value.date()
if isinstance(value, datetime.date):
return value
return None


def main():
today = datetime.datetime.now(UTC).date()
root = pathlib.Path("content/blog")
if not root.is_dir():
sys.exit(f"{root} not found; run from the repository root")

released = []
for path in sorted(root.rglob("*.md")):
if path.name == "_index.md":
continue

# newline="" keeps CRLF intact instead of translating it to LF.
with open(path, "r", encoding="utf-8", newline="") as handle:
text = handle.read()

bounds = split_frontmatter(text)
if bounds is None:
continue
start, end = bounds

# Invalid TOML already fails `zola build`, so treat it as an error here
# rather than skipping the file and publishing nothing.
try:
# A CRLF file leaves a lone trailing `\r` that the parser rejects.
# Only strip the trailing one: a leading `\r` is half of the CRLF
# that ends the opening fence line and must be kept.
frontmatter = tomlkit.parse(text[start:end].rstrip("\r"))
except Exception as error:
sys.exit(f"{path}: invalid TOML frontmatter: {error}")

# Reading the key directly only ever sees the top-level table, so a
# `draft` inside [extra], a comment, or a string is never confused
# for the real one.
if frontmatter.get("draft") is not True:
continue

if "date" not in frontmatter:
sys.exit(f"{path}: draft has no `date`, so it can never publish")

scheduled = publish_date(frontmatter["date"])
if scheduled is None:
sys.exit(f"{path}: unrecognized `date` value {frontmatter['date']!r}")
if scheduled > today:
print(f"holding {path} until {scheduled:%Y-%m-%d}")
continue

del frontmatter["draft"]
with open(path, "w", encoding="utf-8", newline="") as handle:
handle.write(text[:start] + tomlkit.dumps(frontmatter) + text[end:])
print(f"releasing {path} (dated {scheduled:%Y-%m-%d})")
released.append(str(path))

output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a", encoding="utf-8") as handle:
handle.write(f"count={len(released)}\n")
handle.write("published<<EOF\n")
for item in released:
handle.write(f"{item}\n")
handle.write("EOF\n")

print(f"released {len(released)} post(s)")


if __name__ == "__main__":
main()
Loading