Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8ff849c
freshness: give the LLM tutorial context + SAP-aware rules
jung-thomas Aug 24, 2026
a3f7eb5
freshness: tighten system prompt for author noise + calibration
jung-thomas Aug 24, 2026
b47dbb6
Merge pull request #2012 from sap-tutorials/worktree-freshness-contex…
jung-thomas Aug 24, 2026
3f69ec7
perf(rebuild): cache node_modules to skip ~48s npm ci on unchanged lo…
jung-thomas Aug 24, 2026
18dc7db
docs(openspec): propose slug-targeted delta rebuild (O(changed) end-t…
jung-thomas Aug 24, 2026
3b58944
Merge pull request #2016 from sap-tutorials/feat/rebuild-npm-cache
jung-thomas Aug 24, 2026
5dd4d9d
Merge pull request #2017 from sap-tutorials/design/slug-targeted-delt…
jung-thomas Aug 24, 2026
874af87
fix(fetch): unmask GraphQL discovery errors + loud REST fallback
jung-thomas Aug 24, 2026
495d714
Merge pull request #2018 from sap-tutorials/fix/graphql-discovery-unmask
jung-thomas Aug 24, 2026
121f10a
feat(rebuild): generated-content cache helpers (Workstream C foundation)
jung-thomas Aug 25, 2026
7a2fb97
Merge pull request #2019 from sap-tutorials/feat/generated-content-cache
jung-thomas Aug 25, 2026
759010a
feat(rebuild): wire generated-content fast path (Workstream C, flag-g…
jung-thomas Aug 25, 2026
fff5ac9
feat(db): add ContentCurrent + ContentHistory aspects/entities (Optio…
jung-thomas Aug 25, 2026
dd85ef0
feat(publish): Option B dual-write to ContentCurrent + ContentHistory…
jung-thomas Aug 25, 2026
8b62905
Merge pull request #2020 from sap-tutorials/feat/content-cache-wiring
jung-thomas Aug 25, 2026
322f5f2
Merge pull request #2021 from sap-tutorials/feat/content-delta-publis…
jung-thomas Aug 25, 2026
d215947
fix(#2023): stop Hugo YAML 1.1 coercing yes/no quiz options to booleans
jung-thomas Aug 25, 2026
22ef08c
Merge pull request #2024 from sap-tutorials/fix/2023-yaml-yesno-coercion
jung-thomas Aug 25, 2026
cb22538
fix(notify): trigger source-repo dispatch on both master and main
jung-thomas Aug 25, 2026
38ecd0c
fix(feature-flags): register CONTENT_DELTA_WRITE_ENABLED env flag
jung-thomas Aug 25, 2026
2a3ad4d
Merge pull request #2025 from sap-tutorials/notify-master-main-trigger
jung-thomas Aug 25, 2026
f2031a4
feat(serve): Option B read cutover + rollback replay (Workstream D, f…
jung-thomas Aug 25, 2026
1060689
feat(devtoberfest): deep-link URLs for sessions grid filters/search (…
jung-thomas Aug 25, 2026
06bfb86
feat: Option B reader sweep + carry-forward removal + rollback replay…
jung-thomas Aug 25, 2026
91dc665
fix(deploy): build + copy display-app into approuter static (#2035)
jung-thomas Aug 25, 2026
c4b94d5
docs(CLAUDE): add Option B content-model gotcha (ContentCurrent + 3 f…
jung-thomas Aug 25, 2026
43b8bf4
fix(guard): mark Option B ContentCurrent slug lookups as caller-canon…
jung-thomas Aug 25, 2026
5c87e0f
chore(release): bump MTA version to 1.19.0 for PROD release (#2037)
jung-thomas Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .deploy/mta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ID: tutorials-ims
# Bump this on each release you deploy — it's the version shown by `cf mtas`
# and in the mtar filename (tutorials-ims_<version>.mtar). Deploy is manual:
# `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_<version>.mtar -e ../deploy/<env>.mtaext -f`.
version: 1.18.3
version: 1.19.0

# Top-level parameters (overridable per-env via deploy/<env>.mtaext).
parameters:
Expand Down Expand Up @@ -230,6 +230,14 @@ modules:
- mkdir -p static/explore-ui
- cp -r ../app/explore/dist/. static/explore-ui/
- cp -r ../app/scanner/webapp static/scanner-ui
# Display App (event-monitor dashboard) — Vue 3 + Vite, base '/display-app/'.
# Served from static/display-app via the /display-app/ approuter route. This
# file (NOT the root mta.yaml) is what `mbt build` uses, so the build+copy
# must live here or /display-app/ 404s.
- npm --prefix ../app/display-app install
- npm --prefix ../app/display-app run build
- mkdir -p static/display-app
- cp -r ../app/display-app/dist/. static/display-app/
# Data Inspector UI (#999) — bundled inside @cap-js/data-inspector's
# node_modules. Rewrite the UI5 bootstrap CDN in index.html from
# sapui5.hana.ondemand.com (plugin default) to ui5.sap.com — the
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/rebuild-content.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ on:
required: false
type: boolean
default: false
content-cache:
description: 'EXPERIMENTAL (Workstream C / slug-targeted-delta-rebuild): on a slug-targeted run, reuse the generated-content cache to skip regenerating unchanged tutorials (the bulk of the Fetch step). Fail-open; default OFF. Enable to A/B the fast path on DEV before flipping it on by default.'
required: false
type: boolean
default: false

env:
NODE_VERSION: '22'
Expand Down Expand Up @@ -273,7 +278,31 @@ jobs:
prod) echo "srv_url=${{ secrets.CAP_SRV_URL_PROD }}" >> "$GITHUB_OUTPUT" ;;
esac

# Cache the *materialized* node_modules (root + hugo-apps), not just the
# npm download cache. `setup-node` `cache: npm` already warms ~/.npm, but
# `npm ci` still spends ~48s extracting/linking 1680 packages + running
# native builds (better-sqlite3) into node_modules on every run. An
# exact-key cache (keyed on both lockfiles + OS + Node major) lets us skip
# the install entirely when the lockfiles are unchanged.
#
# EXACT match only — NO restore-keys. A node_modules restored for a
# different lockfile would be silently wrong because we skip `npm ci`
# (which would otherwise reconcile). On any lockfile change the cache
# misses, the install below runs clean, and the new tree is re-cached.
# Safe to skip install on hit: neither package.json has install/prepare/
# postinstall lifecycle scripts, so nothing outside node_modules is
# produced at install time.
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: |
node_modules
hugo-apps/node_modules
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json', 'hugo-apps/package-lock.json') }}

- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
Expand Down Expand Up @@ -336,6 +365,27 @@ jobs:
echo "::add-mask::${VCAP}"
echo "VCAP_SERVICES=${VCAP}" >> "$GITHUB_ENV"

# EXPERIMENTAL (Workstream C): restore the previously-generated content
# tree so a slug-targeted run can reuse it and skip regenerating unchanged
# tutorials. Keyed on the parser/generator SOURCE hash — a parser change
# misses the key, nothing is restored, and fetch-tutorials full-regenerates
# (the per-slug existsSync guard falls through). The run_id suffix + prefix
# restore-keys let each run save its updated tree/sidecar while restoring
# the most recent matching one (same pattern as the tutorial cache). The
# runtime feed-fingerprint gate (in fetch-tutorials) covers catalog/tag
# changes. Only runs when the flag is on; fail-open otherwise.
- name: Restore generated-content cache
if: ${{ inputs.content-cache == true && steps.mode.outputs.effective_mode != 'catalog-only' }}
uses: actions/cache@v4
with:
path: |
hugo/content/tutorials
hugo/data/image_dimensions.json
.content-cache
key: content-tree-v1-${{ hashFiles('scripts/parsers/**', 'scripts/fetch-tutorials.ts', 'scripts/lib/content-cache.ts', 'scripts/lib/expand-ai-authored.ts') }}-${{ github.run_id }}
restore-keys: |
content-tree-v1-${{ hashFiles('scripts/parsers/**', 'scripts/fetch-tutorials.ts', 'scripts/lib/content-cache.ts', 'scripts/lib/expand-ai-authored.ts') }}-

- name: Fetch tutorials
if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }}
# [#357 followup] When force-cap-refetch is true, pass --force-cap so
Expand Down Expand Up @@ -363,6 +413,10 @@ jobs:
# telemetry line so the CI invariant regex keeps matching.
AI_AUTHOR_BUILD_CAP: ${{ inputs.ai-author-build-cap }}
CHAT_DEPLOYMENT_ID: ${{ secrets.CHAT_DEPLOYMENT_ID }}
# EXPERIMENTAL (Workstream C): enable the generated-content fast path.
# fetch-tutorials reuses cached non-target content when this is 'true'
# AND the run is slug-targeted AND the feed fingerprint matches.
CONTENT_CACHE_FAST_PATH: ${{ inputs.content-cache == true }}

# [#601] Generate per-advocate profile-page markdown into
# hugo/content/developer-advocates/ from /api/advocates. Runs on ALL
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ approuter/static/*
!approuter/static/.well-known/*.template
.tutorial-cache/
.tutorial-cache-qa/
.content-cache/
hugo/data/image_dimensions.json
hugo/data/homepage_shelves.json
hugo/data/verb_definitions.json
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ Subsystem one-liners:

- **`CONTENT_API_KEY` env var required** for `POST /content/publish` and `/content/rollback`. Missing → 401. Set locally when testing publish.

- **Content served from mutable `ContentCurrent` (Option B, #2017 / Workstream D), not the old `ContentFiles` snapshot-per-version** — three env flags gate the migration, all default OFF, flip in order: `CONTENT_DELTA_WRITE_ENABLED` (publish dual-writes changed slugs → `ContentCurrent` + append-only `ContentHistory`, fail-safe) → **seed** via a full force rebuild (`-f mode=full -f force-publish=true` dual-writes all slugs; no separate migration) → `CONTENT_DELTA_READ_ENABLED` (serve/readers hit `ContentCurrent`, per-slug fallback to `ContentFiles`) → `CONTENT_DELTA_SKIP_CARRYFORWARD` (publish skips `carryForwardUnchanged` → **O(changed) publish**; rollback then **replays `ContentHistory`** into `ContentCurrent`, not clear+fallback). Measured DEV: publish commit ~62s→973ms (PROD carry-forward was ~95s @ 11k files). Serve source header `X-Content-Source: db-current` (ContentCurrent) vs `db` (legacy). Revert = flip `SKIP_CARRYFORWARD` off + `cf restart`. Flags in `srv/lib/feature-flags/registry.js`; readers via `resolveContentBlob` (`srv/lib/content-store.js`). LOB reads stay raw `db.run()`. Cutover cleanup (delete `carryForwardUnchanged` + `ContentFiles`) deferred to a release after PROD soak.

- **GitHub Actions secret is `DISPATCH_TOKEN`, not `GITHUB_DISPATCH_TOKEN`** — GH reserves the `GITHUB_` prefix. The runtime env var is `GITHUB_DISPATCH_TOKEN`, read by `srv/lib/rebuild-trigger.js`.

- **`rebuild-content.yml` auto-infers `mode=slug-targeted`** when a `slug` input is set — don't pass `-f mode=slug-targeted`. Wall-clock: catalog-only ~5min, slug-targeted ~2min, full ~10min.
Expand Down
5 changes: 5 additions & 0 deletions db-qa/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ entity ContentFiles : shared.ContentFilesAspect {}

entity ContentManifest : shared.ContentManifestAspect {}

// Option B (slug-targeted-delta-rebuild) — QA-channel parity with prod.
entity ContentCurrent : shared.ContentCurrentAspect {}

entity ContentHistory : shared.ContentHistoryAspect {}

// Plain-text projection of published Hugo HTML, indexed for full-text search.
// Replaced (not versioned) on every publish so search reflects current content.
@cds.autoexpose: false
Expand Down
35 changes: 35 additions & 0 deletions db/_content-shape.cds
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ aspect ContentManifestAspect : managed {
firstAppendAt : Timestamp;
}

// Option B (slug-targeted-delta-rebuild): the MUTABLE current-content table —
// one row per slug, NO version column. Readers hit this directly (WHERE slug=?)
// instead of joining on the active manifest version, so a publish writes ONLY
// the changed slugs (no O(corpus) carry-forward). `sourceVersion` records the
// manifest version that last wrote this slug (audit + cache-generation token).
aspect ContentCurrentAspect : managed {
key slug : String(255);
content : LargeBinary;
contentHash : Sha256;
sizeBytes : Integer;
compressedBytes : Integer;
mimeType : String(100) default 'text/html';
sourceContent : LargeBinary;
sourceHash : Sha256;
sourceVersion : Integer;
}

// Option B: append-only per-(version, slug) history for drift-detection
// (detectReverts) + rollback replay. Carries the BLOB (`content`/`sourceContent`)
// so rollback is a self-contained replay into ContentCurrent (design.md D2);
// GC'd by the repurposed cleanupContentVersions. `action=DELETED` tombstones a
// slug removed at that version so rollback can re-add/remove correctly.
aspect ContentHistoryAspect : managed {
key version : Integer;
key slug : String(255);
action : String(10) enum { WRITTEN; DELETED; };
content : LargeBinary;
contentHash : Sha256;
sizeBytes : Integer;
compressedBytes : Integer;
mimeType : String(100) default 'text/html';
sourceContent : LargeBinary;
sourceHash : Sha256;
}

aspect TutorialBodyTextAspect : managed {
key slug : String(255);
bodyText : LargeString;
Expand Down
7 changes: 7 additions & 0 deletions db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,13 @@ entity ContentFiles : shared.ContentFilesAspect {}

entity ContentManifest : shared.ContentManifestAspect {}

// Option B (slug-targeted-delta-rebuild): mutable current-content table +
// append-only history. Coexist with ContentFiles/ContentManifest during the
// flag-gated dual-write migration; ContentFiles is retired one release after cutover.
entity ContentCurrent : shared.ContentCurrentAspect {}

entity ContentHistory : shared.ContentHistoryAspect {}

// Plain-text projection of published Hugo HTML, indexed for full-text search.
// Replaced (not versioned) on every publish so search reflects current content.
@cds.autoexpose: false
Expand Down
8 changes: 7 additions & 1 deletion docs/authors/tutorial-repo-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ name: Notify Tutorials Platform

on:
push:
branches: [main]
# Source repos are a MIXTURE of default branches: most default to `main`,
# but some (e.g. sap-tutorials/Tutorials) still default to `master`. List
# BOTH so this template auto-publishes regardless of a repo's default —
# a `main`-only trigger silently never fires on a `master`-default repo
# (root cause of the cp-* PROD content-drift incidents). GitHub ignores a
# listed branch that doesn't exist, so [master, main] is safe everywhere.
branches: [master, main]
paths:
- '**.md'
- '**.png'
Expand Down
75 changes: 73 additions & 2 deletions hugo-apps/src/devtoberfest-sessions-grid/App.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { fetchFeed, fetchMyCompletions } from '../devtoberfest-schedule-shared/feed';
import { mergeCompletion, youtubeThumb, safeHref, sessionMatchesQuery } from '../devtoberfest-schedule-shared/completion';
import EditionPicker from '../devtoberfest-schedule-shared/EditionPicker.vue';
import PointsBanner from '../devtoberfest-schedule-shared/PointsBanner.vue';
import DetailPanel from '../devtoberfest-schedule-shared/DetailPanel.vue';
import type { Feed, ScheduleRow } from '../devtoberfest-schedule-shared/types';
import { formatViewerLocal } from '../devtoberfest-schedule-shared/format-session-time';
import { parseSessionsUrl, toSessionsQuery, type SessionsUrlState } from './url-state';

const loading = ref(true);
const error = ref('');
Expand All @@ -23,6 +24,21 @@ const filterQuery = ref('');
const filterWeek = ref('');
const filterTrack = ref('');

// --- Deep-linking (issue #2030) -------------------------------------------
// The page URL is the source of truth on first load. We parse it once, apply
// the feed-independent filters (q, week, track) synchronously, and defer the
// bits that need the loaded feed (session lookup, edition) to loadData.
const initialUrl = parseSessionsUrl(typeof window !== 'undefined' ? window.location.search : '');
// One-shot consumed on the FIRST load only, so a later edition change via the
// picker doesn't re-open a stale session panel.
let pendingSession: string | null = initialUrl.session;
// Gate: suppress URL writes until the incoming link has been fully applied.
let applied = false;

if (initialUrl.q) filterQuery.value = initialUrl.q;
if (initialUrl.week) filterWeek.value = initialUrl.week;
if (initialUrl.track) filterTrack.value = initialUrl.track;

async function loadData(edition?: string) {
loading.value = true;
error.value = '';
Expand All @@ -40,6 +56,13 @@ async function loadData(edition?: string) {
sessions.value = merged.rows.filter((r) => r.kind === 'session');
earnedPoints.value = merged.earnedPoints;
maxPoints.value = merged.maxPoints;

// Session deep-link: open its detail panel (first load only).
if (pendingSession) {
const row = sessions.value.find((r) => r.id === pendingSession);
if (row) selectedRow.value = row;
pendingSession = null;
}
} catch (e: any) {
error.value = e?.message ?? 'Failed to load sessions.';
} finally {
Expand Down Expand Up @@ -88,7 +111,55 @@ function onThumbError(ev: Event) {
if (placeholder) placeholder.style.display = 'flex';
}

onMounted(() => loadData());
// --- URL sync (issue #2030) -----------------------------------------------
/** Current shareable state, derived from the live refs. */
function currentUrlState(): SessionsUrlState {
return {
q: filterQuery.value || null,
week: filterWeek.value || null,
track: filterTrack.value || null,
// Omit edition when it's just the active default — the ?edition param is
// for explicitly viewing a non-active edition.
edition: editionId.value && editionId.value !== feed.value?.activeEditionId
? editionId.value
: null,
session: selectedRow.value?.id ?? null,
};
}

function writeUrl() {
if (!applied || typeof window === 'undefined') return;
const qs = toSessionsQuery(currentUrlState());
window.history.replaceState({}, '', `${window.location.pathname}${qs}${window.location.hash}`);
}

/** Re-apply in-memory filter/session state from the URL on back/forward. */
function applyFromUrl(st: SessionsUrlState) {
filterQuery.value = st.q ?? '';
filterWeek.value = st.week ?? '';
filterTrack.value = st.track ?? '';
if (st.session) {
const row = sessions.value.find((r) => r.id === st.session);
selectedRow.value = row ?? null;
} else {
selectedRow.value = null;
}
}

// Edition changes require a reload (different feed), so they're handled by
// onEditionChange rather than applyFromUrl.
function onPopState() { applyFromUrl(parseSessionsUrl(window.location.search)); }

onMounted(async () => {
window.addEventListener('popstate', onPopState);
await loadData(initialUrl.edition ?? undefined);
applied = true; // start reflecting user interactions into the URL
writeUrl(); // canonicalise the URL after the incoming link is applied
});

onBeforeUnmount(() => window.removeEventListener('popstate', onPopState));

watch([filterQuery, filterWeek, filterTrack, editionId, selectedRow], writeUrl);
</script>

<template>
Expand Down
Loading
Loading