diff --git a/.github/workflows/publish-scheduled-blogs.yml b/.github/workflows/publish-scheduled-blogs.yml new file mode 100644 index 00000000..ca0a098a --- /dev/null +++ b/.github/workflows/publish-scheduled-blogs.yml @@ -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 }} + # 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 diff --git a/CONTRIBUTING-BLOG-POST.md b/CONTRIBUTING-BLOG-POST.md index 92fc6c06..a5c9f060 100644 --- a/CONTRIBUTING-BLOG-POST.md +++ b/CONTRIBUTING-BLOG-POST.md @@ -71,11 +71,9 @@ 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 # '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. @@ -83,6 +81,9 @@ 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 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 @@ -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. diff --git a/build/publish-scheduled-blogs.py b/build/publish-scheduled-blogs.py new file mode 100755 index 00000000..ed796640 --- /dev/null +++ b/build/publish-scheduled-blogs.py @@ -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<