Skip to content

Rebuild Content Workflow โ€‹

โš ๏ธ Always use this workflow โ€” never npm run publish-content from a workstation. Until #672 shipped, a stale local .tutorial-cache/ silently regressed CI-published content. With the staleness guard in place the worst case is caught server-side, but a workstation publish still skips fetch, Hugo build, and validation. Use gh workflow run rebuild-content.yml -f mode=full (or -f slug=โ€ฆ for one-tutorial fixes).

GitHub Actions workflow that fetches tutorial markdown, builds Hugo, and publishes HTML to HANA. Three rebuild scopes (modes), each tuned for a different write pattern. Auto-classified by admin writes; manual dispatches auto-infer when a slug is set.

Workflow file: .github/workflows/rebuild-content.yml Dispatcher (admin path): srv/lib/rebuild-trigger.js โ†’ srv/lib/_classify-rebuild-mode.js Related issues: #429 (3-mode classifier), #433 (multi-slug filter), #609 (auto-infer), #613 (Phase 2 prefetch scoping).

TL;DR โ€‹

You want toRunWall-clock
Republish one tutorial (parser fix, content typo)gh workflow run rebuild-content.yml --ref main -f slug=$SLUG~2 min
Rebuild after admin Mission / Group / Homepage / VerbDefinitions / etc. saveNothing โ€” admin auto-triggers mode=catalog-only after a 60s debounce~5 min
Full rebuild (tutorial fetch + dependency bump + Vue island change)gh workflow run rebuild-content.yml --ref main -f mode=full~10 min

Manual gh workflow run ... -f slug=X calls auto-infer mode=slug-targeted since #610 โ€” you do NOT need to also pass -f mode=slug-targeted. The ::notice:: annotation at the top of the run UI reads slug-targeted (auto-inferred (slug/slugs set, mode left at default)) when this fires.

The three modes โ€‹

catalog-only โ€‹

For admin saves of catalog data (Missions, Groups, CompletionPaths, FeaturedTasks, HomepageShelves, VerbDefinitions, ShelfDefinitions, Advocates, Concepts, etc.) โ€” these mutate the /build/* outputs Hugo bakes into hugo/data/ and the homepage / mission / group landing pages. Tutorial-page content does not change.

Steps that run: Install deps โ†’ Restore cache โ†’ Fetch concepts โ†’ Fetch homepage shelves โ†’ Fetch verb definitions โ†’ Fetch shelf definitions โ†’ Build Hugo โ†’ Publish to HANA โ†’ Build Vue apps โ†’ Joule vendor โ†’ Build display โ†’ Build admin SPAs โ†’ Assemble static content โ†’ AppRouter push.

Steps that skip: Fetch tutorials (no GitHub round-trip for tutorial markdown), Lint, Validate, AI VCAP, Purge orphan tutorials.

Why the Vue/admin/display/push steps run on catalog-only as of 2026-06-30: the visitor-observable result of an admin save (e.g. HomepageShelves edit) is the homepage HTML in the approuter's static dir. That dir is replaced atomically by the AppRouter push step. Before 2026-06-30 those steps were full-only, so admin saves updated the DB but never reached visitors until someone manually ran a full rebuild. Broadening to catalog-only costs ~4 min wall-clock per admin save but actually delivers the change.

When to dispatch manually: rare. Admin writes auto-classify to this mode via the entity sets in srv/lib/_classify-rebuild-mode.js.

slug-targeted โ€‹

For one or a few tutorial fixes (parser fix, content typo, single-author edit). Uses slug / slugs inputs to narrow Phase 2 metadata prefetch + Phase 3 markdown processing to only the listed slug(s).

Steps that run: everything catalog-only runs, plus Fetch (filtered), Lint (full โ€” fast), Validate (full โ€” fast), AI VCAP if AI authoring is on.

Steps that skip: Build Vue apps, Joule vendor, Build display, Build admin SPAs, Assemble, tarball, AppRouter push.

Auto-classified by admin writes for: Tutorials and Steps CRUD (single slug), Tags CRUD (reverse-lookup of all tutorials carrying the tag, capped at 50 โ€” beyond that, falls back to full + force-cap-refetch=true).

โš ๏ธ Trap: unshipped CSS-fingerprint template changes (#1622). Because slug-targeted skips the AppRouter push, it re-renders + publishes tutorial HTML but does not refresh the approuter static. Hugo content-hash-fingerprints its CSS (head.html / baseof.html: sap-fundamental.<hash>.css, joule.<hash>.css, โ€ฆ). If any CSS-fingerprinting template change (e.g. #1605) has landed since the last full deploy to the target env, the freshly-published HTML references a hash the deployed approuter lacks โ†’ the stylesheet 404s โ†’ pages render unstyled (silent: publish + verify-rows still go green; the 404 is only visible in a browser). A slug-targeted rebuild is therefore unsafe when a CSS-fingerprint template change is unshipped to the approuter โ€” do a full build + deploy first, then run the slug rebuild.

The workflow now guards this automatically: the "Guard - approuter serves referenced CSS (slug-targeted)" step (scripts/check-approuter-assets.cjs) probes the target approuter for every /css asset the rendered HTML references and fails the run before publishing to HANA if any is missing โ€” so the DB is never poisoned with HTML the approuter can't dress. (CSS-only; JS island bundles are guarded on the deploy path via deploy-mta.cjs Step 2.5 / #1604.) If it trips, run a full deploy to that env and re-run.

Manual dispatch:

bash
# Single slug
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f slug=tutorial-platform-feature-cookbook

# Multiple slugs (comma-separated)
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f slugs="foo,bar,baz"

# Union of both inputs
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f slug=foo -f slugs="bar,baz"

The slug filter validates upfront against discovery โ€” if you typo a slug, the run fail-fasts with the list of all unknown slugs in one rerun (per #433 spec).

full โ€‹

For dependency bumps, Vue island changes, AppRouter static-asset changes, or the scheduled nightly. Re-fetches every tutorial markdown, rebuilds every Vue island, every admin SPA, the display app, and pushes a fresh approuter tarball.

Steps that run: everything.

Manual dispatch:

bash
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f mode=full

You can also force the catalog snapshot to refetch (24h TTL cache otherwise) when the data driving /build/catalog has changed and you want the rebuild to pick it up immediately:

bash
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f mode=full -f force-cap-refetch=true

Measured wall-clock (verified 2026-06-24) โ€‹

These numbers come from real runs against main (PR #615 measurement section). Times include GH Actions runner startup overhead (~30-40s for setup + checkout + Hugo install + npm ci) which is the floor for any mode.

ModeEnd-to-endFetch stepPublish step
catalog-only1m 04sskipped~3s
slug-targeted (post #613)2m 22s~55s~3-21s
full~10 min~5-6 minvaries

Pre-#613, slug-targeted was clocking 7m 14s because Phase 2 (GitHub metadata prefetch) ignored the slug filter. The fix scoped Phase 2 to only fetch metadata for in-filter slugs. Time dropped from 4m 40s to 932ms inside Phase 2.

Admin auto-trigger flow โ€‹

When an admin saves through AdminService, srv/lib/rebuild-trigger.js's scheduleRebuild() is called with the classifier's recommended mode, slug (if any), and forceCapRefetch flag. The dispatch is debounced for 60 seconds โ€” multiple admin writes within the window coalesce into one rebuild via mode-priority merging:

  1. full > slug-targeted > catalog-only (higher RANK wins)
  2. Slugs accumulate up to 50; beyond that, the trigger upgrades to full and clears the slug set (configurable cap is YAGNI โ€” bulk admin operations >50 in a 60s window should set x-migration-mode to skip the trigger entirely).

Token sourcing is credstore-first with env fallback (srv/lib/secret-resolver.js, 5-min TTL cache). The GITHUB_DISPATCH_TOKEN secret is bootstrapped via the admin Secrets UI; see secrets-tracking.md ยง Bootstrap GITHUB_DISPATCH_TOKEN.

The dispatcher's mode-classification matrix lives in srv/lib/_classify-rebuild-mode.js:

Entity / ActionModeNotes
Missions CRUDcatalog-only
Groups CRUDcatalog-only
CompletionPaths / CompletionPathItems CRUDcatalog-only
GroupPathItems CRUDcatalog-only
FeaturedTasks CRUDcatalog-only
Tutorials CRUDslug-targetedslug resolved from row
Steps CRUDslug-targetedslug resolved via Step.tutorial_ID โ†’ Tutorials.slug
Tags CRUDslug-targetedreverse-lookup via TutorialTags junction; falls back to full + force-cap-refetch if 0 or >50 slugs
classifyCategories actioncatalog-only
setFeaturedOrder actioncatalog-only
commitTagImport actionfull + force-cap-refetch=truebulk Tag creation
cleanupUnusedTags actionfull + force-cap-refetch=truebulk Tag deletion
Anything elsefull (defensive default)

If an admin save fires a rebuild, you'll see it on the workflow's run list within ~60s with trigger-source: admin-write:the reason. The notice at the top of the run UI confirms the resolved mode.

Drift attribution โ€‹

Every publish now records its initiator on ContentManifest.initiator and PipelineLog.initiator. Format:

  • Workstation: <user>@<hostname> (auto-computed from os.userInfo() + os.hostname())
  • CI: ci/<github_run_id> (passed explicitly from rebuild-content.yml / rebuild-content-qa.yml)

To see who did the most recent N publishes:

sql
SELECT VERSION, STATUS, TRIGGER, INITIATOR, MODIFIEDAT
  FROM COM_SAP_DEVELOPERS_IMS_CONTENTMANIFEST
 ORDER BY VERSION DESC
 LIMIT 20;

Or via the admin Pipeline Log tile (/admin-ui/#pipelinelog-display) โ€” the Initiator column shows the same value joined by PipelineLog.ID = ContentManifest.sessionId.

If a daily content-drift check reports drifted slugs, the first forensic step is:

  1. Find the publish that introduced the regression: SELECT VERSION, INITIATOR FROM COM_SAP_DEVELOPERS_IMS_CONTENTMANIFEST ORDER BY VERSION DESC LIMIT 10.
  2. If INITIATOR is ci/<run_id>, the regression came from CI โ€” pull the workflow log.
  3. If INITIATOR is <user>@<hostname>, talk to that person. The most likely cause is a workstation publish from a stale .tutorial-cache/.

Historical rows (pre-#672) have INITIATOR = NULL and are not attributable โ€” that's intentional, not a bug.

Manual dispatch โ€” UX gotchas โ€‹

Auto-infer (since #610 / PR #610) โ€‹

When invoking via workflow_dispatch with inputs.mode at its full default AND inputs.slug or inputs.slugs set, the workflow's Determine effective rebuild mode step auto-infers mode=slug-targeted. The repository_dispatch path (admin auto-trigger) is NEVER overridden โ€” the admin classifier is authoritative.

Resolution surfaced two ways:

  1. ::notice:: annotation at the top of the run UI: Rebuild mode: the resolved mode (the reason)
  2. Summary panel at the bottom of the run: **Effective mode:** \the mode` โ€” the reason`

What if force-cap-refetch is set? โ€‹

force-cap-refetch=true bypasses the .tutorial-cache/ CAP catalog snapshot (24h TTL) and re-fetches /build/catalog fresh. Use after fixing data the catalog reads from (publish flag, slugs, etc.) so the rebuild reflects the change in the same run instead of waiting for the cache to expire.

Compatible with any mode. Admin classifier sets it automatically when classifying Tags CRUD that resolves to full (couldn't reverse-look up slugs) or for the commitTagImport / cleanupUnusedTags bulk actions.

AI quiz authoring โ€‹

ai-author-enabled=true (the default) wakes up the AI generator on tutorials carrying [AUTOAUTHOR_*] directives in their rules.vr. Requires AI_AUTHOR_AICORE_SERVICE_KEY + CHAT_DEPLOYMENT_ID secrets. See ai-author-ci-setup.md.

Flip to false only to bypass during incident triage. The mode=catalog-only path always skips AI authoring regardless of this flag (catalog-only doesn't fetch markdown so there's no [AUTOAUTHOR_*] to expand).

Publish concurrency / batch-size tuning โ€‹

publish-concurrency (default 6) is the number of /content/publish/append batches in flight at once. publish-batch-size (default 50) is slugs per batch. Multiplied gives total in-flight slugs (default 6ร—50 = 300 โ€” the catalog has ~1400 slugs, so smaller batches just take longer linearly, not exponentially).

Lower if srv hits HeadersTimeoutError. Both defaults raised from 4/25 to 6/50 in #434 PR 2 after 9 stable runs at the prior levels. See #420 for the planned worker_threads change that would unlock higher concurrency.

Troubleshooting โ€‹

Guard failed: "the target approuter does NOT serve CSS the rendered HTML references" (#1622) โ€‹

The slug-targeted CSS guard (scripts/check-approuter-assets.cjs) found /css/<name>.<hash>.css in the freshly-rendered HTML that 404s on the target approuter. This means a CSS-fingerprinting template change has landed since the last full deploy to that env, and the slug rebuild โ€” which never pushes approuter static โ€” would have shipped HTML pointing at stylesheets the approuter lacks (see the trap note under slug-targeted).

Fix: run a full build + deploy to that env (npm run deploy -- --env <env>, no --skip-build), which copies hugo/public/css/* into the approuter static. Then re-run the slug rebuild. The guard runs before the HANA publish, so a failed run leaves the DB untouched. (If the guard instead logs INCONCLUSIVE and passes, it means the approuter returned no 200 for any /css probe โ€” a gated preview channel, wrong URL, or the approuter is down โ€” which is not the fingerprint-drift signature and is not the slug rebuild's fault.)

"HTTP 409: Another publish in progress" โ€‹

Two publishes hit the srv at the same time. The srv enforces serial publishes via a session lock โ€” second and third dispatches fail with this 409. Re-dispatch one at a time. (Surfaced during the measurement work for #609 โ€” three parallel runs ran fine through Hugo but only one made it past Publish.)

Slug filter rejected at discovery โ€‹

If a slug is in slug / slugs but not in the discovery snapshot, the run fail-fasts with ERROR: N unknown slug(s) in filter: the list so you fix typos in one rerun. Cause is usually a typo or a tutorial that hasn't been merged to its source repo yet.

"Rebuild mode: full (explicit ...)" when I expected slug-targeted โ€‹

Auto-infer fires only when inputs.mode is left at the default full. If you explicitly pass -f mode=full -f slug=X from the CLI, you've told the workflow "no really, run a full rebuild" and slug is ignored for mode-selection (the slug input is still honored for Phase 2 / Phase 3 scoping, but every other step also runs).

To get the auto-infer behavior, drop -f mode=full entirely.

Admin auto-trigger didn't fire after my save โ€‹

Check:

  1. Was the entity in _classify-rebuild-mode.js's known set? If not, the classifier falls through to full (which still fires).
  2. Did GITHUB_DISPATCH_TOKEN resolve? srv/lib/rebuild-trigger.js's boot log emits [rebuild-trigger] active โ€” admin writes will dispatch (...) on success, or [rebuild-trigger] GITHUB_DISPATCH_TOKEN unreachable from credstore or env โ€” admin writes will not trigger rebuilds. on failure. Set via /admin-ui/#secrets-display.
  3. Was the 60s debounce window already pending? Repeated admin saves within the window merge into one dispatch โ€” check the next workflow run for your edit.
  4. Did the admin save use x-migration-mode: true header? Migrator REST calls deliberately skip the trigger to avoid burst-dispatching during bulk imports.

When to run purge-orphans โ€‹

The purge-orphans=true workflow input batches the soft-delete operation the admin Tutorials Fiori app performs one-at-a-time. It targets tutorials whose source markdown is no longer present in any upstream repo โ€” the daily content-drift workflow surfaces these as "missing locally" slugs.

When to use it โ€‹

  • The drift report consistently shows โ‰ฅ20 missing-locally slugs.
  • You've inspected the list (artifact content-drift-<env>-<run_number>) and confirmed they are genuinely orphaned, not the result of a fetch regression.
  • You want a one-shot cleanup rather than 20+ clicks in the admin Tutorials app.

When NOT to use it โ€‹

  • fetch-tutorials recently changed โ€” verify the discovery output first.
  • The drift count jumped overnight โ€” that's a fetch problem, not real orphans. Fix the fetch first.
  • You're trying to "unpublish" a single tutorial โ€” use the admin Tutorials app at /admin-ui/#tutorials-display.

How โ€‹

bash
gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main \
  -f mode=full -f purge-orphans=true

The workflow:

  1. Runs full-mode fetch + publish first (the cache is what defines "orphan").
  2. Then the gated Purge orphan tutorials step runs publish-content.ts --purge-orphans.
  3. Result lands in $GITHUB_STEP_SUMMARY as a "๐Ÿงน Orphan purge โ€” full mode" block.

The step is gated on three conditions:

  • inputs.purge-orphans == true
  • steps.mode.outputs.effective_mode == 'full' โ€” slug-targeted / catalog-only modes are rejected at the mode-determine step with an explicit ::error annotation, so misconfigurations fail fast instead of silently no-op-ing
  • steps.publish.outcome == 'success' โ€” if publish fails (network blip, validator error), the server's /content/source-hashes may be in a transient state; skip purge until the operator fixes the underlying issue and re-runs

Safety caps โ€‹

  • Client: 50 absolute orphans. If exceeded, the step fails before any HTTP traffic. Override via PURGE_CAP_ABS env in the workflow file.
  • Server: 100-slug ceiling. Server returns 400 if the client cap was loosened past this point. Split into multiple calls (or raise the server ceiling in a separate change).

Auth โ€‹

The CLI sends Authorization: Bearer $CONTENT_API_KEY โ€” same secret as /content/publish. The endpoint is POST /content/orphan-purge (bare-Express + contentAuthMiddleware), NOT a CAP AdminService action. AdminService is XSUAA-scope-gated and CI doesn't carry an XSUAA bearer.

Rollback (orphan-purge) โ€‹

This section covers orphan-purge rollback only โ€” reversing a mis-purge (bad soft-delete). For rolling back a content publish (reverting ContentManifest.status to a prior SUPERSEDED version), see the content-rollback runbook โ€” different endpoint (/content/rollback), different lifecycle.

See the orphan-purge design ยง Rollback โ€” uses change-tracking + PipelineLog to enumerate which rows flipped during a given CI run, so a mis-purge can be reversed by re-flipping those exact rows back to ACTIVE.