Skip to content

Release 1.14.1 — DEV → PROD - #1789

Merged
jung-thomas merged 44 commits into
mainfrom
release-1.14.1
Aug 14, 2026
Merged

jung-thomas merged 44 commits into
mainfrom
release-1.14.1

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

PROD release 1.14.1

Ships the accumulated DEV integration work to main for the PROD blue-green deploy.

⚠️ Merge instruction

Use a merge commit (NOT squash). A squash would collapse DEV's history into one commit and make main/DEV diverge; the DEV→main flow relies on a real two-parent merge.

Post-merge

Deploy to PROD from a fresh primary main in blue-green (pauses before swap for green verification), then publish content + content-qa into PROD HANA.

The /admin/rebuild handler atomically replaces the whole static dir with
the pushed tarball. rebuild-content.yml's 'Assemble static content' step
does NOT include static/qa (the QA author-preview navigator, shipped only
by the full MTA deploy via root mta.yaml's inline hugo/public-qa build), so
every full/catalog-only content rebuild WIPED static/qa off the running pod
-> /tutorials-qa/ 404s until the next deploy. This is the recurring 'no QA
navigator on PROD' root cause.

Carry deploy-only subtrees (REBUILD_PRESERVE_DIRS = ['qa']) forward from the
previous static dir across the swap, but only when the incoming tarball did
not ship them (so a future tarball that DOES include static/qa still wins).

Verification requires a deploy + a content rebuild (cannot be exercised by
unit tests / curl). Alternative considered: add a QA fetch+build+assemble to
rebuild-content.yml (matches the #350 include-in-tarball precedent) — heavier
(adds a full QA build to every prod rebuild); preserve is lighter and QA nav
is content-rebuild-independent by design.
… mtar

The local deploy path (npm run deploy -> build:deploy -> build:all) never
built the QA channel: build:all omits build:qa, and .deploy/mta.yaml's
approuter builder only COPIES a pre-built hugo/public-qa into static/qa (it
does not render it). So local deploys shipped a stale/empty static/qa and
/tutorials-qa/ 404'd. CI's root mta.yaml builds QA inline — this closes the
divergence for the local path.

- Step 2.7 (new): fetch-tutorials:qa + build:qa BEFORE mbt build, so mbt copies
  a fresh hugo/public-qa into static/qa. Needs a GitHub token (reuses
  NODE_AUTH_TOKEN/GITHUB_TOKEN) + CAP_BASE_URL; hugo/content-qa is generated by
  the fetch (not committed), so fetch must precede build.
- Step 3.6 (new) + scripts/check-shipped-qa-bundle.cjs: mtar-parity guard that
  cracks the built mtar and asserts static/qa/index.html (+ tutorial-navigator)
  are present — fails the deploy loudly if the QA navigator didn't ship. Mirrors
  scripts/check-shipped-admin-bundle.cjs. curl cannot catch this (XSUAA-gated).

UNVERIFIED: cannot be exercised without a deploy (build could not be run here).
Companion to the approuter static/qa preserve PR (stops content-rebuild wiping it).
#1732 author pages, the 'more from author' rail, and byline eligibility were
surfacing tutorials that are unpublished (status=INACTIVE / soft-deleted in
HANA) or deleted from the source repo (stale .tutorial-cache entries). Root
cause: the author-index builder grouped the RAW GitHub discovery set
(authorRows, pushed for every parsed tutorial in fetch-tutorials.ts) and never
intersected it with the active/published catalog set that the main navigator/
browse already use (tutorialMetas = status='ACTIVE' or null, per
srv/lib/build-catalog.js).

Fix at the single source (buildAuthorIndex → author_index.json, which drives
page + rail + byline):
- buildAuthorIndex(rows, advocates, activeSlugs?): skip rows whose
  slug.toLowerCase() is not in the active set. Fail-open when the set is
  empty/undefined (degraded/ALLOW_EMPTY_CAP builds). Lowercase compare — row
  slugs are mixed-case source dirs, catalog slugs are lowercase-canonical.
- writeAuthorPages: thread activeSlugs through (all-inactive authors then drop
  out of the index and their stale .md page is pruned).
- fetch-tutorials.ts: pass new Set(tutorialMetas.map(m => m.slug.toLowerCase())).
- tests: exclusion, case-insensitive match, all-inactive author dropped,
  fail-open (undefined/empty). All 15 author-index unit tests pass.

Takes effect on the next content rebuild / fetch-tutorials run.
…unpublished

fix(authors): exclude unpublished/deleted tutorials from author pages, rail & byline
…d-guard

fix(deploy): build QA channel on local deploy + guard QA navigator in mtar
…ntent-rebuild

fix(approuter): preserve static/qa across content-rebuild swap (recurring QA navigator wipe)
The Link health override field rendered as free text: a bare String-enum
(HomepageLinkStatus) does not reliably materialise a Fiori dropdown from
@Common.ValueListWithFixedValues alone. Back it with an explicit in-memory
code list (LinkStatusChoices, {code,label}) served like VerbChoices/
ShelfChoices, and wire @Common.ValueList CollectionPath onto linkStatusOverride
on both HomepageShelves and HomepageForYouCandidates.

- Picker offers OK/SLOW/BROKEN only; UNKNOWN is a job sentinel and blank
  clears the override (auto-detect resumes next run).
- @UI.RecommendationState: 0 opts the field out of RPT-1/@cap-js/ai, which
  the new CollectionPath would otherwise auto-hook (AICore Internal Server
  Error hazard; recommendations are meaningless for a 3-value picker).
- Integration test locks the READ handler rows + $metadata ValueList wiring.
…ride-valuehelp

fix(admin): value help for linkStatusOverride (Shelf Entries + For-You)
…ution

When a tutorial has no `authorProfile` frontmatter, the index-builder and
both Hugo templates (byline + "More from author" rail) now fall back to the
top git-contributor login — but ONLY link/attribute when that login ALREADY
has an established author page (resolved via `authorProfile` on at least one
other active tutorial).

Changes:
- `AuthorTutorialRow` gains optional `topContributorLogin` field
- `buildAuthorIndex`: pre-scans active rows for established logins, then
  uses `effectiveLogin()` which checks contributor against that set. Mints
  NO new author pages from contributors. Refactors duplicate `isActive` guard
  into a helper to remove the repeated expression.
- `fetch-tutorials.ts`: threads `contributors[0]?.login` into `authorRows.push`
- `tutorial-author.html`: falls back to `$lastCommitterLogin` for the
  author_index lookup when `authorProfile` is absent
- `more-from-author.html`: same fallback via `contributors` frontmatter param

Result: the danielva-class case (contributor has an existing author page but
the tutorial lacked `authorProfile`) now links internally. Author pages with
established logins pick up their contributor-only tutorials on next content
rebuild. Orphan contributors keep the existing GitHub-link byline unchanged.
…or-fallback

feat(#1758): conservative contributor fallback for author-page attribution
The #1732 'More from this author' rail is cross-tutorial navigation the
host page owns. In any embed mode (none/minimal/reader) an embedded
tutorial should stay focused on its own content, so hide the rail via
html[data-embed] .more-from-author — mirroring the existing footer /
breadcrumbs / feedback-share embed-hide rules. Adds an e2e guard that
the rail is present without embed and hidden under ?embed=minimal.
…r-rail

fix(embed): hide More-from-author rail in embed mode
Replace per-row SELECT.one(Users) with a two-pass approach:
pass 1 applies cheap in-process gates (migration/epoch/ceiling/null user_ID),
pass 2 bulk-fetches distinct user IDs in chunks of 500 (HANA packet-cap
safety) and builds a canonical-sapId Set, pass 3 filters survivors against
the Set. O(N) queries → O(ceil(distinct_users/500)) queries.
…ch note

- selectResendCandidates: push submissionIdCompleted!=null and
  completionDate>=epochMs into the WHERE clause; export NGDS_ELIGIBLE
  for reuse by the CLI (#1+#2)
- CLI: refuse --execute when unstamped COMPLETED rows exist, enforcing
  backfill-first ordering (preflight exits 2 with a clear message)
- Add comment explaining kill-switch bypass is intentional (#3)
- Test: seed row 6107 (COMPLETED+canonical+post-epoch, no submissionId)
  and assert it is excluded from candidates
Alerts is @odata.draft.enabled, so ctaUrl's @Common.ValueList field was
auto-hooked by the @cap-js/ai RPT-1 recommendation plugin (it keys on
@Common.ValueList.CollectionPath). That turns the field into a recommendation
field which validates input against the AlertCtaTargets value-list and
discards free-typed URLs (e.g. /whats-new/) on Enter/commit, despite
@Common.ValueListWithFixedValues:false.

ctaUrl was the only such field on a draft-enabled admin entity missing the
@UI.RecommendationState:0 opt-out that every sibling carries. Add it to
restore plain free-text entry (value-help remains as quick-pick suggestions).
Also add /whats-new/ to the CTA_TARGETS suggestion list.

Annotation/data only; no schema or DB change.
…ired

No active Akamai Fast-Purge hook exists today, so s-maxage=86400 meant
published content could stay stale at the CDN for up to 24h. Bring it
down to s-maxage=600 (10 min worst-case staleness) across both the
CAP content-store header helper and the approuter catch-all static route.

Also adds a cacheControl directive to the xs-app.json catch-all route
so homepage HTML and other static pages get the same modest TTL instead
of no Cache-Control at all.

Edge-Cache-Tag continues to be emitted so a future purge hook requires
no changes here beyond raising the TTL back toward a day.

- srv/lib/edge-cache-headers.js: s-maxage 86400 → 600, updated comment
- approuter/xs-app.json: add cacheControl to catch-all static route
- 4 test files: update s-maxage assertions to match new constant
fix: allow free-text CTA URL on Alerts (opt out of @cap-js/ai RPT hook)
Cold-load CLS at 1280x720 was 0.829 (Poor). Root cause: the two island
mount divs that Vue fills at ~2.5s were empty (0px) at SSR, so the
featured-topics-carousel below them was in the viewport when the video
band grew from 0->500px -- a shift of ~500px pushing all below-fold
content down.

Fix: add min-height CSS matching the post-hydration desktop heights:
  [data-island=videos]    { min-height: 500px; }
  [data-island=community] { min-height: 260px; }

Measured with injected CSS on live PROD (Playwright, cold context,
1280x720, no scroll): CLS 0.829 -> 0.067 (-92%). Remaining 0.067 is an
early shift at ~1.3s unrelated to islands (below 0.1 Good threshold).

On mobile (390px) both islands are below-fold at first paint so their
hydration shifts do not score against CLS; no mobile override needed.
fix(ngds): trackingInfo.tracking parity + backfill + resend scripts
…i5slim

perf(cache): reduce edge TTL from 1d to 10min until purge-by-tag is wired
Catalog-only content rebuilds skip "Fetch tutorials", so fetch-tutorials.ts
never emits the per-login author stubs or author_index.json — but the run
still assembles and atomically pushes the approuter static tarball, wiping
every /authors/{login}/ page a prior full rebuild shipped. Same wipe class as
the 2026-08-07 /browse/ incident (and the earlier /explore-ui/ one). Reported
as a PROD 404 on /authors/<login>/ after overnight catalog-only rebuilds.

Author pages can't be rebuilt in catalog-only (buildAuthorIndex needs
per-tutorial author attribution only the skipped GitHub fetch provides), so
mirror the browse fix and re-hydrate from the deployed site:

- Publish author_index.json as a served static asset (/author_index.json) on
  full builds so it can be recovered later (prod channel; QA rebuild is separate).
- scripts/seed-authors-from-deployed.ts fetches that copy in catalog-only and
  regenerates stubs + data + the served copy (so consecutive catalog-only
  rebuilds keep working). Fail-safe: exits non-zero if the deployed index is
  unreachable/empty rather than shipping a wiped /authors/*.
- New catalog-only-gated "Preserve author pages" workflow step, beside the
  existing browse-preservation step.
- Extract shared writeAuthorPagesFromIndex helper (used by both the full-build
  and re-hydration paths); unit tests for parseAuthorIndex + the helper.

Bootstrap: the first catalog-only run after this ships but before a full
rebuild has published /author_index.json fails-safe; run mode=full once.
…log-only-wipe

fix(authors): preserve /authors/* across catalog-only rebuilds
…3.48MB)

The global ui5-bootstrap.js shipped 16.25MB uncompressed on every page.
Measured composition: 63% CLDR locale data (76 locales), 16% all-locale
i18n bundles (57 locales), ~25% theme params, ~11% actual component/icon code.

Root cause: the blanket "@ui5/webcomponents/dist/Assets.js" transitively
imports the localization (CLDR) + all-locale i18n loaders. Those are authored
to lazy-load one locale via dynamic import(), but Hugo's js.Build runs esbuild
with splitting disabled, so every per-locale branch is inlined statically -
~13MB of locale data baked into a single file.

This site imports no date/calendar/number components (no DatePicker/Calendar),
so CLDR is dead weight, and the platform is English-first, so non-English
message bundles are unused (components fall back to compiled English defaults).

Fix: import only the theming json-imports (sap_horizon + sap_horizon_dark),
dropping CLDR and non-EN i18n.

Verified with a real esbuild build (reproduces Hugo js.Build byte-for-byte)
+ Playwright runtime test of the trimmed bundle:
- bundle 16.25MB -> 3.48MB (-79%)
- all representative components upgrade (Button/Dialog/RatingIndicator/
  Timeline/Select/ShellBar/MessageStrip)
- both themes apply (light bg #f5f6f7, dark bg #12171c) via setTheme + observer
- component text renders in English; no CLDR/locale/icon console errors

If a date component or non-English UI is ever added, restore the blanket
Assets.js import, or move this bootstrap to the Vite pipeline so the
per-locale chunks lazy-load as intended (tracked as the Tier 2 follow-up).
perf(ui5): drop unused CLDR + non-EN i18n from bootstrap (16.25MB → 3.48MB, −79%)
…strict

The primary-tree guard blocked ALL env deploys from a worktree. Per policy
update: that strictness should apply only to PROD. DEV/QA integration deploys
may run from a worktree as long as it is a FRESH checkout at the origin/DEV tip
(never stale or ahead), so mbt bakes exactly the integration-branch content.

- prod: unchanged — must run from the primary checkout on `main`.
- dev/qa: worktree allowed; guard now fetches origin/<branch> and asserts
  HEAD === origin/<branch>, failing loudly with a reset hint if divergent.

Verified live: the DEV deploy that ships this change ran from a worktree and
passed the new guard ("fresh DEV checkout @ <sha> (worktree)").
…blish)

Removes the job-level `if:` gate that force-SKIPPED every dev-targeted run.
With the DEV approuter Credential Store seeded (CONTENT_API_KEY +
REBUILD_API_KEY), `gh workflow run rebuild-content.yml -f environment=dev`
now publishes DEV content server-side — no local CONTENT_API_KEY paste,
matching how PROD/QA already work.

PREREQUISITE (one-time, before relying on this): seed the DEV approuter
Credential Store with CONTENT_API_KEY + REBUILD_API_KEY (matching the GH
secrets) via /admin-ui/#secrets on the DEV approuter. Without it, a dev run
401s at the "Push content to AppRouter" step.

repository_dispatch behaviour is unchanged (Determine-environment still
defaults absent/unknown env to prod).
…tree

build(deploy): allow DEV/QA deploys from a fresh worktree; keep PROD strict
…orkflow

ci(rebuild-content): enable keyless DEV content publishing (DRAFT — needs DEV approuter key seed)
…n.css

Two cheap render-path wins surfaced by the DEV Lighthouse pass:

1. Minify (| minify) the Hugo-managed fingerprinted stylesheets that were
   served unminified — homepage.css (index.html) + chroma-light/-dark,
   print, ui5-overrides, view-transitions, scroll-animations, os-toggle
   (head.html). Addresses Lighthouse "unminified-css" (~170ms). homepage.css
   23,060 -> 14,363 bytes (-38%); the #1767 island min-height reservation
   survives minification (verified). Left untouched: sap-theme-vars.css and
   sap-horizon-dark.css (referenced by BARE path from the CAP renderers, so
   their RelPermalink must not change) and sap-fundamental.css (postcss-built,
   special .Publish handling).

2. Self-host the fundamental-styles icon.css instead of loading it cross-origin
   from unpkg.com@0.41.4. Removes a render-blocking third-party request (part of
   the ~561ms render-blocking-resources), eliminates a prod runtime dependency
   (an unpkg outage would break every sap-icon-- glyph), and fixes the version
   drift (unpkg 0.41.4 vs the pinned fundamental-styles@0.41.7 dep). Vendored
   from node_modules/fundamental-styles/dist/icon.css; fingerprinted + minified
   by Hugo. Regenerate on dep bump (command in the head.html comment).

Verified via a local Hugo --minify build + Playwright load of the built site:
unpkg fully removed (0 refs), fundamental-styles-icon.min.<hash>.css serves 200,
all target CSS emits as .min.<hash>.css, no CSS/icon console errors.

The heavier Lighthouse opportunities (Tier-2 UI5 code-split for ~362 KiB unused
JS, oversized homepage images, unused-CSS purge, TrustArc consent banner being
the homepage LCP element) are logged as separate issues.
perf(css): minify Hugo-managed CSS + self-host fundamental-styles icon.css
The multi-select/radio quiz options bound `:checked` directly. On UI5
web components attribute *presence* is truthy, so Vue's `:checked="false"`
serialized `checked=""` and every option rendered pre-selected while the
answers model was empty. Submitting the as-rendered form graded the full
option set against the correct set and always returned "Not quite".

Route `checked` through the same v-bind presence-guard already used for
`disabled` on these elements, merged into one object, so a false value
emits no attribute. Adds a real-DOM regression test mirroring the
existing disabled-coercion test.

Verified live with Playwright on hana-cloud-mission-trial-1 step 4:
reproduced all-boxes-checked -> "Not quite"; confirmed against the real
ui5-checkbox that no attribute renders unchecked.
…cked-coercion

fix(validation): MCQ options render pre-selected -> quiz always grades wrong (#1774)
At the last tutorial of a group, the Next button was missing because all
three navigation surfaces computed prev/next strictly within a single
group/path/container. A multi-group mission therefore broke the chain at
every group boundary (next=null at each group's last tutorial).

Treat a mission as one continuous ordered sequence across its groups
(ordered by CompletionPath legacyId / missionGroupSeq, which the build and
the feed already share):

- scripts/parsers/nav-owner.ts: computeCanonicalNav chains prev/next across
  same-mission containers for baked frontmatter (the reported no-?from= path).
  Standalone groups never chain. Skips not-present boundary neighbours.
- srv/lib/navigator-catalog.js: /build/navigator tutorialMappings stitch
  path boundaries within a mission; emit nextGroupSlug/prevGroupSlug only at
  a boundary so the island can carry the correct ?from= across it.
- tutorial-group-nav island + shared group-nav-context: carry ?from= to the
  crossed-into group; in-group neighbours keep the current ?from=.

Verified: hana-cloud-mission-trial-4 (last of group 15066, mission 15069)
now links Next -> hana-cloud-mission-trial-5 (first of group 15067).

Tests: nav-owner unit (cross-group + standalone bounded + absent-skip),
navigator-groups integration (cross-path chain + boundary slugs), island
happy-dom (boundary ?from= carry both directions), e2e group-nav-ordering.
fix(nav): continuous Next/Prev across groups within a mission (#1775)
#1783)

Drop genuinely ancient tutorials from the Featured-missions eligibility set
in loadInputs so they can't be PageRank-ranked onto the homepage carousel —
quality hygiene, not a fix for any specific tutorial.

- Signal: TutorialMeta.reviewedDate (git last-commit date, monotonic-guarded),
  NOT Tutorials.modifiedAt (churns catalog-wide on every --force rebuild).
- NULL reviewedDate / missing meta row -> KEEP (fail-open). Missions have no
  reviewedDate -> always kept. Most-recent reviewedDate wins across dup rows.
- Cutoff via ImsConfig key 'featured.freshness.maxAgeDays'; missing/blank/
  non-numeric -> generous default 730d (~24mo, so the 344-day-old #1771
  tutorial is NOT dropped). Explicit 0/negative disables the floor. Read/query
  faults fail open.
- Sole chokepoint is the tutorialsBySlug set; pure selectFeaturedTopics
  unchanged. Unbounded TutorialMeta read (no WHERE-IN) keeps HANA packet-safe.

Refs #1771
…ss-cutoff-1783

feat(featured): loose DB-configurable freshness floor for the PageRank carousel (#1783)
@jung-thomas
jung-thomas merged commit 3c4f782 into main Aug 14, 2026
6 checks passed
@jung-thomas
jung-thomas deleted the release-1.14.1 branch August 14, 2026 20:27
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