From f02fda0c2d3cc811ef1fdda2c2a963bd7fd2ab90 Mon Sep 17 00:00:00 2001 From: Richard Tweed Date: Tue, 14 Jul 2026 15:17:55 +0100 Subject: [PATCH] fix(scorm): bake release version into the package so it's identifiable in an LMS (#7) There was no way to tell which version of the course was running in an LMS -- the exported SCORM zip carried no build stamp, so two zips from different releases were indistinguishable once uploaded. Operators debugging "is this the version with the autosave fix?" had nothing to go on. Root cause: nothing in source/data.xml or the export pipeline recorded the build version, and the release workflow (which knows the git tag at build time) did not inject it into the content before exporting. Fix: a {{BUILD_VERSION}} placeholder committed in the course content, plus workflow steps that substitute it at build time. Two stamps land in every release zip: 1. Visible in the player -- a new "Build" section on the About page (page 2) with

Version: {{BUILD_VERSION}} ...

. The token is a placeholder committed in source/data.xml + source/preview.xml. 2. Machine-readable -- the release workflow adds a VERSION.txt (version, build timestamp, full commit SHA, source tree URL) to the zip root. Why a placeholder (not a committed version): the version is only known at release time (the git tag), so it cannot be baked into committed source. The placeholder keeps source/data.xml version-agnostic. Changes to source/data.xml + source/preview.xml (identical): - Added a "Build"

section after the Provenance section on the About page, with a Version: {{BUILD_VERSION}} line. The placeholder value contains no XML-special characters and sits raw in the CDATA. Release workflow (.github/workflows/release.yml): - "Compute build version" step: derives "v (YYYY-MM-DD, commit )" for release events (github.ref_name = tag) or "dev (..., workflow_dispatch)" for manual runs; exposes it via step outputs. - "Inject build version into course content" step: python3-replaces {{BUILD_VERSION}} in source/data.xml + source/preview.xml BEFORE the XOT import, so the substituted value is what gets exported. Asserts the placeholder is present (fails loudly if it was accidentally removed from source). - "Add VERSION.txt to the package" step: writes a VERSION.txt (version, build timestamp, commit SHA, source tree URL) and zips it into the package. - "Verify the package" step: now FAILS THE BUILD if {{BUILD_VERSION}} remains in the exported template.xml (guards against silent version-stamping regressions), and prints the substituted Version: line + VERSION.txt presence. Preview workflow (.github/workflows/preview.yml): - "Set preview build label" step: substitutes {{BUILD_VERSION}} -> "preview (PR #N, commit )" in source/data.xml before render_preview.py, so PR preview HTML shows a clean build line instead of the raw token. Validated end-to-end through a running XOT (docker-container branch) instance on host port 8088: - Pushed source/data.xml + source/preview.xml (with the placeholder) into the container; play.php?template_id=1 returns 200; HTML5 editor opens (200). - Editor Publish round-trip PRESERVES {{BUILD_VERSION}} intact in data.xml -- CKEditor does not mangle the braces, so editor-based edits are safe. - Simulated the release pipeline: substituted {{BUILD_VERSION}} -> "v0.0.6 (2026-07-14, commit abc1234)" in source/data.xml + source/preview.xml, pushed into XOT, play.php returned 200, re-exported SCORM. The exported template.xml shows "Version: v0.0.6 (2026-07-14, commit abc1234)" on the About page, 0 {{BUILD_VERSION}} placeholders remain, and a VERSION.txt was added to the zip root. All content/tracking attrs unchanged (45 questions, 80% pass, trackingMode="full", trackingWeight 1x7 + 21, unmarkForCompletion="true" x1, 0 empty options, 27 delaySecs="0", xPersistProgress autosave script present). - Ran the preview-workflow substitution script against a copy of source/data.xml: injected "preview (PR 7, commit abc1234)", 0 placeholders remain, render_preview.py renders the substituted line. Both workflow YAMLs parse; both heredoc scripts execute cleanly. Trade-off: the placeholder is content that the release workflow mutates at build time (not a committed version), so a locally hand-built SCORM zip (without running the workflow) will show the literal {{BUILD_VERSION}} token on the About page and have no VERSION.txt. This is acceptable -- the documented deliverable path is the release workflow, which always substitutes. Do not remove the {{BUILD_VERSION}} placeholder from the About page without a replacement -- the release workflow's verify step expects it present before substitution and absent after, and that check guards against silent version-stamping regressions. Guidance updates: - docs/PROJECT_CONTEXT.md: added "Build version stamping ({{BUILD_VERSION}} placeholder)" section documenting the two stamps, who substitutes the placeholder, why a placeholder, and the do-not-remove note; added build version greps to the verification checklist. - docs/COURSE_VERIFICATION.md: added "Build version stamping" section with the local validation results (substituted About-page line, 0 placeholders, VERSION.txt in zip, all attrs unchanged, Publish round-trip preserves the placeholder). Generated with pi 0.80.6 and GLM 5.2 --- .github/workflows/preview.yml | 17 +++++++++++++ .github/workflows/release.yml | 46 +++++++++++++++++++++++++++++++++++ docs/COURSE_VERIFICATION.md | 24 ++++++++++++++++++ docs/PROJECT_CONTEXT.md | 41 +++++++++++++++++++++++++++++++ source/data.xml | 4 +++ source/preview.xml | 4 +++ 6 files changed, 136 insertions(+) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 562a3e5..dc2d19d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -27,6 +27,23 @@ jobs: with: python-version: "3.11" + - name: Set preview build label + run: | + set -e + short=$(git rev-parse --short HEAD) + ver="preview (PR ${{ github.event.pull_request.number }}, commit $short)" + python3 - "$ver" source/data.xml <<'PY' + import sys, pathlib + ver = sys.argv[1] + p = pathlib.Path('source/data.xml') + s = p.read_text(encoding='utf-8') + if '{{BUILD_VERSION}}' in s: + p.write_text(s.replace('{{BUILD_VERSION}}', ver), encoding='utf-8') + print(f"preview build label: {ver}") + else: + print("no {{BUILD_VERSION}} placeholder found (skipping)") + PY + - name: Render preview HTML run: | python3 tools/render_preview.py source/data.xml preview/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07cbb34..9581322 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,20 @@ jobs: ref: docker-container path: xote + - name: Compute build version + id: ver + run: | + short=$(git rev-parse --short HEAD) + date=$(date -u +%Y-%m-%d) + if [ "${{ github.event_name }}" = "release" ]; then + # On release publish, github.ref_name is the tag (e.g. v0.0.6) + ver="${{ github.ref_name }} ($date, commit $short)" + else + ver="dev ($date, commit $short, ${{ github.event_name }})" + fi + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "Build version: $ver" + - name: Build the xerte image working-directory: xote run: docker build -t xerte . @@ -47,6 +61,20 @@ jobs: done curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8081/ + - name: Inject build version into course content + run: | + set -e + python3 - "${{ steps.ver.outputs.version }}" source/data.xml source/preview.xml <<'PY' + import sys, pathlib + ver = sys.argv[1] + for fn in sys.argv[2:]: + p = pathlib.Path(fn) + s = p.read_text(encoding='utf-8') + assert '{{BUILD_VERSION}}' in s, f"{fn}: {{BUILD_VERSION}} placeholder not found" + p.write_text(s.replace('{{BUILD_VERSION}}', ver), encoding='utf-8') + print(f"injected {ver!r} into {fn}") + PY + - name: Create project and import course content run: | set -e @@ -76,14 +104,32 @@ jobs: "http://localhost:8081/website_code/php/scorm/export.php?scorm=true&template_id=${{ env.template_id }}" ls -la out/ + - name: Add VERSION.txt to the package + run: | + set -e + printf 'Secure code development\nVersion: %s\nBuilt: %s\nCommit: %s\nSource: %s\n' \ + "${{ steps.ver.outputs.version }}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "${{ github.sha }}" "${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}" \ + > out/VERSION.txt + (cd out && zip Secure_code_development_scorm.zip VERSION.txt) + echo "--- VERSION.txt in package ---" + unzip -p out/Secure_code_development_scorm.zip VERSION.txt + - name: Verify the package run: | + set -e unzip -l out/Secure_code_development_scorm.zip | head unzip -p out/Secure_code_development_scorm.zip imsmanifest.xml | grep -oE 'schemaversion>[^<]*|scormtype="[a-z]*"' unzip -p out/Secure_code_development_scorm.zip template.xml > /tmp/check.xml echo "questions: $(grep -oE ']*?)/>',x) if html.unescape(html.unescape(re.search(r'text=\"([^\"]*)\"',m.group(1)).group(1)).replace('

','').replace('

','').strip())==''))")" + # build version was injected (placeholder must be gone, label must show) + if grep -q '{{BUILD_VERSION}}' /tmp/check.xml; then + echo "ERROR: {{BUILD_VERSION}} placeholder was not substituted"; exit 1 + fi + echo "build version in About page: $(grep -oE 'Version: [^<&]*' /tmp/check.xml | head -1)" + echo "VERSION.txt in package: $(unzip -l out/Secure_code_development_scorm.zip | grep -c VERSION.txt)" - name: Upload artifact (for workflow_dispatch / fallback) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/COURSE_VERIFICATION.md b/docs/COURSE_VERIFICATION.md index 636879d..0cd6ee4 100644 --- a/docs/COURSE_VERIFICATION.md +++ b/docs/COURSE_VERIFICATION.md @@ -182,3 +182,27 @@ nets. Guards make it a no-op outside a live SCORM `normal` session. **Pending (convention 9 — re-test on LMS)**: confirm a learner attempt now shows the gradebook updating incrementally (not only on Save), and that a completed attempt still reports `lesson_status=passed` with a non-zero grade. + +## Build version stamping (`{{BUILD_VERSION}}` placeholder) + +So an operator can tell which version is running in an LMS, the About page +(page 2) carries a `Build` section with a `{{BUILD_VERSION}}` placeholder, +and the release workflow adds a `VERSION.txt` to the zip. The placeholder is +committed in `source/data.xml` + `source/preview.xml` and substituted at build +time: the **release workflow** injects `v (date, commit )` before +the XOT export (and fails the build if the token remains in the export), and +the **preview workflow** injects `preview (PR #N, commit )` before +rendering so PR preview HTML shows a clean build line. + +**Verified locally** (simulating the release workflow end-to-end through the +running XOT): substituted `{{BUILD_VERSION}}` → `v0.0.6 (2026-07-14, commit +abc1234)` in `source/data.xml` + `source/preview.xml`, pushed into XOT, +`play.php` returned 200, re-exported SCORM — the exported `template.xml` +shows the substituted `Version: v0.0.6 (…)` line on the About page, +0 `{{BUILD_VERSION}}` placeholders remain, and a `VERSION.txt` was added to +the zip root. All content/tracking attrs unchanged (45 questions, 80% pass, +`trackingMode=full`, `trackingWeight` 1×7 + 21, `unmarkForCompletion`×1, 0 +empty options, 27 `delaySecs=0`, autosave script present). A Publish +round-trip preserved the `{{BUILD_VERSION}}` placeholder intact in +`data.xml` (CKEditor does not mangle the braces). See `PROJECT_CONTEXT.md` § +"Build version stamping" for full detail. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 0a5642d..d0c9895 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -257,6 +257,43 @@ re-introducing the terminate-only commit will silently revert to would just call `doLMSCommit` a second time, which is idempotent) and can be dropped. +## Build version stamping (`{{BUILD_VERSION}}` placeholder) + +So an operator can tell **which version is running** in an LMS, the SCORM +package carries a build stamp in two places: + +1. **Visible in the player** — the About page (page 2) has a `Build` section + with a line `

Version: {{BUILD_VERSION}} …

`. The + `{{BUILD_VERSION}}` token is a **placeholder** committed in `source/data.xml` + + `source/preview.xml`. +2. **Machine-readable** — the release workflow adds a `VERSION.txt` (version, + build timestamp, full commit SHA, source tree URL) to the zip root. + +**Who substitutes the placeholder:** +- **Release workflow** (`.github/workflows/release.yml`): a `Compute build + version` step derives `v (YYYY-MM-DD, commit )` for release events + (`github.ref_name` = tag) or `dev (…, workflow_dispatch)` for manual runs; an + `Inject build version into course content` step `python3`-replaces + `{{BUILD_VERSION}}` in `source/data.xml` + `source/preview.xml` **before** the + XOT import, so the substituted value is what gets exported. The `Verify the + package` step fails the build if `{{BUILD_VERSION}}` remains in the exported + `template.xml`. +- **Preview workflow** (`.github/workflows/preview.yml`): a `Set preview build + label` step substitutes `{{BUILD_VERSION}}` → `preview (PR #N, commit )` + before `render_preview.py`, so PR preview HTML shows a clean build line instead + of the raw token. + +**Why a placeholder (not a committed version):** the version is only known at +release time (the git tag), so it cannot be baked into committed source. The +placeholder keeps `source/data.xml` version-agnostic and is a valid, static +value that round-trips through the XOT editor (verified: a Publish cycle +preserves `{{BUILD_VERSION}}` intact — CKEditor does not mangle the braces). + +**Do not remove the `{{BUILD_VERSION}}` placeholder** from the About page +without a replacement — the release workflow's verify step expects it to be +present before substitution and absent after, and that check guards against +silent version-stamping regressions. + ## SCORM scoring (how the LMS grade is computed) SCORM 1.2 reports **one** `cmi.core.score.raw` (0–100) and **one** @@ -362,6 +399,10 @@ grep -oE 'trackingMode="[a-z_]+"' /tmp/c.xml # "full" grep -oE 'delaySecs="0"' /tmp/c.xml | wc -l # 27 (all bullets pages) grep -c 'xPersistProgress' /tmp/c.xml # 1 (SCORM autosave script present on root) grep -oE 'unmarkForCompletion="true"' /tmp/c.xml | wc -l # 1 (Welcome page) +# build version: in a release export {{BUILD_VERSION}} is substituted; in source/preview it is the placeholder +grep -oE 'Version: [^<&]*' /tmp/c.xml # e.g. 'Version: v0.0.6 (...)' +grep -c '{{BUILD_VERSION}}' /tmp/c.xml # 0 in a release export (1 in committed source) +unzip -p Secure_code_development_scorm.zip VERSION.txt # present in release builds # empty options (must be 0): python3 -c "import re,html as H;x=open('/tmp/c.xml').read();print(sum(1 for m in re.finditer(r'

Secure code development is the practice of designing, writing, reviewing, and maintaining software so that it can withstand malicious or accidental misuse. It is not a final coat of paint applied after the features are finished; it shapes the requirements, the architecture, and the day-to-day coding decisions that engineers make.

diff --git a/source/preview.xml b/source/preview.xml index 182d8a0..aba5b17 100644 --- a/source/preview.xml +++ b/source/preview.xml @@ -42,6 +42,10 @@

Provenance

Generated with the pi coding-agent harness v0.79.8 using LLM models glm-5.2 and kimi k2.7-coder. Not endorsed by OWASP.

+ +

Build

+ +

Version: {{BUILD_VERSION}} — this identifies the SCORM package build so you can tell which version is running in your LMS.

]]>What is secure code development?

Secure code development is the practice of designing, writing, reviewing, and maintaining software so that it can withstand malicious or accidental misuse. It is not a final coat of paint applied after the features are finished; it shapes the requirements, the architecture, and the day-to-day coding decisions that engineers make.