forked from mj3b/governance-frameworks
-
Notifications
You must be signed in to change notification settings - Fork 0
498 lines (435 loc) · 20.2 KB
/
Copy pathdeploy-docs.yml
File metadata and controls
498 lines (435 loc) · 20.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
name: Deploy Governance Docs to WordPress
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
deploy:
name: Convert & publish governance docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
- name: Detect changed documents
id: changes
run: |
# All governed documents: directory/filename (without .md)
DOCS=(
"research/fragmented-catholic-digital-governance"
"research/governance-as-code-catholic-technology"
"research/trusted-data-infrastructure-catholic-ministry"
"project-governance/definitions"
"project-governance/project-types"
"project-governance/lifecycle"
"project-governance/project-vetting-criteria"
"project-governance/committees"
"standards/overview"
"standards/committees"
)
# Determine diff base
DEPLOY_ALL=false
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
PREV_TAG=$(git tag --sort=-creatordate | head -2 | tail -1)
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$GITHUB_REF_NAME" ]; then
DIFF_BASE="$PREV_TAG"
else
# First tag ever — deploy everything
DEPLOY_ALL=true
fi
else
# Manual trigger — deploy everything
DEPLOY_ALL=true
fi
CHANGED_DOCS=""
if [ "$DEPLOY_ALL" = true ]; then
echo "First deployment or manual trigger — deploying all documents."
CHANGED_DOCS="${DOCS[*]}"
else
echo "Comparing against: $DIFF_BASE"
CHANGED=$(git diff --name-only "$DIFF_BASE" HEAD 2>/dev/null || echo "")
for DOC in "${DOCS[@]}"; do
if echo "$CHANGED" | grep -q "${DOC}.md"; then
echo " ${DOC}.md changed"
CHANGED_DOCS="${CHANGED_DOCS:+$CHANGED_DOCS }${DOC}"
else
echo " ${DOC}.md unchanged"
fi
done
fi
echo "changed_docs=$CHANGED_DOCS" >> "$GITHUB_OUTPUT"
# Detect deleted governance documents (stale pages to remove)
DELETED_DOCS=""
if [ "$DEPLOY_ALL" != true ] && [ -n "$DIFF_BASE" ]; then
DELETED=$(git diff --name-only --diff-filter=D "$DIFF_BASE" HEAD 2>/dev/null || echo "")
for FILE in $DELETED; do
case "$FILE" in
# Skip non-governance markdown files
README.md|RELEASING.md|CONTRIBUTING.md|CHANGELOG.md|*/README.md) continue ;;
*/*.md)
DOC="${FILE%.md}"
echo " Deleted: $FILE"
DELETED_DOCS="${DELETED_DOCS:+$DELETED_DOCS }${DOC}"
;;
esac
done
fi
echo "deleted_docs=$DELETED_DOCS" >> "$GITHUB_OUTPUT"
- name: Install pandoc
uses: pandoc/actions/setup@86321b6dd4675f5014c611e05088e10d4939e09e # v1.1.1
- name: Deploy changed documents to WordPress
if: steps.changes.outputs.changed_docs != ''
id: deploy
env:
WP_REST_URL: ${{ vars.WP_REST_URL }}
WP_APP_USERNAME: ${{ secrets.WP_APP_USERNAME }}
WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
run: |
# Document titles keyed by directory/basename
declare -A DOC_TITLES=(
["research/fragmented-catholic-digital-governance"]="Fragmented Catholic Digital Governance at Scale"
["research/governance-as-code-catholic-technology"]="Governance-as-Code for Catholic Technology Deployment"
["research/trusted-data-infrastructure-catholic-ministry"]="Trusted Data Infrastructure for Catholic Ministry"
["project-governance/definitions"]="CDCF Governance Definitions"
["project-governance/project-types"]="CDCF Project Types: Foundation Projects and Community Projects"
["project-governance/lifecycle"]="CDCF Project Lifecycle"
["project-governance/project-vetting-criteria"]="CDCF Project Vetting Criteria"
["project-governance/committees"]="CDCF Governance Bodies"
["standards/overview"]="CDCF Standards: Overview"
["standards/committees"]="CDCF Standards Committees"
)
# Parent page slugs for each section
declare -A SECTION_PARENTS=(
["project-governance"]="project-governance"
["research"]="research"
["standards"]="standards"
)
# Display titles for section parent pages (used when auto-creating)
declare -A SECTION_TITLES=(
["project-governance"]="Project Governance"
["research"]="Research"
["standards"]="Standards"
)
# Resolve the top-level "governance" page ID (grandparent of all sections)
GOV_PAGE=$(curl -s \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages?slug=governance&status=publish,draft")
GOV_ID=$(echo "$GOV_PAGE" | jq -r 'if type == "array" then .[0].id // empty else empty end')
if [ -z "$GOV_ID" ] || [ "$GOV_ID" = "null" ]; then
GOV_ID=0
echo "Warning: top-level 'governance' page not found."
else
echo "Resolved top-level 'governance' page (ID: $GOV_ID)"
fi
# Cache resolved parent page IDs
declare -A PARENT_IDS
DEPLOYED_IDS=""
for DOC in ${{ steps.changes.outputs.changed_docs }}; do
TITLE="${DOC_TITLES[$DOC]}"
# Derive slug — prefix with section name to avoid collisions
# (e.g. both project-governance/committees and standards/committees)
SECTION=$(dirname "$DOC")
BASENAME=$(basename "$DOC")
if [ "$SECTION" = "standards" ]; then
SLUG="standards-${BASENAME}"
else
SLUG="$BASENAME"
fi
PARENT_SLUG="${SECTION_PARENTS[$SECTION]}"
echo "Converting ${DOC}.md..."
pandoc "${DOC}.md" --wrap=none -M section="$SECTION" --lua-filter=scripts/fix-internal-links.lua -f markdown -t html5 -o "${SLUG}.html"
HTML_CONTENT=$(cat "${SLUG}.html")
# Resolve parent page ID (cached per section); create if missing
if [ -z "${PARENT_IDS[$PARENT_SLUG]+x}" ]; then
PARENT_PAGE=$(curl -s \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages?slug=${PARENT_SLUG}&status=publish,draft")
PID=$(echo "$PARENT_PAGE" | jq -r 'if type == "array" then .[0].id // empty else empty end')
if [ -n "$PID" ] && [ "$PID" != "null" ]; then
PARENT_IDS[$PARENT_SLUG]="$PID"
echo " Resolved parent '${PARENT_SLUG}' (ID: $PID)"
else
# Auto-create the section parent page under "governance"
SECTION_TITLE="${SECTION_TITLES[$SECTION]:-$PARENT_SLUG}"
echo " Parent '${PARENT_SLUG}' not found — creating..."
PARENT_PAYLOAD=$(jq -n \
--arg title "$SECTION_TITLE" \
--arg slug "$PARENT_SLUG" \
--argjson parent "$GOV_ID" \
--arg template "governance-toc" \
'{title: $title, slug: $slug, content: "", parent: $parent, status: "publish", template: $template}')
PARENT_RESPONSE=$(curl -s -X POST \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
-H "Content-Type: application/json" \
-d "$PARENT_PAYLOAD" \
"$WP_REST_URL/wp/v2/pages")
PID=$(echo "$PARENT_RESPONSE" | jq -r '.id // empty')
if [ -n "$PID" ] && [ "$PID" != "null" ]; then
PARENT_IDS[$PARENT_SLUG]="$PID"
echo " Created parent '${PARENT_SLUG}' (ID: $PID)"
else
PARENT_IDS[$PARENT_SLUG]="0"
echo " Warning: failed to create parent '${PARENT_SLUG}'."
echo " Response: $PARENT_RESPONSE"
fi
fi
fi
PARENT_ID="${PARENT_IDS[$PARENT_SLUG]}"
echo "Deploying '${TITLE}' (slug: ${SLUG}, parent: ${PARENT_ID})..."
# Check if a page with this slug already exists
EXISTING=$(curl -s \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages?slug=${SLUG}&status=publish,draft")
PAGE_ID=$(echo "$EXISTING" | jq -r 'if type == "array" then .[0].id // empty else empty end')
PAYLOAD=$(jq -n \
--arg title "$TITLE" \
--arg slug "$SLUG" \
--arg content "$HTML_CONTENT" \
--argjson parent "$PARENT_ID" \
'{title: $title, slug: $slug, content: $content, parent: $parent, status: "publish"}')
if [ -n "$PAGE_ID" ] && [ "$PAGE_ID" != "null" ]; then
echo " Updating existing page (ID: $PAGE_ID)..."
HTTP_CODE=$(curl -s -o /tmp/wp-response.json -w '%{http_code}' -X POST \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$WP_REST_URL/wp/v2/pages/$PAGE_ID")
else
echo " Creating new page..."
HTTP_CODE=$(curl -s -o /tmp/wp-response.json -w '%{http_code}' -X POST \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$WP_REST_URL/wp/v2/pages")
fi
RESPONSE=$(cat /tmp/wp-response.json)
if [[ "$HTTP_CODE" -ge 400 ]]; then
echo " ERROR: HTTP $HTTP_CODE"
echo " Response: $RESPONSE"
exit 1
fi
PAGE_ID=$(echo "$RESPONSE" | jq -r '.id')
echo " Deployed (page ID: $PAGE_ID)."
DEPLOYED_IDS="${DEPLOYED_IDS:+$DEPLOYED_IDS }${PAGE_ID}"
done
echo "deployed_ids=$DEPLOYED_IDS" >> "$GITHUB_OUTPUT"
echo ""
echo "All changed documents deployed."
- name: Translate deployed pages
if: steps.deploy.outputs.deployed_ids != ''
id: translate
env:
WP_REST_URL: ${{ vars.WP_REST_URL }}
WP_APP_USERNAME: ${{ secrets.WP_APP_USERNAME }}
WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
DEPLOYED_IDS: ${{ steps.deploy.outputs.deployed_ids }}
run: |
TARGET_LANGS=("it" "es" "fr" "pt" "de")
# Capture the moment we start enqueueing so the verify step can
# confirm each translation post's modified_gmt advanced past it.
DEPLOY_START_GMT=$(date -u +'%Y-%m-%dT%H:%M:%S')
echo "deploy_start_gmt=${DEPLOY_START_GMT}" >> "$GITHUB_OUTPUT"
echo "Deploy start (UTC): $DEPLOY_START_GMT"
TRANSLATED_IDS=""
for PAGE_ID in $DEPLOYED_IDS; do
echo "Translating page ID $PAGE_ID..."
for LANG in "${TARGET_LANGS[@]}"; do
RESPONSE=$(curl -s -f -X POST \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
-H "Content-Type: application/json" \
-d "{\"source_id\": ${PAGE_ID}, \"target_lang\": \"${LANG}\"}" \
"$WP_REST_URL/cdcf/v1/translate")
TRANSLATED_ID=$(echo "$RESPONSE" | jq -r '.post_id // empty')
MESSAGE=$(echo "$RESPONSE" | jq -r '.message // empty')
if [ -n "$TRANSLATED_ID" ]; then
echo " ${LANG}: page ID ${TRANSLATED_ID} -- ${MESSAGE}"
TRANSLATED_IDS="${TRANSLATED_IDS:+$TRANSLATED_IDS }${TRANSLATED_ID}"
else
echo " ${LANG}: translation enqueue failed"
echo " Response: $RESPONSE"
fi
done
done
echo "translated_ids=$TRANSLATED_IDS" >> "$GITHUB_OUTPUT"
echo ""
echo "All translations enqueued."
- name: Verify translations completed
if: steps.translate.outputs.translated_ids != ''
env:
WP_REST_URL: ${{ vars.WP_REST_URL }}
WP_APP_USERNAME: ${{ secrets.WP_APP_USERNAME }}
WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
DEPLOY_START_GMT: ${{ steps.translate.outputs.deploy_start_gmt }}
TRANSLATED_IDS: ${{ steps.translate.outputs.translated_ids }}
run: |
# Poll each translation page's modified_gmt until it advances past
# DEPLOY_START_GMT. The /translate endpoint is fire-and-forget, so
# without this step a silent worker failure (OpenAI timeout, missing
# API key, dead Redis worker) lets the deploy report success while
# leaving translations stale.
MAX_ATTEMPTS=60 # 60 x 30s = 30 minutes
SLEEP_SECONDS=30
declare -A PENDING
for ID in $TRANSLATED_IDS; do PENDING[$ID]=1; done
INITIAL_COUNT=${#PENDING[@]}
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
REMAINING=()
for ID in "${!PENDING[@]}"; do
# `|| echo '{}'` keeps `set -e` from killing the whole step on
# transient curl failures (we observed exit 56 / CURLE_RECV_ERROR
# mid-poll). `--retry` handles short network blips at the curl
# layer; the `|| echo` is the safety net for everything else.
RESPONSE=$(curl -s --max-time 15 --retry 3 --retry-delay 5 --retry-all-errors \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages/${ID}?_fields=id,modified_gmt,status&context=edit" \
|| echo '{}')
MODIFIED=$(echo "$RESPONSE" | jq -r '.modified_gmt // empty' 2>/dev/null || echo "")
STATUS=$(echo "$RESPONSE" | jq -r '.status // empty' 2>/dev/null || echo "")
# modified_gmt is ISO 8601 with no offset; lexicographic compare
# against DEPLOY_START_GMT (also UTC, no offset) is correct.
if [ -n "$MODIFIED" ] && [[ "$MODIFIED" > "$DEPLOY_START_GMT" ]]; then
echo " page ${ID} translated (modified_gmt=${MODIFIED}, status=${STATUS})"
else
REMAINING+=("$ID")
fi
done
unset PENDING
declare -A PENDING
for ID in "${REMAINING[@]:-}"; do
[ -n "$ID" ] && PENDING[$ID]=1
done
if [ "${#PENDING[@]}" -eq 0 ]; then
echo "All translations completed."
exit 0
fi
COMPLETED=$((INITIAL_COUNT - ${#PENDING[@]}))
echo "Attempt ${attempt}/${MAX_ATTEMPTS}: ${COMPLETED}/${INITIAL_COUNT} done, ${#PENDING[@]} pending - sleeping ${SLEEP_SECONDS}s..."
sleep "$SLEEP_SECONDS"
done
echo ""
echo "ERROR: the following translation posts did not update within $((MAX_ATTEMPTS * SLEEP_SECONDS))s of deploy start:"
for ID in "${!PENDING[@]}"; do
echo " - page ID ${ID}"
done
echo ""
echo "Likely causes: OpenAI timeout/error, Redis Queue worker not running,"
echo "or missing/invalid cdcf_openai_api_key. Check WordPress error_log."
exit 1
- name: Remove stale WordPress pages
if: steps.changes.outputs.deleted_docs != ''
env:
WP_REST_URL: ${{ vars.WP_REST_URL }}
WP_APP_USERNAME: ${{ secrets.WP_APP_USERNAME }}
WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
run: |
STALE_SECTIONS=()
for DOC in ${{ steps.changes.outputs.deleted_docs }}; do
SECTION=$(dirname "$DOC")
BASENAME=$(basename "$DOC")
if [ "$SECTION" = "standards" ]; then
SLUG="standards-${BASENAME}"
else
SLUG="$BASENAME"
fi
echo "Checking for stale page: ${SLUG}..."
EXISTING=$(curl -s \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages?slug=${SLUG}&status=publish,draft")
PAGE_ID=$(echo "$EXISTING" | jq -r 'if type == "array" then .[0].id // empty else empty end')
if [ -n "$PAGE_ID" ] && [ "$PAGE_ID" != "null" ]; then
echo " Trashing page ID $PAGE_ID (slug: ${SLUG})..."
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages/$PAGE_ID")
if [[ "$HTTP_CODE" -lt 300 ]]; then
echo " Trashed."
else
echo " Warning: delete returned HTTP $HTTP_CODE"
fi
else
echo " No WordPress page found — skipping."
fi
# Track sections that had deletions
if [[ ! " ${STALE_SECTIONS[*]} " =~ " ${SECTION} " ]]; then
STALE_SECTIONS+=("$SECTION")
fi
done
# If an entire section directory no longer exists, trash the parent page
for SECTION in "${STALE_SECTIONS[@]}"; do
if [ ! -d "$SECTION" ]; then
echo "Section '${SECTION}' no longer exists — checking for stale parent page..."
PARENT=$(curl -s \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages?slug=${SECTION}&status=publish,draft")
PARENT_ID=$(echo "$PARENT" | jq -r 'if type == "array" then .[0].id // empty else empty end')
if [ -n "$PARENT_ID" ] && [ "$PARENT_ID" != "null" ]; then
echo " Trashing parent page ID $PARENT_ID (slug: ${SECTION})..."
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
-u "$WP_APP_USERNAME:$WP_APP_PASSWORD" \
"$WP_REST_URL/wp/v2/pages/$PARENT_ID")
if [[ "$HTTP_CODE" -lt 300 ]]; then
echo " Trashed."
else
echo " Warning: delete returned HTTP $HTTP_CODE"
fi
fi
fi
done
echo ""
echo "Stale page cleanup complete."
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '24'
cache: 'npm'
- name: Install npm dependencies
run: npm ci
env:
PUPPETEER_SKIP_DOWNLOAD: 'true'
- name: Build standalone HTML pages
run: npm run build:html
- name: Build combined PDF
run: npm run build:pdf
env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/google-chrome-stable
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${GITHUB_REF_NAME}"
# For manual triggers, auto-determine tag
if [[ "$TAG" != v* ]]; then
LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
TAG="v0.1"
else
MAJOR=$(echo "$LATEST_TAG" | sed 's/^v//' | cut -d. -f1)
MINOR=$(echo "$LATEST_TAG" | sed 's/^v//' | cut -d. -f2)
if [ "$MINOR" -ge 9 ]; then
TAG="v$((MAJOR + 1)).0"
else
TAG="v${MAJOR}.$((MINOR + 1))"
fi
fi
gh api repos/${{ github.repository }}/git/refs \
-f ref="refs/tags/$TAG" \
-f sha="$(git rev-parse HEAD)"
fi
# Collect all build artifacts
ASSETS=()
for f in dist/*.html dist/*.pdf; do
[ -f "$f" ] && ASSETS+=("$f")
done
if gh release view "$TAG" &>/dev/null; then
echo "Release $TAG already exists — uploading assets."
gh release upload "$TAG" "${ASSETS[@]}" --clobber
else
gh release create "$TAG" \
--title "Governance Docs $TAG" \
--notes "CDCF governance documentation, $TAG." \
"${ASSETS[@]}"
fi