GitHub to WordPress Publishing
Pipeline Overview
Frontmatter Strategy
Every .md file in the content folders starts with YAML frontmatter that maps directly to WordPress fields:
Schema
---
id: git-cheat-sheet # required: stable doc identifier
title: Git Quick Reference # required: post title
description: Essential Git... # required: excerpt / meta description
category: code # required: must == top-level folder
tags: [cheatsheet, git] # required: YAML list
status: publish # optional: publish | draft, default publish
date: 2026-07-20T00:00:00Z # optional: ISO 8601 publish datetime
slug: git-cheat-sheet # optional: URL slug, defaults to filename stem
template: reference # optional: post template name
featured_image: # optional: URL to featured image
---
Validation Rules
| Field | Rule | Verified |
|---|---|---|
id | Required. Becomes _id86_doc_id post meta. | ✅ |
title | Required. Becomes post_title. | ✅ |
description | Required. Becomes post_excerpt. | ✅ |
category | Required. Must match the first directory component. Auto-created in WordPress if new. | ✅ |
tags | Required. Non-empty YAML list. Auto-created. | ✅ |
status | publish or draft. Defaults to publish. | ✅ |
date | ISO 8601 datetime. Falls back to WordPress current time. | ✅ |
slug | URL slug. Defaults to filename stem. | ✅ |
template | Sets _wp_page_template post meta. | ✅ |
featured_image | Sets post thumbnail from existing attachment URL. | ✅ |
Metadata Contract
| Meta Key | Source | Purpose | Verified |
|---|---|---|---|
_id86_doc_id | Frontmatter id | Stable document identifier | ✅ |
_id86_source_path | File path | Repository-relative path for post matching | ✅ |
_id86_source_hash | SHA-256 of file | Change detection; skip when unchanged | ✅ |
_id86_content_type | Always instant-documentation | Marks post as managed content | ✅ |
Content Layout
The first folder level determines the WordPress category. Deeper nesting organizes content but does not change taxonomy.
code/git/git-cheat-sheet.md → category: code
devops/docker/docker-compose.md → category: devops
english/grammar/english-tenses.md → category: english
seo/keyword-research.md → category: seo
Sync Decision Flow
Sync Tool
Location: sync.py at the repository root.
Commands
| Command | Effect |
|---|---|
tools/sync-docs.sh | Full sync via wrapper script (auto-sources venv + CF creds) |
tools/sync-docs.sh --dry-run | Validate documents without changing WordPress |
tools/sync-docs.sh --force | Update every document regardless of content hash |
tools/sync-docs.sh --skip-cache-purge | Skip LiteSpeed and Cloudflare cache purging |
tools/sync-docs.sh --cron | Cron mode: git pull, HEAD check, quiet log to reports/cron-sync.log |
python sync.py [flags] | Direct Python invocation (requires venv + env setup) |
Wrapper script
Location: tools/sync-docs.sh
A Bash wrapper that handles setup automatically and supports two modes:
Manual mode (interactive terminal):
tools/sync-docs.sh # full sync with output
tools/sync-docs.sh --dry-run # validate only
tools/sync-docs.sh --force # update all
The script:
- Activates the Python venv at
.venv/bin/python - Sources
~/.ssh/cloudflare/.envif it exists (mapsCF_FULL_CONTROL_TOKEN→CLOUDFLARE_API_TOKEN) - Passes all flags through to
sync.py - Prints full output with report path
Cron mode (triggered by timer or scheduler):
tools/sync-docs.sh --cron
This mode:
- Runs
git fetch origin mainand comparesHEADtoorigin/main - Exits immediately if no new commits (avoids unnecessary runs)
- Pulls new commits with
--ff-onlywhen changes are detected - Runs
sync.pyquietly, appending output toreports/cron-sync.log - Exits cleanly even when there is nothing to sync
Alias shortcut
alias id86sync='/home/rezriz/github/ID86-instant-documentation/tools/sync-docs.sh'
Usage: id86sync --dry-run, id86sync --force, id86sync --cron
Requirements
markdown2>=2.5,<3
PyYAML>=5.4,<7
Configuration
config.yml is git-ignored. Copy config.example.yml:
wp:
ssh_host: GC-US-M10
ssh_user: rezriz
path: /home/Dmg59ZFtKg6bIws1/id86net/public_html
sudo_user: Dmg59ZFtKg6bIws1
Cloudflare credentials (optional, for cache purge):
| Source | Variables | Purpose |
|---|---|---|
| GitHub Actions secrets | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID | CI/CD pipeline |
Local .env | ~/.ssh/cloudflare/.env sets CF_FULL_CONTROL_TOKEN, CF_ZONE_ID | Local testing |
sync-docs.sh | Auto-sources the .env file when present | Both manual and cron modes |
Trigger Options
Two independent trigger paths — use either or both:
| Path | When to use | Cost | Latency |
|---|---|---|---|
| GitHub Actions | CI/CD, push triggered | Free tier minutes | ~1-3 min |
Manual id86sync | On-demand after editing | Free | Instant |
Cron id86sync --cron | Polling fallback when Actions are exhausted | Free | Up to 15 min |
Setting up the cron trigger
Register a cron job on this VPS to poll for changes every 15 minutes:
crontab -e
Add the line:
*/15 * * * * /home/rezriz/github/ID86-instant-documentation/tools/sync-docs.sh --cron
The cron mode runs git fetch origin main, compares HEAD to origin/main, and only runs sync.py when new commits are detected. Output is appended to reports/cron-sync.log for debugging.
Logs
| Log | Path | Format |
|---|---|---|
| Manual sync | Terminal stdout | Live output |
| Cron sync | reports/cron-sync.log | Append-only, timestamped per run |
| Sync reports | reports/sync-{YYYYMMDD-HHMMSS}.json | Per-sync structured JSON |
PHP Bridge
Location: tools/wp-sync-bridge.php
Receives the JSON payload from sync.py and executes WordPress operations through WP-CLI. The bridge:
- Looks up an existing post by
_id86_source_pathmeta key - Compares
_id86_source_hash— skips if unchanged (unless--force) - Resolves or creates the category by slug
- Inserts or updates the post with frontmatter fields
- Sets tags, date, template, and featured image
- Stores all metadata keys
- Returns a JSON report of created, updated, skipped, and failed documents
Post-Sync Steps
After the bridge completes, sync.py runs three cleanup steps. Each step tolerates failure:
- Fuse.js search index:
sudo -u Dmg59ZFtKg6bIws1 wp fusejs generate-index— Verified ✅ - LiteSpeed cache:
sudo -u Dmg59ZFtKg6bIws1 wp litespeed-purge all— Verified ✅ - Cloudflare cache:
POST /client/v4/zones/{zone}/purge_cache— Verified ✅
sync.py Internals
Location: sync.py at the repository root.
Function map
| Function | Role |
|---|---|
load_config() | Reads config.yml, merges from CLOUDFLARE_API_TOKEN/CLOUDFLARE_ZONE_ID env vars |
parse_document(path) | Splits frontmatter from body, validates required fields, enforces category↔folder match, returns dict with all fields plus SHA-256 hash and HTML content |
collect_documents() | Recursive glob for *.md files, skips .git/, .github/, README.md, calls parse_document() on each |
sync(documents, config, force) | Writes JSON payload to temp file → SCP to GC-US-M10 → SCP bridge PHP → SSH + WP-CLI eval → cleanup temp files |
rebuild_search_index(config) | Runs wp fusejs generate-index with sudo on GC-US-M10 |
purge_litespeed(config) | Runs wp litespeed-purge all with sudo on GC-US-M10 |
purge_cloudflare(config) | Calls Cloudflare API POST purge_cache with purge_everything: true |
write_report(results, documents, force, dry_run) | Writes timestamped JSON report to reports/sync-{timestamp}.json |
Payload structure
The JSON payload sent to GC-US-M10 via SCP:
{
"documents": [
{
"doc_id": "git-cheat-sheet",
"source_path": "code/git/git-cheat-sheet.md",
"source_hash": "3a0f0217287159ef2849cc23aef6e0fa27a1603d7ed24e4e0c0e6f3e8d7c5b2a",
"title": "Git Quick Reference",
"description": "Essential Git commands for inspecting, branching...",
"category": "code",
"tags": ["cheatsheet", "git", "version-control"],
"status": "publish",
"slug": "git-cheat-sheet",
"date": "2026-07-20T00:00:00Z",
"template": "",
"featured_image": "",
"content": "<h2>Start a repository</h2>\n... (HTML from markdown2)"
}
],
"force": false
}
Error handling strategy
Step tolerance
Each post-sync step tolerates individual failure:
- Fuse.js reindex: prints warning, continues → sync still reports success
- LiteSpeed purge: prints warning, continues → sync still reports success
- Cloudflare purge: prints warning (or skips if no token), continues → sync still reports success
This means the sync pipeline completes even if cache purging is temporarily unavailable.
Sync Report
Every sync writes a timestamped JSON report to reports/sync-{YYYYMMDD-HHMMSS}.json:
{
"timestamp": "20260720-143000",
"dry_run": false,
"force": false,
"total_documents": 5,
"results": {
"created": [{"source_path": "code/git/git-cheat-sheet.md", "message": "post 4095"}],
"updated": [],
"skipped": [],
"errors": []
},
"summary": {"created": 1, "updated": 0, "skipped": 0, "errors": 0}
}
Reports accumulate in reports/ (git-ignored). In GitHub Actions, they are uploaded as build artifacts.
GitHub Actions Workflow
Location: .github/workflows/sync-wordpress.yml
Execution Flow
Triggers on:
- Push to
mainaffectingcode/**/*.md,devops/**/*.md,english/**/*.md,seo/**/*.md,sync.py,tools/wp-sync-bridge.php,requirements.txt - Manual
workflow_dispatch
Required GitHub secrets:
| Secret | Purpose |
|---|---|
GC_US_M10_SSH_KEY | SSH private key for the rezriz user on GC-US-M10 |
CLOUDFLARE_API_TOKEN | Cloudflare API token with cache purge permission |
CLOUDFLARE_ZONE_ID | Cloudflare zone ID for id86.net |
Content Consistency Rules
Heading hierarchy
Because the single post template (GP Element 3663) already renders the post title as an H1, all markdown source files must start content at H2. Using an H1 in the markdown body creates a duplicate title on the rendered page.
---
title: Git Quick Reference
---
## Start a repository ✅ correct — H2 is the first heading
### Daily workflow ✅ H3 for subsections
---
title: Git Quick Reference
---
# Git Quick Reference ❌ WRONG — H1 duplicates the template title
Last-updated date
The single post template shows the last modified date via the [id86_modified_date] shortcode, not the published date. This shortcode reads get_the_modified_time('F j, Y') and falls back to the publish date if no modified date exists.
The date updates automatically whenever the sync pipeline updates the post.
Frontmatter slug vs file path
The slug field in frontmatter overrides the WordPress URL slug. If omitted, the filename stem is used. The WordPress permalink structure follows the slug, not the folder path.
Example:
- File:
devops/linux/plocate-cheat-sheet.md - Slug:
plocate-cheat-sheet(from frontmatter) - URL:
/devops/plocate-cheat-sheet/
Guardrails
Credential safety
config.ymlis git-ignored. Onlyconfig.example.ymlis committed to the repository.- Cloudflare API token is read from
CLOUDFLARE_API_TOKENenvironment variable or~/.ssh/cloudflare/.env. - GitHub Actions uses encrypted GitHub Secrets (
GC_US_M10_SSH_KEY,CLOUDFLARE_API_TOKEN,CLOUDFLARE_ZONE_ID). - The sync-docs.sh wrapper auto-sources
~/.ssh/cloudflare/.envwhen present.
Content safety
| Rule | Description | Verified |
|---|---|---|
| Non-destructive deletes | Removing a .md file does NOT delete its WordPress post | ✅ |
| Fail closed on validation | Missing required frontmatter fields abort the entire sync | ✅ |
| Per-file error isolation | A failure in one document does not block other documents | ✅ |
| Source is authoritative | Title, content, excerpt, category, tags are always overwritten from markdown | ✅ |
| Category auto-creation | A new top-level folder auto-creates the WordPress category | ✅ |
| Draft isolation | Draft posts are created but not publicly visible | ✅ |
| Dry-run safety | Validates all documents without sending payload or changing WordPress | ✅ |
Unchanged fields
The bridge never overwrites these post properties:
post_author,post_passwordcomment_status,ping_statuspost_thumbnail(only set whenfeatured_imageis present in frontmatter — never cleared)- Any custom meta keys outside the
_id86_*namespace
One-time Setup
Required steps to bootstrap the sync pipeline on a new machine:
1. Clone the repository
git clone git@github.com:donnyaw/ID86-instant-documentation.git
cd ID86-instant-documentation
2. Create Python virtual environment
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
3. Configure WordPress target
cp config.example.yml config.yml
# Edit config.yml with your SSH host, path, and sudo_user
4. Source Cloudflare credentials (optional)
source ~/.ssh/cloudflare/.env
5. Test with dry-run
tools/sync-docs.sh --dry-run
6. Register bash alias (optional)
echo "alias id86sync='$PWD/tools/sync-docs.sh'" >> ~/.bashrc
source ~/.bashrc
7. Install cron job (optional, for polling)
(crontab -l 2>/dev/null; echo '*/15 * * * * /home/rezriz/github/ID86-instant-documentation/tools/sync-docs.sh --cron') | crontab -
Code Repository
Automation code
The sync pipeline code lives in two locations:
| Location | Purpose |
|---|---|
github.com/donnyaw/ID86-instant-documentation | Markdown content + sync tools (committed and pushed) |
/home/rezriz/github/Wordpress/wp-dev/themes/develop-internally/id86/ | Local git tracking of all sync pipeline code |
File inventory
| File | Role |
|---|---|
sync.py | Python sync engine — collect documents, build payload, transfer, post-sync steps |
tools/wp-sync-bridge.php | PHP bridge running under WP-CLI — create/update posts, store meta |
tools/sync-docs.sh | Bash wrapper — venv + CF env setup, dual manual/cron modes |
tools/deploy-homepage.php | One-time deployer for homepage GP Elements and theme code |
config.yml | Git-ignored WordPress target configuration |
config.example.yml | Committed template with documented fields |
requirements.txt | Python dependencies: markdown2, PyYAML |
.github/workflows/sync-wordpress.yml | GitHub Actions CI/CD workflow |
reports/ | Git-ignored sync report output directory |
Sync Verification
All items verified in production end-to-end test on 2026-07-20:
| # | Test | Result |
|---|---|---|
| 1 | New markdown guide creates one WordPress post | ✅ Created post 4138 |
| 2 | Draft status prevents public visibility | ✅ Post 4137 in draft status |
| 3 | Custom slug from frontmatter | ✅ frontmatter-test-all-fields |
| 4 | ISO 8601 date field applied as post_date | ✅ 2026-07-20 07:00:00 |
| 5 | Unchanged guide skipped via hash | ✅ Skipped with "is unchanged" |
| 6 | Edited guide updated via hash mismatch | ✅ Updated post in-place |
| 7 | New category auto-created from folder | ✅ Category testing created |
| 8 | _id86_doc_id meta stored | ✅ frontmatter-test |
| 9 | _id86_source_path meta stored | ✅ code/testing/frontmatter-test.md |
| 10 | _id86_source_hash meta stored | ✅ SHA-256 written |
| 11 | _id86_content_type meta stored | ✅ instant-documentation |
| 12 | Fuse.js search index regenerated | ✅ "Success: Search index regenerated" |
| 13 | LiteSpeed cache purged | ✅ "Success: Purged All!" |
| 14 | Cloudflare cache purged | ✅ API returned success: true |
| 15 | Sync report written to reports/ | ✅ Timestamped JSON written |
| 16 | Dry-run mode does not change WordPress | ✅ 0 changes on dry-run |
| 17 | Non-destructive on file removal | ✅ Removing test files left posts intact |