From e6eaf53c0dbd5dbf05b92ba32fd4b219471d6d69 Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 11:53:03 -0700 Subject: [PATCH 1/9] Auto-publish scheduled blog posts on their date Adds an hourly workflow that releases blog posts marked draft = true once their frontmatter date has passed, by removing the draft line and committing to main. This lets maintainers merge posts ahead of time without them going live immediately. Uses Zola's native draft support, so pending posts are excluded from the build, blog listing, sitemap, and RSS feed until released. The workflow pushes with the valkeyrie-bot app token so the existing deploy workflow triggers on the resulting commit. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- .github/workflows/publish-scheduled-blogs.yml | 67 ++++++++++ CONTRIBUTING-BLOG-POST.md | 20 ++- build/publish-scheduled-blogs.py | 114 ++++++++++++++++++ 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-scheduled-blogs.yml create mode 100755 build/publish-scheduled-blogs.py diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml new file mode 100644 index 00000000..535cafa7 --- /dev/null +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -0,0 +1,67 @@ +name: Publish scheduled blogs + +on: + schedule: + # Hourly at :05. Posts go live within an hour of their frontmatter date. + - cron: '5 * * * *' + 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 }} + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + token: ${{ steps.generate-token.outputs.token }} + + - 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' + printf '%s\n' "$PUBLISHED" | grep . | xargs -r git add -- + 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 diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 92fc6c06..cd6b61f3 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -83,6 +83,10 @@ description= "It's become clear that people want to talk about Valkey and have b # '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 the post back from being published. +# While this is true, the post is not built, listed, or included in the feed. +# 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 @@ -157,6 +161,16 @@ 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 + +To merge a post before it should be public, set `draft = true` in the frontmatter and set `date` to the intended publish date and time (UTC). +Zola excludes drafts entirely: the post is not built, does not appear in the blog listing, and stays out of the sitemap and RSS feed. + +An hourly job (`.github/workflows/publish-scheduled-blogs.yml`) checks the drafts under `content/blog`. +Once a draft's `date` has passed, the job removes the `draft` line and commits to `main`, which publishes the post. +Expect the post to go live within an hour of its `date`. + +A draft with no `date` is never published automatically, so it is safe to park work-in-progress on `main`. +Maintainers can also publish immediately by removing the `draft` line by hand, or run the workflow from the Actions tab with **Run workflow** (tick `dry_run` to preview what would be published). diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py new file mode 100755 index 00000000..920cfc37 --- /dev/null +++ b/build/publish-scheduled-blogs.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Release blog posts whose publish date has arrived. + +Scans `content/blog` for posts marked `draft = true` in their TOML frontmatter. +When a post's `date` is now in the past (UTC), the `draft` line is removed so +Zola will render it on the next build. + +Only the `draft` line is touched; the rest of the file is left byte-for-byte +intact. Writes the list of released files to $GITHUB_OUTPUT as `published`. +""" + +import datetime +import os +import pathlib +import re +import sys +import tomllib + +UTC = datetime.timezone.utc +FRONTMATTER = re.compile(r"^\+\+\+[^\S\n]*\n(.*?)\n\+\+\+[^\S\n]*$", re.S | re.M) +DRAFT_LINE = re.compile(r"^\s*draft\s*=\s*true\s*(#.*)?$") + + +def as_utc(value, path): + """Coerce a frontmatter `date` into an aware UTC datetime.""" + if isinstance(value, datetime.datetime): + return value if value.tzinfo else value.replace(tzinfo=UTC) + if isinstance(value, datetime.date): + return datetime.datetime(value.year, value.month, value.day, tzinfo=UTC) + if isinstance(value, str): + text = value.strip().replace("T", " ") + text = re.split(r"[+]|\bZ$", text)[0].strip() + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.datetime.strptime(text, fmt).replace(tzinfo=UTC) + except ValueError: + continue + print(f"::warning file={path}::unrecognized date {value!r}; leaving as draft") + return None + + +def strip_draft(frontmatter): + """Remove the top-level `draft` line, ignoring lines inside TOML tables.""" + lines = frontmatter.split("\n") + for index, line in enumerate(lines): + if line.lstrip().startswith("["): + break # a table header; `draft` past this point is not top-level + if DRAFT_LINE.match(line): + return "\n".join(lines[:index] + lines[index + 1 :]) + return None + + +def main(): + now = datetime.datetime.now(UTC) + 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 + + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.search(text) + if not match or match.start() != 0: + continue + + try: + data = tomllib.loads(match.group(1)) + except tomllib.TOMLDecodeError as error: + print(f"::warning file={path}::could not parse frontmatter: {error}") + continue + + if data.get("draft") is not True: + continue + + if "date" not in data: + print(f"::warning file={path}::draft has no date; leaving as draft") + continue + + publish_at = as_utc(data["date"], path) + if publish_at is None: + continue + if publish_at > now: + print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M} UTC") + continue + + stripped = strip_draft(match.group(1)) + if stripped is None: + print(f"::warning file={path}::draft is set but no `draft` line found") + continue + + path.write_text( + text[: match.start(1)] + stripped + text[match.end(1) :], + encoding="utf-8", + ) + print(f"releasing {path} (dated {publish_at:%Y-%m-%d %H:%M} UTC)") + 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< Date: Fri, 31 Jul 2026 12:02:19 -0700 Subject: [PATCH 2/9] Address review: token scope, path staging, CRLF preservation - Scope the app token with permission-contents: write, since the workflow permissions block only constrains GITHUB_TOKEN. - Stage content/blog directly instead of piping paths through xargs, which splits on whitespace and would break on filenames containing spaces. - Read and write with newline="" so CRLF-authored posts keep their line endings and produce a one-line diff. content/blog already contains one CRLF file, which previously would have been rewritten whole. Also strip the resulting lone trailing carriage return before parsing, which tomllib rejects. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- .github/workflows/publish-scheduled-blogs.yml | 6 ++++- build/publish-scheduled-blogs.py | 22 +++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml index 535cafa7..243d85d5 100644 --- a/.github/workflows/publish-scheduled-blogs.yml +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -28,6 +28,8 @@ jobs: with: app-id: ${{ secrets.VALKEYRIE_BOT_APP_ID }} private-key: ${{ secrets.VALKEYRIE_BOT_PRIVATE_KEY }} + # workflow `permissions` only scopes GITHUB_TOKEN, not this token. + permission-contents: write - name: Checkout uses: actions/checkout@v4 @@ -46,7 +48,9 @@ jobs: run: | git config user.name 'valkeyrie-bot[bot]' git config user.email '${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com' - printf '%s\n' "$PUBLISHED" | grep . | xargs -r git add -- + # 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 diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py index 920cfc37..6926899f 100755 --- a/build/publish-scheduled-blogs.py +++ b/build/publish-scheduled-blogs.py @@ -40,13 +40,16 @@ def as_utc(value, path): def strip_draft(frontmatter): - """Remove the top-level `draft` line, ignoring lines inside TOML tables.""" - lines = frontmatter.split("\n") + """Remove the top-level `draft` line, ignoring lines inside TOML tables. + + Keeps line endings intact so a CRLF-authored post yields a one-line diff. + """ + lines = frontmatter.splitlines(keepends=True) for index, line in enumerate(lines): if line.lstrip().startswith("["): break # a table header; `draft` past this point is not top-level if DRAFT_LINE.match(line): - return "\n".join(lines[:index] + lines[index + 1 :]) + return "".join(lines[:index] + lines[index + 1 :]) return None @@ -61,13 +64,16 @@ def main(): if path.name == "_index.md": continue - text = path.read_text(encoding="utf-8") + # newline="" keeps CRLF intact instead of translating it to LF. + with open(path, "r", encoding="utf-8", newline="") as handle: + text = handle.read() match = FRONTMATTER.search(text) if not match or match.start() != 0: continue try: - data = tomllib.loads(match.group(1)) + # A CRLF file leaves a lone trailing `\r` that tomllib rejects. + data = tomllib.loads(match.group(1).rstrip("\r")) except tomllib.TOMLDecodeError as error: print(f"::warning file={path}::could not parse frontmatter: {error}") continue @@ -91,10 +97,8 @@ def main(): print(f"::warning file={path}::draft is set but no `draft` line found") continue - path.write_text( - text[: match.start(1)] + stripped + text[match.end(1) :], - encoding="utf-8", - ) + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(text[: match.start(1)] + stripped + text[match.end(1) :]) print(f"releasing {path} (dated {publish_at:%Y-%m-%d %H:%M} UTC)") released.append(str(path)) From a009e6d9447d6c0ab9982ada2971134efcbaa88f Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 12:47:42 -0700 Subject: [PATCH 3/9] Address review: drop TOML regex, publish once daily in PT hours Parse frontmatter entirely with tomllib. The regex module is no longer imported: the fences are found by string search, and each candidate line is validated by tomllib itself, which is what authoritatively distinguishes the real top-level 'draft = true' from one in a table, a comment, or a string. Quoted date values are re-parsed as bare TOML instead of a strptime loop, so both forms follow identical rules. This also fixes two files the regex mishandled: a post with a leading blank line before the fence, and one with a multi-line array in its frontmatter. Publish once a day at 16:00 UTC (08:00 PST / 09:00 PDT) instead of hourly. Posts are conventionally dated 00:00:00 or 01:01:01, so an hourly job would have published them in the middle of the night PT. Document that dates are UTC and that the time of day in 'date' does not control publication, and drop the suggestion that work-in-progress can be parked on main. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- .github/workflows/publish-scheduled-blogs.yml | 7 +- CONTRIBUTING-BLOG-POST.md | 21 ++-- build/publish-scheduled-blogs.py | 95 +++++++++++++------ 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml index 243d85d5..49e6c708 100644 --- a/.github/workflows/publish-scheduled-blogs.yml +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -2,8 +2,11 @@ name: Publish scheduled blogs on: schedule: - # Hourly at :05. Posts go live within an hour of their frontmatter date. - - cron: '5 * * * *' + # Once a day at 16:00 UTC, which is 08:00 PST / 09: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 16 * * *' workflow_dispatch: inputs: dry_run: diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index cd6b61f3..6a43eb18 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -83,7 +83,7 @@ description= "It's become clear that people want to talk about Valkey and have b # '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 the post back from being published. +# 'draft' holds a finished post back until its 'date' arrives. # While this is true, the post is not built, listed, or included in the feed. # Leave it out unless you are scheduling; see "Scheduling a post" below. draft = true @@ -165,12 +165,19 @@ Merging to `main` rebuilds and publishes [Valkey.io](http://valkey.io/), so a po ## Scheduling a post -To merge a post before it should be public, set `draft = true` in the frontmatter and set `date` to the intended publish date and time (UTC). +Scheduling is for posts that are *finished and approved* but should not be public yet, such as a release announcement tied to a date. +Work-in-progress should stay in a pull request, not be merged as a draft. + +To schedule a post, set `draft = true` in the frontmatter and set `date` to the day it should go live. Zola excludes drafts entirely: the post is not built, does not appear in the blog listing, and stays out of the sitemap and RSS feed. -An hourly job (`.github/workflows/publish-scheduled-blogs.yml`) checks the drafts under `content/blog`. -Once a draft's `date` has passed, the job removes the `draft` line and commits to `main`, which publishes the post. -Expect the post to go live within an hour of its `date`. +**Dates are UTC, and the time of day in `date` does not control when the post appears.** +A job (`.github/workflows/publish-scheduled-blogs.yml`) runs once a day at 16:00 UTC (08:00 PST / 09:00 PDT). +On the first run at or after a draft's `date`, it removes the `draft` line and commits to `main`, which publishes the post. + +In practice that means a post dated `2026-08-15` goes live during the morning of the 15th, Pacific time. +Because posts are conventionally dated `00:00:00` or `01:01:01`, the job deliberately ignores the time and publishes during PT working hours rather than at whatever hour is written in the file. +If a post must go out at an exact time, publish it by hand instead. -A draft with no `date` is never published automatically, so it is safe to park work-in-progress on `main`. -Maintainers can also publish immediately by removing the `draft` line by hand, or run the workflow from the Actions tab with **Run workflow** (tick `dry_run` to preview what would be published). +A draft with no `date` is never published automatically. +Maintainers can publish immediately by removing the `draft` line by hand, or run the workflow early from the Actions tab with **Run workflow** (tick `dry_run` to preview what would be published without committing). diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py index 6926899f..57faf43b 100755 --- a/build/publish-scheduled-blogs.py +++ b/build/publish-scheduled-blogs.py @@ -2,54 +2,81 @@ """Release blog posts whose publish date has arrived. Scans `content/blog` for posts marked `draft = true` in their TOML frontmatter. -When a post's `date` is now in the past (UTC), the `draft` line is removed so -Zola will render it on the next build. +When a post's `date` is now in the past, the `draft` line is removed so Zola +will render it on the next build. -Only the `draft` line is touched; the rest of the file is left byte-for-byte -intact. Writes the list of released files to $GITHUB_OUTPUT as `published`. +Dates without a timezone are read as UTC. Frontmatter is parsed with `tomllib` +rather than pattern matching, so a `draft` key inside a table, in a comment, or +within a string value is never mistaken for the real one. + +Only the `draft` line is removed; the rest of the file, including its line +endings, is left byte-for-byte intact. Writes the released paths to +$GITHUB_OUTPUT as `published`. """ import datetime import os import pathlib -import re import sys import tomllib UTC = datetime.timezone.utc -FRONTMATTER = re.compile(r"^\+\+\+[^\S\n]*\n(.*?)\n\+\+\+[^\S\n]*$", re.S | re.M) -DRAFT_LINE = re.compile(r"^\s*draft\s*=\s*true\s*(#.*)?$") +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. + """ + body = text.lstrip() + if not body.startswith(FENCE): + return None + + start = text.index(FENCE) + len(FENCE) + end = text.find(FENCE, start) + if end == -1: + return None + return start, end + + +def as_utc(value): + """Coerce a parsed TOML `date` into an aware datetime, or None if unusable. + `tomllib` yields a date, a datetime, or a string when the value was quoted. + Quoted values are re-parsed as bare TOML so the same rules apply to both. + """ + if isinstance(value, str): + try: + value = tomllib.loads(f"date = {value.strip()}")["date"] + except tomllib.TOMLDecodeError: + return None -def as_utc(value, path): - """Coerce a frontmatter `date` into an aware UTC datetime.""" if isinstance(value, datetime.datetime): return value if value.tzinfo else value.replace(tzinfo=UTC) if isinstance(value, datetime.date): return datetime.datetime(value.year, value.month, value.day, tzinfo=UTC) - if isinstance(value, str): - text = value.strip().replace("T", " ") - text = re.split(r"[+]|\bZ$", text)[0].strip() - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): - try: - return datetime.datetime.strptime(text, fmt).replace(tzinfo=UTC) - except ValueError: - continue - print(f"::warning file={path}::unrecognized date {value!r}; leaving as draft") return None def strip_draft(frontmatter): - """Remove the top-level `draft` line, ignoring lines inside TOML tables. + """Remove the top-level `draft` line, or None if there isn't one. - Keeps line endings intact so a CRLF-authored post yields a one-line diff. + Each candidate line is validated with `tomllib`, so only a line that really + is the top-level `draft = true` assignment is removed. Line endings are + preserved so a CRLF-authored post still yields a one-line diff. """ lines = frontmatter.splitlines(keepends=True) for index, line in enumerate(lines): - if line.lstrip().startswith("["): + stripped = line.strip() + if stripped.startswith("["): break # a table header; `draft` past this point is not top-level - if DRAFT_LINE.match(line): - return "".join(lines[:index] + lines[index + 1 :]) + try: + if tomllib.loads(stripped).get("draft") is not True: + continue + except tomllib.TOMLDecodeError: + continue # part of a multi-line value, not a standalone assignment + return "".join(lines[:index] + lines[index + 1 :]) return None @@ -67,13 +94,15 @@ def main(): # newline="" keeps CRLF intact instead of translating it to LF. with open(path, "r", encoding="utf-8", newline="") as handle: text = handle.read() - match = FRONTMATTER.search(text) - if not match or match.start() != 0: + + bounds = split_frontmatter(text) + if bounds is None: continue + start, end = bounds try: # A CRLF file leaves a lone trailing `\r` that tomllib rejects. - data = tomllib.loads(match.group(1).rstrip("\r")) + data = tomllib.loads(text[start:end].strip("\r")) except tomllib.TOMLDecodeError as error: print(f"::warning file={path}::could not parse frontmatter: {error}") continue @@ -85,21 +114,25 @@ def main(): print(f"::warning file={path}::draft has no date; leaving as draft") continue - publish_at = as_utc(data["date"], path) + publish_at = as_utc(data["date"]) if publish_at is None: + print( + f"::warning file={path}::unrecognized date " + f"{data['date']!r}; leaving as draft" + ) continue if publish_at > now: - print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M} UTC") + print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M %Z}") continue - stripped = strip_draft(match.group(1)) + stripped = strip_draft(text[start:end]) if stripped is None: print(f"::warning file={path}::draft is set but no `draft` line found") continue with open(path, "w", encoding="utf-8", newline="") as handle: - handle.write(text[: match.start(1)] + stripped + text[match.end(1) :]) - print(f"releasing {path} (dated {publish_at:%Y-%m-%d %H:%M} UTC)") + handle.write(text[:start] + stripped + text[end:]) + print(f"releasing {path} (dated {publish_at:%Y-%m-%d %H:%M %Z})") released.append(str(path)) output = os.environ.get("GITHUB_OUTPUT") From 04226aa2bf2130a13697c5fae73761d7b44f7393 Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 12:55:53 -0700 Subject: [PATCH 4/9] Recommend dates without a time; tighten scheduling docs The publish job ignores the time of day, so a bare date is the clearer convention. 16 posts already use one. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- CONTRIBUTING-BLOG-POST.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 6a43eb18..681cc180 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -71,11 +71,10 @@ 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. +# `date` is when your post will be published. Use a date with no time. # 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 +# The maintainers will update this value before publishing. +date= 2024-07-01 # '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. @@ -84,7 +83,6 @@ description= "It's become clear that people want to talk about Valkey and have b # 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. -# While this is true, the post is not built, listed, or included in the feed. # Leave it out unless you are scheduling; see "Scheduling a post" below. draft = true [extra] @@ -165,19 +163,13 @@ Merging to `main` rebuilds and publishes [Valkey.io](http://valkey.io/), so a po ## Scheduling a post -Scheduling is for posts that are *finished and approved* but should not be public yet, such as a release announcement tied to a date. -Work-in-progress should stay in a pull request, not be merged as a draft. +Scheduling is for finished, approved posts that shouldn't be public yet. +Work-in-progress stays in a pull request. -To schedule a post, set `draft = true` in the frontmatter and set `date` to the day it should go live. -Zola excludes drafts entirely: the post is not built, does not appear in the blog listing, and stays out of the sitemap and RSS feed. +Set `draft = true` and set `date` to the day it should go live. +Drafts are not built, listed, or included in the feed. -**Dates are UTC, and the time of day in `date` does not control when the post appears.** -A job (`.github/workflows/publish-scheduled-blogs.yml`) runs once a day at 16:00 UTC (08:00 PST / 09:00 PDT). -On the first run at or after a draft's `date`, it removes the `draft` line and commits to `main`, which publishes the post. - -In practice that means a post dated `2026-08-15` goes live during the morning of the 15th, Pacific time. -Because posts are conventionally dated `00:00:00` or `01:01:01`, the job deliberately ignores the time and publishes during PT working hours rather than at whatever hour is written in the file. -If a post must go out at an exact time, publish it by hand instead. - -A draft with no `date` is never published automatically. -Maintainers can publish immediately by removing the `draft` line by hand, or run the workflow early from the Actions tab with **Run workflow** (tick `dry_run` to preview what would be published without committing). +A job runs daily at 16:00 UTC (08:00 PST / 09: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. From 3b6ba1be845261294d88f33022af35320478056e Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 12:57:18 -0700 Subject: [PATCH 5/9] Clarify that draft exclusion is temporary This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- CONTRIBUTING-BLOG-POST.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 681cc180..280426c9 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -167,7 +167,8 @@ 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. -Drafts are not built, listed, or included in the feed. +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 16:00 UTC (08:00 PST / 09: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. From c2bbf85f25406ad8e0189d31172c6e1a142c2e3c Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 13:01:14 -0700 Subject: [PATCH 6/9] Fail on invalid frontmatter; fix draft removal in multi-line values The line-scan approach had a real bug: a 'draft = true' inside a multi-line string was removed instead of the actual key, corrupting the value and leaving the post a draft. 8 existing posts use multi-line frontmatter values, so this was reachable. Now a candidate line is removed and the result re-parsed, accepting the edit only if it drops 'draft' and changes nothing else. Invalid TOML, a missing date, and an unparseable date now exit non-zero instead of warning and skipping. 'zola build' already fails on bad TOML, so there is no reason to accept it quietly here. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- build/publish-scheduled-blogs.py | 47 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py index 57faf43b..92d00a76 100755 --- a/build/publish-scheduled-blogs.py +++ b/build/publish-scheduled-blogs.py @@ -59,24 +59,28 @@ def as_utc(value): return None -def strip_draft(frontmatter): - """Remove the top-level `draft` line, or None if there isn't one. +def strip_draft(frontmatter, data): + """Remove the `draft` line from `frontmatter`, or None if it can't be found. - Each candidate line is validated with `tomllib`, so only a line that really - is the top-level `draft = true` assignment is removed. Line endings are - preserved so a CRLF-authored post still yields a one-line diff. + Rather than trying to recognize the line by eye, each candidate is removed + and the result re-parsed: the edit is accepted only if it drops `draft` and + changes nothing else. That rules out a `draft = true` sitting inside a + multi-line string, where deleting the line would corrupt the value. + + Line endings are preserved so a CRLF-authored post yields a one-line diff. """ + expected = {key: value for key, value in data.items() if key != "draft"} lines = frontmatter.splitlines(keepends=True) + for index, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("["): - break # a table header; `draft` past this point is not top-level + if "draft" not in line: + continue + candidate = "".join(lines[:index] + lines[index + 1 :]) try: - if tomllib.loads(stripped).get("draft") is not True: - continue + if tomllib.loads(candidate.strip("\r")) == expected: + return candidate except tomllib.TOMLDecodeError: - continue # part of a multi-line value, not a standalone assignment - return "".join(lines[:index] + lines[index + 1 :]) + continue # removing this line broke the TOML, so it wasn't the one return None @@ -100,35 +104,30 @@ def main(): 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 tomllib rejects. data = tomllib.loads(text[start:end].strip("\r")) except tomllib.TOMLDecodeError as error: - print(f"::warning file={path}::could not parse frontmatter: {error}") - continue + sys.exit(f"{path}: invalid TOML frontmatter: {error}") if data.get("draft") is not True: continue if "date" not in data: - print(f"::warning file={path}::draft has no date; leaving as draft") - continue + sys.exit(f"{path}: draft has no `date`, so it can never publish") publish_at = as_utc(data["date"]) if publish_at is None: - print( - f"::warning file={path}::unrecognized date " - f"{data['date']!r}; leaving as draft" - ) - continue + sys.exit(f"{path}: unrecognized `date` value {data['date']!r}") if publish_at > now: print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M %Z}") continue - stripped = strip_draft(text[start:end]) + stripped = strip_draft(text[start:end], data) if stripped is None: - print(f"::warning file={path}::draft is set but no `draft` line found") - continue + sys.exit(f"{path}: could not remove the `draft` line") with open(path, "w", encoding="utf-8", newline="") as handle: handle.write(text[:start] + stripped + text[end:]) From 681c092c64c15bb2d794e3ff5e2a381c1474615f Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 13:07:16 -0700 Subject: [PATCH 7/9] Use tomlkit to remove the draft key instead of editing lines tomlkit is a round-trip TOML parser, so 'del frontmatter["draft"]' replaces the hand-rolled line scanning and the remove-then-re-parse verification. Reading the key directly only sees the top-level table, so the table-header scan is gone too, along with the multi-line-value corruption it was working around. Verified tomlkit round-trips all 59 existing posts byte-identically, and preserves comments and CRLF endings. Adds a pip install step; tomlkit is MIT with no dependencies. This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- .github/workflows/publish-scheduled-blogs.yml | 3 + build/publish-scheduled-blogs.py | 79 +++++++------------ 2 files changed, 30 insertions(+), 52 deletions(-) diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml index 49e6c708..1030d317 100644 --- a/.github/workflows/publish-scheduled-blogs.yml +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -40,6 +40,9 @@ jobs: 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 diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py index 92d00a76..e8fdd192 100755 --- a/build/publish-scheduled-blogs.py +++ b/build/publish-scheduled-blogs.py @@ -2,23 +2,22 @@ """Release blog posts whose publish date has arrived. Scans `content/blog` for posts marked `draft = true` in their TOML frontmatter. -When a post's `date` is now in the past, the `draft` line is removed so Zola -will render it on the next build. +When a post's `date` is now in the past, the `draft` key is removed so Zola will +render it on the next build. -Dates without a timezone are read as UTC. Frontmatter is parsed with `tomllib` -rather than pattern matching, so a `draft` key inside a table, in a comment, or -within a string value is never mistaken for the real one. +Dates without a timezone are read as UTC. Frontmatter is edited with `tomlkit`, +which preserves the original formatting, comments, and line endings, so only the +`draft` line changes. -Only the `draft` line is removed; the rest of the file, including its line -endings, is left byte-for-byte intact. Writes the released paths to -$GITHUB_OUTPUT as `published`. +Writes the released paths to $GITHUB_OUTPUT as `published`. """ import datetime import os import pathlib import sys -import tomllib + +import tomlkit UTC = datetime.timezone.utc FENCE = "+++" @@ -29,8 +28,7 @@ def split_frontmatter(text): Returns None when the file does not open with a frontmatter block. """ - body = text.lstrip() - if not body.startswith(FENCE): + if not text.lstrip().startswith(FENCE): return None start = text.index(FENCE) + len(FENCE) @@ -43,13 +41,13 @@ def split_frontmatter(text): def as_utc(value): """Coerce a parsed TOML `date` into an aware datetime, or None if unusable. - `tomllib` yields a date, a datetime, or a string when the value was quoted. - Quoted values are re-parsed as bare TOML so the same rules apply to both. + 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. """ if isinstance(value, str): try: - value = tomllib.loads(f"date = {value.strip()}")["date"] - except tomllib.TOMLDecodeError: + value = tomlkit.parse(f"date = {value.strip()}")["date"] + except Exception: return None if isinstance(value, datetime.datetime): @@ -59,31 +57,6 @@ def as_utc(value): return None -def strip_draft(frontmatter, data): - """Remove the `draft` line from `frontmatter`, or None if it can't be found. - - Rather than trying to recognize the line by eye, each candidate is removed - and the result re-parsed: the edit is accepted only if it drops `draft` and - changes nothing else. That rules out a `draft = true` sitting inside a - multi-line string, where deleting the line would corrupt the value. - - Line endings are preserved so a CRLF-authored post yields a one-line diff. - """ - expected = {key: value for key, value in data.items() if key != "draft"} - lines = frontmatter.splitlines(keepends=True) - - for index, line in enumerate(lines): - if "draft" not in line: - continue - candidate = "".join(lines[:index] + lines[index + 1 :]) - try: - if tomllib.loads(candidate.strip("\r")) == expected: - return candidate - except tomllib.TOMLDecodeError: - continue # removing this line broke the TOML, so it wasn't the one - return None - - def main(): now = datetime.datetime.now(UTC) root = pathlib.Path("content/blog") @@ -107,30 +80,32 @@ def main(): # 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 tomllib rejects. - data = tomllib.loads(text[start:end].strip("\r")) - except tomllib.TOMLDecodeError as error: + # 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}") - if data.get("draft") is not True: + # 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 data: + if "date" not in frontmatter: sys.exit(f"{path}: draft has no `date`, so it can never publish") - publish_at = as_utc(data["date"]) + publish_at = as_utc(frontmatter["date"]) if publish_at is None: - sys.exit(f"{path}: unrecognized `date` value {data['date']!r}") + sys.exit(f"{path}: unrecognized `date` value {frontmatter['date']!r}") if publish_at > now: print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M %Z}") continue - stripped = strip_draft(text[start:end], data) - if stripped is None: - sys.exit(f"{path}: could not remove the `draft` line") - + del frontmatter["draft"] with open(path, "w", encoding="utf-8", newline="") as handle: - handle.write(text[:start] + stripped + text[end:]) + handle.write(text[:start] + tomlkit.dumps(frontmatter) + text[end:]) print(f"releasing {path} (dated {publish_at:%Y-%m-%d %H:%M %Z})") released.append(str(path)) From e0a06c170502eca6802db597f7df89bf13a2a118 Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 13:18:07 -0700 Subject: [PATCH 8/9] Publish at 15:00 UTC per review feedback This was generated by AI but verified, with love, by a human. Signed-off-by: Madelyn Olson --- .github/workflows/publish-scheduled-blogs.yml | 4 ++-- CONTRIBUTING-BLOG-POST.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml index 1030d317..ca0a098a 100644 --- a/.github/workflows/publish-scheduled-blogs.yml +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -2,11 +2,11 @@ name: Publish scheduled blogs on: schedule: - # Once a day at 16:00 UTC, which is 08:00 PST / 09:00 PDT. + # 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 16 * * *' + - cron: '0 15 * * *' workflow_dispatch: inputs: dry_run: diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 280426c9..5a96ad5a 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -170,7 +170,7 @@ 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 16:00 UTC (08:00 PST / 09:00 PDT) and publishes any draft whose `date` has arrived. +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. From f31aa8eb4f8bfe7b12231ea6a96ddee1ce9c6dc6 Mon Sep 17 00:00:00 2001 From: Madelyn Olson Date: Fri, 31 Jul 2026 15:45:35 -0700 Subject: [PATCH 9/9] Compare publish dates by calendar day A post dated 2026-07-31 23:00:00 was held until the next day even though the docs say the time of day is ignored. Compare date() values instead, converting offset-aware datetimes to UTC first. Also align the frontmatter comment with the scheduling section: date is the publish date, not the day writing started. Signed-off-by: Madelyn Olson --- CONTRIBUTING-BLOG-POST.md | 3 +-- build/publish-scheduled-blogs.py | 33 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 5a96ad5a..a5c9f060 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -72,8 +72,7 @@ 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. Use a date with no time. -# For the most part, you can leave this as the day you _started_ the post. -# The maintainers will update this value before publishing. +# Put the date you expect it to go out; maintainers will adjust it if needed. date= 2024-07-01 # '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. diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py index e8fdd192..ed796640 100755 --- a/build/publish-scheduled-blogs.py +++ b/build/publish-scheduled-blogs.py @@ -2,10 +2,11 @@ """Release blog posts whose publish date has arrived. Scans `content/blog` for posts marked `draft = true` in their TOML frontmatter. -When a post's `date` is now in the past, the `draft` key is removed so Zola will -render it on the next build. +Once a post's `date` has arrived, the `draft` key is removed so Zola will render +it on the next build. -Dates without a timezone are read as UTC. Frontmatter is edited with `tomlkit`, +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. @@ -38,11 +39,14 @@ def split_frontmatter(text): return start, end -def as_utc(value): - """Coerce a parsed TOML `date` into an aware datetime, or None if unusable. +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: @@ -51,14 +55,17 @@ def as_utc(value): return None if isinstance(value, datetime.datetime): - return value if value.tzinfo else value.replace(tzinfo=UTC) + # 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 datetime.datetime(value.year, value.month, value.day, tzinfo=UTC) + return value return None def main(): - now = datetime.datetime.now(UTC) + 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") @@ -96,17 +103,17 @@ def main(): if "date" not in frontmatter: sys.exit(f"{path}: draft has no `date`, so it can never publish") - publish_at = as_utc(frontmatter["date"]) - if publish_at is None: + scheduled = publish_date(frontmatter["date"]) + if scheduled is None: sys.exit(f"{path}: unrecognized `date` value {frontmatter['date']!r}") - if publish_at > now: - print(f"holding {path} until {publish_at:%Y-%m-%d %H:%M %Z}") + 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 {publish_at:%Y-%m-%d %H:%M %Z})") + print(f"releasing {path} (dated {scheduled:%Y-%m-%d})") released.append(str(path)) output = os.environ.get("GITHUB_OUTPUT")