Skip to content

Commit aac79c7

Browse files
authored
Enhance editorial scheduling workflow (#22)
1 parent a235bd9 commit aac79c7

11 files changed

Lines changed: 1440 additions & 3 deletions

File tree

.github/workflows/auto-publish.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Auto-publish scheduled posts
2+
3+
# Runs hourly. Reads docs/blog/.schedule.yml, finds entries whose publish_at
4+
# has passed, removes `draft: true` from each target post, marks the entry
5+
# as published, and commits. The resulting commit to `main` triggers the
6+
# normal deploy workflow.
7+
8+
on:
9+
schedule:
10+
- cron: "5 * * * *" # five-past every hour, UTC
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: write
15+
16+
concurrency:
17+
group: schedule-mutation
18+
cancel-in-progress: false
19+
20+
jobs:
21+
publish-due:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
with:
26+
ref: main
27+
28+
- uses: actions/setup-python@v5
29+
with:
30+
python-version: "3.12"
31+
32+
- name: Install PyYAML
33+
run: pip install pyyaml
34+
35+
- name: Publish due entries
36+
id: run
37+
run: |
38+
set -e
39+
python scripts/schedule_lib.py publish-due --actor "auto-publisher" | tee result.json
40+
published_count=$(python -c "import json; d=json.load(open('result.json')); print(len(d.get('published', [])))")
41+
echo "count=$published_count" >> "$GITHUB_OUTPUT"
42+
43+
- name: Commit and push
44+
if: steps.run.outputs.count != '0'
45+
env:
46+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
47+
run: |
48+
set -e
49+
git config user.name "github-actions[bot]"
50+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
51+
slugs=$(python -c "import json; d=json.load(open('result.json')); print(', '.join(p['slug'] for p in d['published']))")
52+
git add docs/blog/
53+
git commit -m "auto-publish: $slugs" \
54+
-m "Triggered by hourly scheduler." \
55+
-m "Run: $RUN_URL"
56+
git push origin main
57+
58+
- name: Job summary
59+
if: always()
60+
run: |
61+
{
62+
echo "### Auto-publish run"
63+
echo
64+
echo '```json'
65+
cat result.json 2>/dev/null || echo "{}"
66+
echo '```'
67+
} >> "$GITHUB_STEP_SUMMARY"
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Lint new posts
2+
3+
# Blocking PR check: any new file added to docs/blog/posts/ MUST include
4+
# `draft: true` in its frontmatter. This guarantees a PR merge cannot
5+
# accidentally publish a post — the only legitimate way to flip a post live
6+
# is through the scheduling workflows (auto-publisher, /publish-now,
7+
# "Schedule a post" → publish-now). Removing `draft: true` is the dedicated
8+
# job of the github-actions bot, and that path doesn't go through PR review.
9+
#
10+
# Modified existing posts are NOT checked here — auto-publisher commits
11+
# legitimately remove `draft: true`.
12+
13+
#
14+
# Runs on every PR (no path filter) so this check can be marked "required" in
15+
# branch protection without leaving unrelated PRs hanging on a missing status.
16+
# When no posts were added the check passes trivially.
17+
18+
on:
19+
pull_request:
20+
branches: [main]
21+
22+
permissions:
23+
contents: read
24+
pull-requests: write
25+
26+
jobs:
27+
lint:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 0 # need history to diff against base
33+
34+
- uses: actions/setup-python@v5
35+
with:
36+
python-version: "3.12"
37+
38+
- name: Install PyYAML
39+
run: pip install pyyaml
40+
41+
- name: Collect newly added posts
42+
id: added
43+
env:
44+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
45+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
46+
run: |
47+
set -e
48+
ADDED=$(git diff --name-only --diff-filter=A "$BASE_SHA" "$HEAD_SHA" -- 'docs/blog/posts/*.md' || true)
49+
if [ -z "$ADDED" ]; then
50+
echo "No new posts added in this PR — lint passes trivially."
51+
else
52+
echo "Newly added post files:"
53+
echo "$ADDED"
54+
fi
55+
{
56+
echo "files<<EOF"
57+
echo "$ADDED"
58+
echo "EOF"
59+
} >> "$GITHUB_OUTPUT"
60+
61+
- name: Enforce draft on new posts
62+
if: steps.added.outputs.files != ''
63+
env:
64+
FILES: ${{ steps.added.outputs.files }}
65+
run: |
66+
set -e
67+
# FILES is newline-separated; word-splitting on whitespace gives the arg list.
68+
# shellcheck disable=SC2086
69+
python scripts/schedule_lib.py lint-new-posts $FILES | tee result.json
70+
71+
- name: Post-fail explainer
72+
if: failure()
73+
uses: actions/github-script@v7
74+
with:
75+
script: |
76+
const fs = require('fs');
77+
let body = '### Lint failed: new post(s) missing `draft: true`\n\n';
78+
body += 'Every new post in `docs/blog/posts/` must include `draft: true` in its frontmatter when first added. ';
79+
body += 'This is a safety rail — a merge of this PR with the post in its current state would put it live on blogs.jaseci.org immediately, bypassing editorial review.\n\n';
80+
body += 'Fix: add `draft: true` to the frontmatter of the affected file(s):\n\n';
81+
body += '```yaml\n---\ndate: 2026-XX-XX\nauthors:\n - your_author_id\ncategories:\n - Your Category\nslug: your-slug\ndraft: true # <- this line\n---\n```\n\n';
82+
body += 'Once an editor merges the PR, they will schedule a publish time using the *Schedule a post* workflow (or `/schedule <ISO datetime>` in this PR\'s comments). The auto-publisher will flip `draft: true` off when that time arrives.\n\n';
83+
try {
84+
const result = fs.readFileSync('result.json', 'utf8');
85+
body += '<details><summary>Lint output</summary>\n\n```json\n' + result + '\n```\n\n</details>';
86+
} catch (e) {}
87+
await github.rest.issues.createComment({
88+
owner: context.repo.owner,
89+
repo: context.repo.repo,
90+
issue_number: context.payload.pull_request.number,
91+
body,
92+
});

.github/workflows/list-posts.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: List schedulable posts
2+
3+
# Editor utility. Click "Run workflow" to get a markdown table of every post
4+
# in docs/blog/posts/ joined with its current draft state and active schedule
5+
# entry. Use it to find the slug you want to pass to "Schedule a post".
6+
7+
on:
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
list:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.12"
22+
23+
- name: Install PyYAML
24+
run: pip install pyyaml
25+
26+
- name: Render table
27+
run: |
28+
python scripts/schedule_lib.py posts-status > status.json
29+
python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
30+
import json, datetime
31+
data = json.load(open("status.json"))
32+
posts = data["posts"]
33+
34+
drafts = [p for p in posts if p["draft"]]
35+
live = [p for p in posts if not p["draft"]]
36+
queued = [p for p in posts if p.get("schedule")]
37+
38+
print(f"# Schedulable posts ({len(posts)} total)")
39+
print()
40+
print(f"- **{len(drafts)}** drafts (hidden from site)")
41+
print(f"- **{len(live)}** live")
42+
print(f"- **{len(queued)}** with active schedule entries")
43+
print()
44+
print("## Drafts & scheduled")
45+
print()
46+
print("| Slug | State | Publish at (UTC) | Title | File |")
47+
print("|---|---|---|---|---|")
48+
for p in posts:
49+
if not (p["draft"] or p.get("schedule")):
50+
continue
51+
entry = p.get("schedule") or {}
52+
state = entry.get("status") or ("draft" if p["draft"] else "live")
53+
when = entry.get("publish_at", "")
54+
title = (p["title"] or "").replace("|", "\\|")[:80]
55+
print(f"| `{p['slug']}` | {state} | {when} | {title} | [`{p['file']}`]({p['file']}) |")
56+
57+
print()
58+
print("## All live posts")
59+
print()
60+
print("| Slug | Date | Title |")
61+
print("|---|---|---|")
62+
for p in posts:
63+
if p["draft"] or p.get("schedule"):
64+
continue
65+
title = (p["title"] or "").replace("|", "\\|")[:80]
66+
print(f"| `{p['slug']}` | {p['post_date']} | {title} |")
67+
68+
print()
69+
print(f"<sub>Generated {datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%SZ')}. Copy a slug into the *Schedule a post* workflow.</sub>")
70+
PY

.github/workflows/schedule.yml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: Schedule a post
2+
3+
# Editorial scheduling form. Anyone with write access to the repo can run this
4+
# from the Actions tab. It updates docs/blog/.schedule.yml and the target
5+
# post's frontmatter, then commits the change to main.
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
action:
11+
description: What to do
12+
type: choice
13+
required: true
14+
default: add
15+
options:
16+
- add
17+
- hold
18+
- cancel
19+
- publish-now
20+
slug:
21+
description: Post slug (matches `slug:` frontmatter, or filename without .md)
22+
type: string
23+
required: true
24+
publish_at:
25+
description: ISO 8601 UTC datetime (only for `add`, e.g. 2026-06-01T14:00:00Z)
26+
type: string
27+
required: false
28+
notes:
29+
description: Optional notes (only for `add`)
30+
type: string
31+
required: false
32+
33+
permissions:
34+
contents: write
35+
36+
concurrency:
37+
group: schedule-mutation
38+
cancel-in-progress: false
39+
40+
jobs:
41+
run:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@v4
45+
with:
46+
ref: main
47+
48+
- uses: actions/setup-python@v5
49+
with:
50+
python-version: "3.12"
51+
52+
- name: Install PyYAML
53+
run: pip install pyyaml
54+
55+
- name: Validate inputs
56+
env:
57+
ACTION: ${{ inputs.action }}
58+
PUBLISH_AT: ${{ inputs.publish_at }}
59+
run: |
60+
if [ "$ACTION" = "add" ] && [ -z "$PUBLISH_AT" ]; then
61+
echo "::error::publish_at is required for action=add"
62+
exit 1
63+
fi
64+
65+
- name: Run scheduler
66+
id: run
67+
env:
68+
ACTION: ${{ inputs.action }}
69+
SLUG: ${{ inputs.slug }}
70+
PUBLISH_AT: ${{ inputs.publish_at }}
71+
NOTES: ${{ inputs.notes }}
72+
ACTOR: ${{ github.actor }}
73+
run: |
74+
set -e
75+
ARGS=(--slug "$SLUG" --actor "$ACTOR")
76+
if [ "$ACTION" = "add" ]; then
77+
ARGS+=(--publish-at "$PUBLISH_AT")
78+
if [ -n "$NOTES" ]; then
79+
ARGS+=(--notes "$NOTES")
80+
fi
81+
fi
82+
python scripts/schedule_lib.py "$ACTION" "${ARGS[@]}" | tee result.json
83+
84+
- name: Commit changes
85+
env:
86+
ACTION: ${{ inputs.action }}
87+
SLUG: ${{ inputs.slug }}
88+
ACTOR: ${{ github.actor }}
89+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
90+
run: |
91+
set -e
92+
git config user.name "github-actions[bot]"
93+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
94+
if git diff --quiet && [ -z "$(git status --porcelain docs/blog)" ]; then
95+
echo "No file changes produced; nothing to commit."
96+
exit 0
97+
fi
98+
git add docs/blog/
99+
git commit -m "schedule: $ACTION $SLUG" \
100+
-m "Triggered by @$ACTOR via workflow_dispatch." \
101+
-m "Run: $RUN_URL"
102+
git push origin main
103+
104+
- name: Job summary
105+
if: always()
106+
env:
107+
ACTION: ${{ inputs.action }}
108+
SLUG: ${{ inputs.slug }}
109+
run: |
110+
{
111+
echo "### Schedule action: \`$ACTION\` on \`$SLUG\`"
112+
echo
113+
echo '```json'
114+
cat result.json 2>/dev/null || echo "{}"
115+
echo '```'
116+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)