Sync System
How Markdown files in the Git repository are published to BookStack pages via the BookStack REST API — automatically or on demand.
How it works
The sync is handled by .github/scripts/bookstack-sync.py, a Python script that
calls the BookStack REST API.
For each Markdown file passed to it:
-
1
Derive the page name
The filename stem is converted to Title Case — hyphens and underscores become spaces. E.g.
old-fashioned.md→ Old Fashioned. -
2
Search BookStack for an existing page
The API is queried for a page with that exact name (case-insensitive).
-
3a
Update — if the page exists
The page content is replaced via
PUT /api/pages/{id}. Images are uploaded first; local paths are substituted with BookStack URLs before the update. -
3b
Create — if the page does not exist
A new page is created in the target book via
POST /api/pages. Images are then uploaded and the page is updated with resolved URLs.
A preflight check runs before any sync: the script attempts a GET /api/search
to verify connectivity and token validity. If this fails, the sync aborts immediately with a
descriptive error.
Directory Mapping (bookstack-config.json)
By default, every file lands in the book identified by BOOKSTACK_BOOK_ID.
To route files in different directories to different books, create a
bookstack-config.json at the repo root:
{
"directory_map": {
"cocktails": "env:BOOKSTACK_COCKTAILS_BOOK_ID",
"whiskeys": "env:BOOKSTACK_WHISKEY_BOOK_ID"
},
"default_book_id": "env:BOOKSTACK_BOOK_ID"
}
The "env:VAR_NAME" syntax reads the book ID from an environment variable at runtime, keeping IDs out of the repository. You can also use plain integers:
{
"directory_map": {
"cocktails": 3,
"whiskeys": 7
},
"default_book_id": 1
}
Lookup behaviour
The script walks up the path of each file looking for a matching directory name in
directory_map. For example, cocktails/negroni.md matches the
"cocktails" key. If no match is found, default_book_id (or
BOOKSTACK_BOOK_ID) is used. If no fallback is configured, the sync raises an error.
Environment Variables
| Variable | Required | Description |
|---|---|---|
BOOKSTACK_URL |
Required | Base URL of the BookStack instance — no trailing slash, no /api suffix. E.g. http://bookstack-bookstack-dev.apps-crc.testing |
BOOKSTACK_TOKEN_ID |
Required | API token ID. Generated in BookStack → My Account → API Tokens. |
BOOKSTACK_TOKEN_SECRET |
Required | API token secret (shown once at creation time). |
BOOKSTACK_BOOK_ID |
Optional* | Default book ID for new pages. Optional if bookstack-config.json covers all directories. |
BOOKSTACK_COCKTAILS_BOOK_ID |
Optional | Book ID for files under cocktails/ (used when config references env:BOOKSTACK_COCKTAILS_BOOK_ID). |
BOOKSTACK_WHISKEY_BOOK_ID |
Optional | Book ID for files under whiskeys/. |
Local Sync (sync.sh)
For running the sync from your workstation — without going through CI — use sync.sh.
It reads credentials from a .sync-env file (git-ignored) and auto-resolves the
BookStack URL from the CRC route if BOOKSTACK_URL is not set.
First-time setup
# Copy the example credentials file
cp .sync-env.example .sync-env
# Fill in your token values
$EDITOR .sync-env
export BOOKSTACK_URL="" # leave blank to auto-resolve from CRC route
export BOOKSTACK_TOKEN_ID="your-token-id"
export BOOKSTACK_TOKEN_SECRET="your-token-secret"
export BOOKSTACK_BOOK_ID="your-book-id"
.gitignore. Keep it out of version control — it contains live API credentials.
Usage
| Command | What it does |
|---|---|
./sync.sh | Syncs only files changed vs origin/main |
./sync.sh --all | Syncs every .md file in the repo (excluding .github/) |
./sync.sh cocktails/negroni.md | Syncs one or more specific files |
The script creates a Python virtualenv in .venv/ on first run and installs requests automatically.
Image Handling
BookStack pages often include images. The sync script handles the upload automatically:
- Local image references —
— are detected by a regex. - Each referenced image is uploaded to the BookStack Gallery API (
POST /api/image-gallery). If the image already exists, it is updated instead (PUT). - The local path in the Markdown is replaced with the public BookStack URL before the page is saved.
- Absolute URLs (
https://…) are left untouched. - If a referenced image file is missing from disk, a warning is printed and the original path is preserved.
<content-dir>/images/<slug>.jpg where the slug matches the Markdown filename. E.g. for cocktails/negroni.md, the image lives at cocktails/images/negroni.jpg.
Rate Limiting & Retries
The BookStack API enforces rate limits. The sync script handles this gracefully:
- A 0.5-second delay is inserted between every API call.
- On a
429 Too Many Requestsresponse, the script waits with exponential backoff (2attempt seconds) and retries up to 5 times. - All other non-2xx responses raise an exception immediately.
Attempt 1: wait 2s
Attempt 2: wait 4s
Attempt 3: wait 8s
Attempt 4: wait 16s
Attempt 5: raise_for_status()
Plan Mode (--plan)
Pass --plan as the first argument to preview what the sync would do
without making any changes. This is used in the CD pipeline to produce a summary in the GitHub
Actions step summary.
python .github/scripts/bookstack-sync.py --plan cocktails/negroni.md whiskeys/ardbeg-10.md
Output includes a Terraform-style plan and a Mermaid diagram:
BookStack will perform the following actions:
# page "Negroni" will be created
+ page {
+ name = "Negroni"
+ book_id = 3
}
# page "Ardbeg 10" will be updated in-place
~ page {
~ name = "Ardbeg 10"
~ markdown = (content updated)
}
Plan: 1 to create, 1 to update.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Could not connect to BookStack |
BOOKSTACK_URL is wrong or the instance is not running |
Verify the URL and that the BookStack pod is Ready |
Authentication failed (401) |
Invalid BOOKSTACK_TOKEN_ID or BOOKSTACK_TOKEN_SECRET |
Regenerate the API token in BookStack → My Account → API Tokens |
Permission denied (403) |
API token exists but lacks permission to read/write pages | Ensure the token's user has at least Editor role in BookStack |
API not found (404) |
URL contains a path suffix, or BookStack API is disabled | Use the base URL only — no /api or /index.php |
No book ID configured for '…' |
File is in an unmapped directory and BOOKSTACK_BOOK_ID is not set |
Add the directory to bookstack-config.json or set BOOKSTACK_BOOK_ID |
| Image uploaded but URL not substituted | Image path in Markdown uses an absolute URL or wrong relative path | Ensure images use relative paths like images/slug.jpg |