Skip to main content

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

FieldRuleVerified
idRequired. Becomes _id86_doc_id post meta.
titleRequired. Becomes post_title.
descriptionRequired. Becomes post_excerpt.
categoryRequired. Must match the first directory component. Auto-created in WordPress if new.
tagsRequired. Non-empty YAML list. Auto-created.
statuspublish or draft. Defaults to publish.
dateISO 8601 datetime. Falls back to WordPress current time.
slugURL slug. Defaults to filename stem.
templateSets _wp_page_template post meta.
featured_imageSets post thumbnail from existing attachment URL.

Metadata Contract

Meta KeySourcePurposeVerified
_id86_doc_idFrontmatter idStable document identifier
_id86_source_pathFile pathRepository-relative path for post matching
_id86_source_hashSHA-256 of fileChange detection; skip when unchanged
_id86_content_typeAlways instant-documentationMarks 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

CommandEffect
tools/sync-docs.shFull sync via wrapper script (auto-sources venv + CF creds)
tools/sync-docs.sh --dry-runValidate documents without changing WordPress
tools/sync-docs.sh --forceUpdate every document regardless of content hash
tools/sync-docs.sh --skip-cache-purgeSkip LiteSpeed and Cloudflare cache purging
tools/sync-docs.sh --cronCron 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:

  1. Activates the Python venv at .venv/bin/python
  2. Sources ~/.ssh/cloudflare/.env if it exists (maps CF_FULL_CONTROL_TOKENCLOUDFLARE_API_TOKEN)
  3. Passes all flags through to sync.py
  4. Prints full output with report path

Cron mode (triggered by timer or scheduler):

tools/sync-docs.sh --cron

This mode:

  1. Runs git fetch origin main and compares HEAD to origin/main
  2. Exits immediately if no new commits (avoids unnecessary runs)
  3. Pulls new commits with --ff-only when changes are detected
  4. Runs sync.py quietly, appending output to reports/cron-sync.log
  5. 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):

SourceVariablesPurpose
GitHub Actions secretsCLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_IDCI/CD pipeline
Local .env~/.ssh/cloudflare/.env sets CF_FULL_CONTROL_TOKEN, CF_ZONE_IDLocal testing
sync-docs.shAuto-sources the .env file when presentBoth manual and cron modes

Trigger Options

Two independent trigger paths — use either or both:

PathWhen to useCostLatency
GitHub ActionsCI/CD, push triggeredFree tier minutes~1-3 min
Manual id86syncOn-demand after editingFreeInstant
Cron id86sync --cronPolling fallback when Actions are exhaustedFreeUp 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

LogPathFormat
Manual syncTerminal stdoutLive output
Cron syncreports/cron-sync.logAppend-only, timestamped per run
Sync reportsreports/sync-{YYYYMMDD-HHMMSS}.jsonPer-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:

  1. Looks up an existing post by _id86_source_path meta key
  2. Compares _id86_source_hash — skips if unchanged (unless --force)
  3. Resolves or creates the category by slug
  4. Inserts or updates the post with frontmatter fields
  5. Sets tags, date, template, and featured image
  6. Stores all metadata keys
  7. 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:

  1. Fuse.js search index: sudo -u Dmg59ZFtKg6bIws1 wp fusejs generate-index — Verified ✅
  2. LiteSpeed cache: sudo -u Dmg59ZFtKg6bIws1 wp litespeed-purge all — Verified ✅
  3. Cloudflare cache: POST /client/v4/zones/{zone}/purge_cache — Verified ✅

sync.py Internals

Location: sync.py at the repository root.

Function map

FunctionRole
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 main affecting code/**/*.md, devops/**/*.md, english/**/*.md, seo/**/*.md, sync.py, tools/wp-sync-bridge.php, requirements.txt
  • Manual workflow_dispatch

Required GitHub secrets:

SecretPurpose
GC_US_M10_SSH_KEYSSH private key for the rezriz user on GC-US-M10
CLOUDFLARE_API_TOKENCloudflare API token with cache purge permission
CLOUDFLARE_ZONE_IDCloudflare 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.yml is git-ignored. Only config.example.yml is committed to the repository.
  • Cloudflare API token is read from CLOUDFLARE_API_TOKEN environment 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/.env when present.

Content safety

RuleDescriptionVerified
Non-destructive deletesRemoving a .md file does NOT delete its WordPress post
Fail closed on validationMissing required frontmatter fields abort the entire sync
Per-file error isolationA failure in one document does not block other documents
Source is authoritativeTitle, content, excerpt, category, tags are always overwritten from markdown
Category auto-creationA new top-level folder auto-creates the WordPress category
Draft isolationDraft posts are created but not publicly visible
Dry-run safetyValidates all documents without sending payload or changing WordPress

Unchanged fields

The bridge never overwrites these post properties:

  • post_author, post_password
  • comment_status, ping_status
  • post_thumbnail (only set when featured_image is 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:

LocationPurpose
github.com/donnyaw/ID86-instant-documentationMarkdown 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

FileRole
sync.pyPython sync engine — collect documents, build payload, transfer, post-sync steps
tools/wp-sync-bridge.phpPHP bridge running under WP-CLI — create/update posts, store meta
tools/sync-docs.shBash wrapper — venv + CF env setup, dual manual/cron modes
tools/deploy-homepage.phpOne-time deployer for homepage GP Elements and theme code
config.ymlGit-ignored WordPress target configuration
config.example.ymlCommitted template with documented fields
requirements.txtPython dependencies: markdown2, PyYAML
.github/workflows/sync-wordpress.ymlGitHub 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:

#TestResult
1New markdown guide creates one WordPress post✅ Created post 4138
2Draft status prevents public visibility✅ Post 4137 in draft status
3Custom slug from frontmatterfrontmatter-test-all-fields
4ISO 8601 date field applied as post_date✅ 2026-07-20 07:00:00
5Unchanged guide skipped via hash✅ Skipped with "is unchanged"
6Edited guide updated via hash mismatch✅ Updated post in-place
7New category auto-created from folder✅ Category testing created
8_id86_doc_id meta storedfrontmatter-test
9_id86_source_path meta storedcode/testing/frontmatter-test.md
10_id86_source_hash meta stored✅ SHA-256 written
11_id86_content_type meta storedinstant-documentation
12Fuse.js search index regenerated✅ "Success: Search index regenerated"
13LiteSpeed cache purged✅ "Success: Purged All!"
14Cloudflare cache purged✅ API returned success: true
15Sync report written to reports/✅ Timestamped JSON written
16Dry-run mode does not change WordPress✅ 0 changes on dry-run
17Non-destructive on file removal✅ Removing test files left posts intact