-
Notifications
You must be signed in to change notification settings - Fork 94
Auto-publish scheduled blog posts on their date #622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
madolson
wants to merge
9
commits into
valkey-io:main
Choose a base branch
from
madolson:auto-publish-scheduled-blogs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+230
−8
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
e6eaf53
Auto-publish scheduled blog posts on their date
madolson b0d32c0
Address review: token scope, path staging, CRLF preservation
madolson a009e6d
Address review: drop TOML regex, publish once daily in PT hours
madolson 04226aa
Recommend dates without a time; tighten scheduling docs
madolson 3b6ba1b
Clarify that draft exclusion is temporary
madolson c2bbf85
Fail on invalid frontmatter; fix draft removal in multi-line values
madolson 681c092
Use tomlkit to remove the draft key instead of editing lines
madolson e0a06c1
Publish at 15:00 UTC per review feedback
madolson f31aa8e
Compare publish dates by calendar day
madolson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} | ||
| # 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.