Skip to content

release: v1.40.0 (DEV→main promotion) - #2825

Merged
jung-thomas merged 77 commits into
mainfrom
release/v1.40.0
Oct 17, 2026
Merged

jung-thomas merged 77 commits into
mainfrom
release/v1.40.0

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

Release v1.40.0 — DEV → main promotion

Promotes DEV to main for the next PROD deploy. Bumps the deploy version
1.39.1 → 1.40.0 (.deploy/mta.yaml) and refreshes the public What's New
change log (hugo/data/whats_new.json) with the 16 PRs merged since 1.39.1.

Minor bump — two user-facing features landed alongside fixes.

Features

Fixes & improvements

Changelog

Verification

  • .deploy/mta.yaml parses; version = 1.40.0.
  • Digest is valid JSON; 16 ids added, 0 removed, all pre-existing entries byte-identical.
  • build:whats-new-snapshot runs clean; srv snapshot matches source.

⚠️ Merging this does not redeploy — the MTA deploy to PROD is a separate, explicitly-authorized step.

🤖 Generated with Claude Code

jung-thomas and others added 30 commits October 15, 2026 16:52
…pendent

The new URL Health admin tile (app/admin/health) failed to render with a
runtime ModuleError: "failed to load sap/ui/model/odata/v4/library.js ...
script load error". Three defects compounded:

1. manifest + (intended) ui5.yaml listed `sap.ui.model.odata.v4` under
   dependency libs. That is NOT a SAPUI5 framework library — the OData V4
   model ships inside `sap.ui.core`. `ui5 build` can't resolve it
   ("Could not find library"), so copy-components.js silently fell back from
   the preload build to a raw copy that ships NO Component-preload.js.
   UI5 then lazy-loaded each declared lib from the CDN at runtime, and the
   bogus odata/v4 path 404'd → the ModuleError.

2. app/admin/health had no ui5.yaml AND no package.json. `ui5 build` needs
   both; without them it walked up to app/admin (tutorials-admin-fiori,
   ui5:null) and failed project resolution — another path to the same raw-copy
   fallback.

Fix: add app/admin/health/{ui5.yaml,package.json} matching the sibling apps,
and drop the non-existent sap.ui.model.odata.v4 lib from both the manifest and
ui5.yaml (sap.m + sap.ui.core only). Result: 52/52 admin components now
preload-build (was 51/52, health raw-copied); health ships a real
Component-preload.js and no longer hits the CDN.

Guard so this class can't ship silently again: discoverComponents() now throws
at build time when a non-excluded admin component lacks its ui5.yaml, with a
message pointing at the raw-copy/CDN failure mode and the PRELOAD_EXCLUDE
opt-out. preload-components.cjs threads PRELOAD_EXCLUDE through so excluded
tiles stay exempt. Unit tests cover the real tree, a throwing fixture, and the
exempt path.
…reload

fix(admin-health): make URL Health tile preload-buildable, not CDN-dependent
… formatter

The URL Health tile rendered no further than instantiation: the Last Checked
cell used an expression binding `{= ${lastChecked} ? new Date(${lastChecked})
.toLocaleString() : '—' }`. UI5 expression-binding grammar forbids `new`, so
the XMLTemplateProcessor threw at render time:

  SyntaxError: Expected : but instead saw Date
  → routing target 'healthTarget' could not be displayed

(This was the second, independent defect behind #2796: the CDN/preload fix let
the view load far enough to finally hit this binding error.)

Fix: move the date formatting into a controller formatter `formatTimestamp`
(mirrors the existing `formatBadSlots` pattern) and bind
`{ path: 'lastChecked', formatter: '.formatTimestamp' }`. Null/invalid dates
render the em-dash, same as before.

Verified by instantiating the real view in headless Chromium against UI5
1.148.12 (playwright-core, the repo's test/e2e/_browser.js pattern): the
XMLView now creates with no binding error and the Last Checked cell renders a
formatted date ("10/15/2026, 2:00:00 PM") — the formatter runs. The kept `{= }`
expression bindings (!!, ===-ternary) parse fine.
…binding

fix(admin-health): replace illegal new-Date expression binding with a formatter
The 'Rebuild this tutorial' object-page button gave no feedback and
looked like it didn't fire. Root cause: it was rendered from a
@UI.DataFieldForAction annotation, so Fiori Elements V4 invoked the
bound rebuildContent action through its generic path and never called
the SourceMarkdownHandler.onRebuildTutorial controller extension — the
code that owns the confirm dialog, success/error MessageBox, and
context refresh. The POST went out and returned 200 silently.

Fix (AskJoule pattern — the one custom header action that works here):
- manifest.json: add content.header.actions.RebuildTutorialAction with
  press -> SourceMarkdownHandler.onRebuildTutorial.
- admin-annotations.cds: drop the @UI.DataFieldForAction for
  rebuildContent (keeps checkFreshness) so the button isn't rendered
  twice. The bound-action FQN form was correct; do NOT revert to the
  slash form (that is the featureFlags 'Unknown action import' break).
- onRebuildTutorial: take the press event, disable the triggering
  button across the dispatch round-trip (re-enable on resolve/reject)
  so the same slug can't be re-submitted immediately, and surface the
  server's existing  flag ('already in progress') instead of
  implying a fresh dispatch.
- i18n: add RebuildTutorialToastDebounced.

Server rebuildContent action + rebuild-trigger.js unchanged; its unit
test (admin-rebuild-tutorial.test.js) stays green (5 passed).

Admin-UI change -> needs a FULL deploy (bundle-gated Step 3.5) to verify
in-browser on DEV.
…feedback

fix(admin-ui): wire Rebuild-tutorial button to its handler + feedback
The static guard check-ui5-controller-extensions failed on #2799: the
manifest press ref RebuildTutorialAction pointed at
...ext.SourceMarkdownHandler.onRebuildTutorial, but that handler lived
only in SourceMarkdownHandler.controller.js. FE V4 resolves a manifest
press reference as a PLAIN module (loader path <name>.js), not a
controller extension (<name>.controller.js) — so the click would 404
at runtime and the guard blocks the build.

Fix (Concepts #537 / AskJoule #405 canonical pattern):
- Add a plain SourceMarkdownHandler.js exporting onRebuildTutorial. The
  sibling .controller.js keeps the Source-Markdown SECTION logic; both
  share the base name and FE resolves each by suffix (same shape as the
  Concepts ConceptActionsController .js/.controller.js pair, and the
  manifest already registers SourceMarkdownHandler as controllerName).
- Correct the handler arg shape: a manifest press handler is invoked
  with the binding CONTEXT (or [context] / legacy event), NOT a UI
  event — resolveCtx() handles all shapes (my earlier getSource()-based
  button-disable could never have worked here).
- Debounce without a button: a manifest press handler isn't handed the
  triggering control, so replace button-disable with a module-level
  in-flight guard + the server's existing  result flag.
- Remove the now-duplicated onRebuildTutorial from the .controller.js.

check-ui5-controller-extensions now OK (54 manifests); full
static-guards exit 0; guard unit test + backend rebuild action test
green (21 pass).
…plainjs

fix(admin-ui): resolve Rebuild-button press to a plain .js module (fixes guards on #2799)
A tutorial-updated repository_dispatch that changed N>1 tutorials sent
no usable client_payload.slug, so rebuild-content.yml's mode step fell
through to the default 'full' mode — a ~10-min full-corpus rebuild
(whole-catalog fetch + publish + render-concepts over ~10.8k concepts)
for what was usually a handful of changed slugs. Single-slug pushes were
fine (~2 min); the variance was bimodal (slug-targeted vs demoted-full),
not latency.

Changes:
- publish-content.ts: add filterToSlugs() + --slugs/PUBLISH_SLUGS so a
  publish can scope to N slugs (O(N), not O(catalog)); the delta/
  carry-forward pipeline already handles any-size sets. Scope gate
  generalized from  to  (slug OR slugs) so the
  page/author/concept full-catalog merges stay off scoped publishes.
  allow-revert stays single-slug only.
- rebuild-content.yml: mode step validates client_payload.slugs (strict
  charset, each entry) → emits dispatch_slugs → slug-targeted; threads
  into fetch (TUTORIAL_SLUGS) + publish (PUBLISH_SLUGS). Emits a loud
  ::warning:: when a tutorial-updated dispatch has no usable slug/slugs
  (previously a silent demotion to full).
- rebuild-content-qa.yml: same client_payload.slugs wiring (fetch +
  publish), payload-only.
- notify-qa.yml.template: emit a comma-separated slugs payload for
  2..CAP(20) changed tutorials (single slug for N=1; empty → full for 0
  / >CAP / invalid charset).

Tests: filterToSlugs unit tests (TDD, 10 pass incl. dedup/blank-skip/
loud-fail-on-missing); content-delta suites green.

PROPAGATION NOTE: notify-qa.yml.template is a TEMPLATE — the live
per-repo push workflows across the sap-tutorials/* source repos must be
regenerated from it (and the prod tutorial-updated dispatcher template)
for the upstream slugs payload to actually flow. That rollout is a
separate step (provision-tutorial-repo / a sync). The receiver-side
changes here work the moment any dispatcher sends client_payload.slugs.

CI-only change (workflows + build script) — no app deploy required.
The publish-content-qa regression guard pinned the literal
`if (!opts.slug && channel === 'prod')`, but this PR generalized the scope
guard to `!isScoped` (--slug OR --slugs). Widen the regex to match either
form while keeping the load-bearing `channel === 'prod'` invariant strict —
the production gate (publish-content.ts:1580) still enforces prod-only
render-concepts, so this is a test-guard update, not a behavior change.

Also rebased onto current origin/DEV to pick up #2804 (the UI5
SourceMarkdownHandler.js fix), which clears the inherited guards failure.
…patch

fix(rebuild): keep multi-tutorial pushes slug-targeted instead of demoting to full
Bump deploy version 1.38.0 -> 1.39.0 (.deploy/mta.yaml) for the DEV->main
promotion and refresh the user-facing What's New digest
(hugo/data/whats_new.json) with 24 summarized PRs merged since 1.38.0.

Headline: URL Health subsystem for tutorials/missions/groups (#2787) —
nightly + on-demand broken/slow link detection and membership-integrity
reporting in a new Admin UI screen.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Bump deploy version 1.39.0 -> 1.39.1 (.deploy/mta.yaml) for the DEV->main
promotion and refresh the What's New digest (hugo/data/whats_new.json).

Patch release: carries #2796 — make the URL Health admin tile
preload-buildable (bundled into the admin UI, not CDN-dependent), a
follow-up to the URL Health subsystem shipped in 1.39.0 (not yet deployed
to PROD; this is the version that will deploy).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Read-only profiling spike. Per-phase wall-clock from PROD runs shows two
dominant costs on unscoped publishes (not the single conflated phase the
issue assumed):
- render-concepts ~95-106s: per-concept delta skip defeated by shell churn
  (full-doc hash embeds the __shell__, so any CSS/island/nav change flips all
  ~10,789 concept hashes). 10,620/10,789 'changed' on a 65-slug delta.
- append/commit ~130-165s: per-row N+1 SELECT-then-UPDATE-or-INSERT upserts
  (metadata/steps/meta/authorship/bodytext/branchspecs).
Corrects the issue's guessed defaults (actual 6/50, not 4/25); confirms the
#1932 carry-forward OOM fix still holds; flags a 361s image-refresh outlier.

Refs #2802
Two-level concept-phase delta (phase-gate via conceptPhaseToken + shell-only
re-compose via new bodyHash column), N+1 upsert batching in appendToSession,
and pre-session GET micro-wins. Corrects the spike's premise: the dominant
concept cost is buildConceptsPayload (~50-60s, 12 table scans) run every call,
not just the render loop — so the whole phase must be gated, not just the loop.

Refs #2802
11 TDD tasks: F1 concept-phase delta (schema, bodyHash, conceptInputDigest,
phase skip + shell guard, commit persistence), F2 batch the four N+1 upserts
(chunk 500, row-equivalence tests), F3 fold duplicate hashes GET + file
image-refresh issue. Verified against real code: carry-forward covers
concept-* keys, stale-chrome guard required, CommunityEventConceptLinks lacks
modifiedAt (uses extractedAt).

Refs #2802
…#2802)

Replace per-slug N+1 SELECT+write loops in upsertBodyTexts and
upsertBranchSpecs with a single batched SELECT IN per 500-slug chunk,
then one multi-row INSERT for new slugs and one UPDATE per existing
slug. The 500-item chunk ceiling matches the HANA packet-safe limit
established at content-publish-session.js:265. At scale (full rebuild
~7k slugs) this collapses ~14k DB round-trips to ~28 per entity.
`fetchRemoteHashes` now reads `X-Pipeline-Token` from the same response
and returns `{ hashes, pipelineToken }`, eliminating the separate
`fetchRemotePipelineToken` GET in the delta path of publish-content.ts.
`fetchRemotePipelineToken` had no other callers and is removed from the
import. The auto-verify call site destructures `.hashes` as before.
…up (#2802)

- scripts/publish-content.ts: capture rc.conceptPhaseToken from renderConceptsPhase
  result (localConceptPhaseToken); forward to commitSession alongside pipelineToken.
- scripts/lib/publish-client.ts: add conceptPhaseToken to CommitInput interface,
  commitSession POST body, and RenderConceptsResult; delete dead
  fetchRemotePipelineToken (0 callers).
- packages/content/content-store.js: commitHandler now destructures
  conceptPhaseToken from req.body and forwards it to sessionHelpers.commitSession.
- test/unit/commit-conceptphasetoken-wire.test.js: new wire test — Part A asserts
  publish-client includes conceptPhaseToken in POST body (fetch mock); Part B
  asserts commitHandler persists conceptPhaseToken on ContentManifest (CDS in-memory).
- test/unit/upsert-tutorial-metadata-batch.test.js: fix Task-8 assertion from 1 to 2
  rawSlugInSelects — linkTutorialAuthorship (Task 9) legitimately adds a second
  batched LOWER(slug) IN lookup; both are intentional N+1 wins.
- test/unit/concept-input-digest.test.js: remove unused vi import.
…ead (#2806)

- cds build --production: stage db/last-dev/csn.json + ContentFiles/
  ContentManifest hdbmigrationtable (additive ALTER ADD for bodyHash +
  conceptPhaseToken) — the check-cds-build-staging guard requires these
  committed alongside the schema change.
- publish-client.ts fetchRemoteHashes: optional-chain res.headers?.get so a
  Response without a headers object (pre-existing test mock, defensive in prod)
  returns pipelineToken null instead of throwing TypeError.
- publish-client.test.ts: update the two fetchRemoteHashes assertions to the
  new { hashes, pipelineToken } return shape (#2802 folded the token read in).

Refs #2802 #2806
perf(publish): concept-phase delta + batch N+1 upserts (#2802)
jung-thomas and others added 28 commits October 17, 2026 04:08
…ine)

Implements Task 2 of #2811 (tutorial variables).

- scripts/parsers/variables.ts: new module with TutorialVariable interface,
  VariablesValidationError class, validateVariables(), wrapTokens(),
  SENTINEL_RE, and sentinelFor() helper.

  validateVariables: hard-fails on duplicate name, undeclared body token,
  bad regex pattern, off-allowlist default.from, invalid type. Allows
  declared-but-unused variables (soft).

  wrapTokens: line-by-line via createFenceTracker. Outside fenced code
  (prose + inline code) → <span class="tut-var" data-tut-var="NAME">PLACEHOLDER</span>.
  Inside fenced code → TUTVARxNAMEx sentinel (Chroma-safe, lexer-opaque).

- scripts/parsers/types.ts: add variables? field to TutorialFrontmatter.

- scripts/parsers/compose.ts: add TutorialVariable[] to ComposeResult;
  call validateVariables after frontmatter extraction (hard-fail propagates
  through publish pipeline); apply wrapTokens to processedBody after all
  other body transforms and before step splitting.

- scripts/parsers/__tests__/variables.test.ts: 26 tests covering all
  validateVariables error paths, wrapTokens prose/inline/fenced cases,
  SENTINEL_RE/sentinelFor round-trip, and composeTutorial integration.

No-variables tutorials are completely untouched (zero-cost fast path).
In render-codeblock.html, capture the highlighted (or plain-text)
output into a variable before emitting, then apply replaceRE to
turn sentinel tokens `TUTVARx([A-Za-z0-9_]+)x` (emitted by the TS
parser for fenced-block occurrences) into
`<span class="tut-var" data-tut-var="NAME"></span>` before emitting
via safeHTML.

Covers both branches: Chroma-highlighted (lang != text) and plain-text
(lang == text). The span shape matches what the downstream island
queries via [data-tut-var]. Three security-reviewed: comments added
to satisfy check-hugo-safe-html.cjs (131 files scanned, OK).

Spike-verified: sentinel is one Chroma token in bash/js/text; replaceRE
is a no-op when no sentinel present so existing tutorials are unchanged.
Pattern `TUTVARx([A-Za-z0-9_]+)x` was greedy: on a line with two
sentinels back-to-back (e.g. `TUTVARxfooxTUTVARxbarx`) the `+`
consumed `fooxTUTVARxbar` through to the final `x`, producing one
garbled span instead of two.

Change `+` → `+?` (lazy) on both the text and highlight branches.
RE2 (Hugo's regex engine) supports lazy quantifiers. Verified with a
two-sentinel fixture: `cf deploy TUTVARxgroupNumberxTUTVARxappNamex.mtar`
renders as two separate spans with the literal `-` and `.mtar` intact.
[#2811] Task 4 — threads `variables?: TutorialVariable[]` from the
parser's ComposeResult through the Hugo page pipeline:

- `render-frontmatter.ts`: add `variables?: TutorialVariable[]` to
  `RenderHugoFrontmatterArgs`; import type from `variables.ts`; emit
  `fm.variables = args.variables` when non-empty (same guard style as
  `fm.osList`/`fm.hasAi`).

- `fetch-tutorials.ts` (`writeHugoPage`): add optional `variables`
  param; pass it into `renderHugoFrontmatter`; pass `composed.variables`
  at the call site.

- `u1-object-page.html`:
  - Config script: `<script id="tutorial-variables" type="application/json">`
    sibling to `tutorial-data`, guarded by `{{ with .Params.variables }}`.
  - Mount div: `<div id="tutorial-variables-panel" data-slug="…"
    data-mission-slug="…">` at the top of `<section id="op-steps">`,
    before the Steps heading and wizard — below description/prerequisites,
    above Step 1. Also guarded by `{{ with .Params.variables }}`.
  - Module script: `<script type="module" src="…tutorial-variables…">`
    guarded by `{{ if and .Params.variables … }}` so pages without
    variables ship zero extra fetches.

Island `tutorial-variables` does not exist yet (Task 5); `island-src.html`
falls back to `/js/tutorial-variables.js` which 404s until Task 5 ships
the bundle — intentional.

Note: Hugo's `.Params` YAML round-trip encodes the variables array as a
JSON string (same as the existing `steps`/`tutorial-data` pattern).
Task 5's island must double-parse `JSON.parse(JSON.parse(el.textContent))`
mirroring the `initDoneButtonGate` pattern in tutorial.ts:409-410.
Adds the tutorial-variables Vue island (Task 5):

- hugo-apps/src/tutorial-variables/substitution.ts — pure exported helpers
  applyValue() and resolveDefault() + TutorialVariable types; importable by
  vitest without DOM side-effects.
- hugo-apps/src/tutorial-variables/TutorialVariables.vue — panel with one
  labelled <input> per variable, identity default-filler (/auth/user,
  credentials:include, fail-open), live applyValue() on every keystroke.
  Two TASK-6 SEAM comments mark where Task 6 inserts readValue/writeValue.
- hugo-apps/src/tutorial-variables/main.ts — island entry: double-parse guard
  (matches validation/main.ts pattern), mounts into #tutorial-variables-panel.
- hugo-apps/src/tutorial-variables/__tests__/substitution.test.ts — jsdom
  unit tests (TDD: written first, confirmed failing before implementation);
  9 tests pass.
- hugo-apps/vite.config.ts — adds 'tutorial-variables' rollup entry.

Build: tutorial-variables-*.js emitted + island_manifest.json entry confirmed.
Tests: 9/9 pass (vitest --project unit, happy-dom).
Adds localStorage-backed persistence (Task 6) to the tutorial-variables
island so variable values survive browser restarts and carry across
tutorials within the same mission/group.

- New storage.ts: readValue / writeValue with key scheme
  tutvar:<scope>:<name>; mission key beats tutorial key; all
  localStorage access guarded in try/catch for private-mode safety.
- New __tests__/storage.test.ts: 9 happy-dom tests (mission carry-over,
  per-tutorial isolation, precedence, empty missionSlug edge cases).
- TutorialVariables.vue: wired at both TASK-6 SEAMs — onMounted reads
  stored values before identity defaults; onInput writes on every
  keystroke.
- Add TutorialVariables entity (cuid, LegacyKeyed) with HANA-safe column names
  (varName/varLabel/varType/maxLength/isRequired/placeholder/defaultFrom/
  defaultPattern/configJson/sortOrder) — avoids reserved words name/type/
  required/key/value/default/order
- Add Tutorials.variables Composition back-ref in db/schema.cds
- Add @cds.persistence.journal in db/persistence.cds for .hdbmigrationtable gen
- Create srv/lib/variables-publish.js: replaceVariablesForSlug (DELETE+INSERT
  in cds.tx, mirrors contributors-publish.js) + upsertTutorialVariables session helper
- Wire upsertTutorialVariables into appendToSession (packages/content/
  content-publish-session.js) after linkTutorialAuthorship — best-effort,
  errors swallowed so variable write never fails a content publish
- Add @readonly TutorialVariables projection to AdminService (srv/admin-service.cds)
- Hybrid test: test/hybrid/2811-tutorial-variables.test.js (isSafeForWrites guard)
- SQLite unit test: test/unit/variables-publish.test.js (6 tests, all pass)
- app/admin-annotations.cds: annotate AdminService.TutorialVariables with
  column labels and @UI.LineItem (varName, varLabel, varType, isRequired,
  sorted by sortOrder); add VariablesFacet ReferenceFacet to the Tutorials
  ObjectPage Facets list (after ContributorsFacet), mirroring the same
  pattern. CDS compile verified (exit 0, VariablesFacet in EDMX).

- test/e2e/tutorial-variables.spec.ts: Playwright e2e spec asserting panel
  presence, live substitution in prose/inline-code/fenced-code contexts,
  localStorage persistence across reload, and mission carry-over
  (it.skip with TODO). Self-skips without SMOKE_BASE_URL (exit 0, 4 skipped).
  Lists 3 active tests with dummy SMOKE_BASE_URL (exit 0).
…n; enforce number maxLength (#2811)

C1: wrapTokens now detects CommonMark inline-code spans (backtick runs) within
non-fenced lines and emits the TUTVARxNAMEx sentinel inside them instead of a
raw <span>. Goldmark HTML-escapes <code> content, so a raw <span> injected inside
backticks rendered as escaped literal text and substitution silently failed.

Client-side expandSentinels() in substitution.ts walks text nodes at island mount,
finds TUTVARxNAMEx literals, and replaces each with a real [data-tut-var] span
(built via createElement/setAttribute — zero XSS surface). Called from main.ts
before Vue mount so applyValue() finds all spans on first render.

I1: expandSentinels is covered by 8 new happy-dom unit tests in
hugo-apps/src/tutorial-variables/__tests__/substitution.test.ts, including the
exact da164-TUTVARxgroupNumberx@x -> da164-007@x flow, multiple sentinels, and
double-processing guard. Parser test updated: inline-code case now asserts sentinel
output (not span). All 52 variables-feature tests pass.

I2: onInput in TutorialVariables.vue clamps numeric field values to maxLength chars
and reflects back into the DOM input, since browsers ignore :maxlength on type=number.
Replace CF-egress-blocked InnerTube transcript fetch with official
YouTube Data API v3 captions.download (OAuth, SAP Developers channel).
Three-tier source resolution (uploaded/ASR captions -> description),
two-tier learner notice, warn-loud-and-continue on empty quiz.
…p + source tag

- Replace fetchTranscriptViaHttp HTTP round-trip with in-process call to
  fetchTranscript from srv/lib/youtube-official-transcript.js (Task 4).
  The old endpoint was blocked in CF during builds; the new impl uses the
  YouTube OAuth + Data API directly without needing a deployed CAP backend.

- Widen TranscriptFetcher type to include 'description' so it matches Task 4's
  three-tier return shape (uploaded|auto|description|none).

- Change drop logic: only source='none' (or missing/empty segments) drops the
  video quiz. 'description', 'uploaded', and 'auto' all flow through to
  generation. The drop now emits a loud greppable [ai-author][EMPTY-QUIZ]
  marker including the videoId for build-log searchability.

- Thread a per-step questionSource discriminator: 'captions' when transcript
  source is 'uploaded' or 'auto', 'description' when 'description'. Stamped
  onto emitted questions as the carrier; lifted to step.questionSource by
  fetch-tutorials.ts at the target.validation assignment site; stripped from
  individual questions in the anti-leak pass so it does not duplicate in
  every question object in public Hugo frontmatter.

- Add questionSource? field to TutorialStep type for Task 6 (Vue island).

- Add 5 new TDD tests covering: description flows through + stamps
  questionSource:description; uploaded/auto stamp captions; none emits
  [ai-author][EMPTY-QUIZ] + drops; short description drops via existing
  MIN_SUBSTANTIVE_WORDS guard (no duplication).
Renders a <p class="validation-source-notice"> below the quiz form when
questionSource is 'captions' or 'description', with verbatim copy per
the task brief.  Absent for hand-authored / non-video quizzes (undefined).

- StepData += questionSource? in main.ts; passed as new prop
- Props += questionSource? in Validation.vue (withDefaults: undefined)
- Template: <p v-if="props.questionSource"> after </form>, inside <template v-else>
- Style: .validation-source-notice (small, muted neutral color)
- Test: Validation.source-notice.test.ts (3 cases; TDD — red before green)
…OAuth secrets

- .github/workflows/rebuild-content.yml: tee fetch-tutorials stdout+stderr
  to /tmp/fetch-tutorials.log; add non-failing step "Surface dropped video
  quizzes (EMPTY-QUIZ)" (if: always()) that greps -F for the exact marker
  '[ai-author][EMPTY-QUIZ]' and appends any matches under a warning heading
  to $GITHUB_STEP_SUMMARY. Silent when clean; step never fails the job.

- docs/developers/operations/secrets-tracking.md: document three new BTP
  Credential Store aliases (YOUTUBE_OAUTH_CLIENT_ID, YOUTUBE_OAUTH_CLIENT_SECRET,
  YOUTUBE_OAUTH_REFRESH_TOKEN) — one-time OAuth consent as SAP Developers
  channel owner, rotated via /admin-ui/#secrets, consumed by the official
  YouTube captions.download transcript tier in scripts/lib/youtube-transcript.ts.
  References the design doc and explains the fail-open three-tier fallback.
Line 207 cited the wrong path `scripts/lib/youtube-transcript.ts`;
the actual module is `srv/lib/youtube-official-transcript.js`,
consumed in-process by `scripts/fetch-tutorials.ts` at build time.
… paths

Three drop paths in the video branch logged without the greppable marker,
making them invisible to the workflow step-summary grep. Add the marker to:
- invalid YouTube URL drop
- no transcript fetcher available drop
- transcript fetch threw / catch block (off-channel reject + OAuth failures)

The source:'none' path already had the marker and is unchanged.

Also adds a test asserting that a fetchTranscript rejection emits
[ai-author][EMPTY-QUIZ] and includes the videoId — this was the only
drop path with no test coverage.
fix(#2800): official-API video transcripts for AUTOAUTHOR_VIDEO quizzes
…r OPTION blocks (#2820)

Two rendering bugs reported on the hana-clients tutorials (QA channel,
Tutorials-Contribution), both around the [OPTION] feature:

1. Images rendered centred instead of left-aligned, especially after an
   [OPTION] block. options.ts emits OPTION blocks flush-left (closing the
   surrounding <ol>, #2703), so a figure authored inside an OPTION body or
   trailing the group lands outside any <li> and reverts to the globally
   centred .tutorial-figure. Re-apply the list-item left-align treatment to
   figures inside .os-panel/.option-tabs and to figures that trail an
   OPTION group within a step. CSS-only; regenerated sap-fundamental.css.

2. Selecting Windows on a step that only has Linux/macOS panels silently
   showed macOS (the old cross-OS FALLBACK_CHAIN). Replace the chain with
   exact-match-only rendering: show the OS's panel if present, otherwise a
   'No <OS> instructions for this step.' placeholder — never another OS's
   content. init() still snaps a fresh visitor's INITIAL selection to an
   offered OS so no step starts blank, but an explicit pick is honoured
   verbatim. Updated test/unit/os-toggle.test.ts to the new spec.

Bug 2 in the report (wrong-OS content) is the same root cause as (2) plus
inconsistent per-step OPTION coverage in the author's Oct-14 markdown; see
the issue reply for per-step guidance.
…g-2820

fix(options): honest missing-OS placeholder + left-align figures after OPTION blocks (#2820)
The `*,, *` pattern on line 286 had a stray space, so bash parsed the
arm as two whitespace-separated patterns (`*,,` and `*`) and failed with
`syntax error near unexpected token '*'`. This broke the "Determine
effective rebuild mode" step on EVERY dispatch (exit 2 at parse time,
before any logic ran) — all content rebuilds were blocked.

Fixed to `*,,*` (empty-element = two adjacent commas anywhere),
matching the correct form already used on line 323. Verified with
`bash -n` plus the 5 validation cases (valid list accepted;
empty-element / leading / trailing comma / bad charset all rejected).
fix(rebuild-content): bash case syntax error blocking all content rebuilds
Bump deploy version 1.39.1 -> 1.40.0 (.deploy/mta.yaml) for the DEV->main
promotion and refresh the What's New digest (hugo/data/whats_new.json) with
the 16 PRs merged since 1.39.1 (#2798-#2824).

Minor release: carries two user-facing features — tutorial variables (#2818)
and sort/filter on the admin URL Health table (#2817) — plus the content-rebuild
unblock (#2824), faster content publishing (#2806), scheduler failover
hardening (#2815), multi-tutorial slug-targeted publish fixes (#2801/#2808/#2809),
admin Rebuild-button fixes (#2799/#2804), OS-tab rendering fixes (#2822), more
accurate video quizzes (#2821), and test/CI hardening.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@jung-thomas
jung-thomas merged commit 7d6117a into main Oct 17, 2026
8 checks passed
@jung-thomas
jung-thomas deleted the release/v1.40.0 branch October 17, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant